Compare commits
32 Commits
2435e25aee
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 676f0ca01c | |||
| 92b6d6f1dd | |||
| 754dd8b053 | |||
| 6fa26b3ddb | |||
| a1c9832515 | |||
| 5c3057e998 | |||
| a0c90f57d5 | |||
| 5c09120c23 | |||
| 1c4aaa7040 | |||
| 2b29716fee | |||
| e1e97ef79d | |||
| 3329860b02 | |||
| 8c0d80d7fb | |||
| cef77e86b7 | |||
| a4c2c039d2 | |||
| 73d3b2a27f | |||
| 8c4f56a7ab | |||
| 49c59cbaea | |||
| 13e91fdfbf | |||
| afcbadee8b | |||
| 91c65e0861 | |||
| 735e825c7d | |||
| cdd4151462 | |||
| 76aedbaf96 | |||
| 8b2ed0e700 | |||
| 49b4162448 | |||
| e2085c1baa | |||
| be46f92ddf | |||
| 702f16f199 | |||
| 9b3f4a5fe6 | |||
| 3498a2926c | |||
| 76713db985 |
@@ -12,3 +12,4 @@ frontend/node_modules
|
||||
|
||||
backend/target
|
||||
backend/.gitignore
|
||||
backend/data
|
||||
|
||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Warren
|
||||
A lightweight self-hosted web-based file manager
|
||||
|
||||
## Installation
|
||||
> **NOTE:** Warren will create an initial admin user with the email `admin@example.com` and the password `admin1234567`. Make sure to change this user's password or delete it after you have created another user with admin privileges.
|
||||
### Docker
|
||||
Copy the repository's `compose.yaml` as a starting point. Use environment variables to configure warren to your liking.
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
target
|
||||
serve
|
||||
.env
|
||||
data
|
||||
|
||||
251
backend/Cargo.lock
generated
251
backend/Cargo.lock
generated
@@ -17,6 +17,17 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.3"
|
||||
@@ -53,6 +64,15 @@ version = "1.0.98"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -248,12 +268,23 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -278,6 +309,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -293,6 +334,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -344,6 +391,15 @@ version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-queue"
|
||||
version = "0.3.12"
|
||||
@@ -410,6 +466,12 @@ version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -430,6 +492,17 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.0.1"
|
||||
@@ -548,6 +621,17 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-rs-sys",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1036,6 +1120,15 @@ dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "io-uring"
|
||||
version = "0.7.8"
|
||||
@@ -1069,6 +1162,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.77"
|
||||
@@ -1088,12 +1191,38 @@ dependencies = [
|
||||
"spin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.174"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
|
||||
|
||||
[[package]]
|
||||
name = "liblzma"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10bf66f4598dc77ff96677c8e763655494f00ff9c1cf79e2eb5bb07bc31f807d"
|
||||
dependencies = [
|
||||
"liblzma-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "liblzma-sys"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.15"
|
||||
@@ -1106,10 +1235,20 @@ version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
|
||||
dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.9.4"
|
||||
@@ -1427,6 +1566,16 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"hmac",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -1496,6 +1645,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -1878,6 +2033,12 @@ dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.10"
|
||||
@@ -2560,6 +2721,7 @@ version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
@@ -2646,6 +2808,7 @@ dependencies = [
|
||||
"sqlx",
|
||||
"thiserror 2.0.12",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -2653,6 +2816,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3081,6 +3245,20 @@ name = "zeroize"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -3114,3 +3292,76 @@ dependencies = [
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8835eb39822904d39cb19465de1159e05d371973f0c6df3a365ad50565ddc8b9"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"constant_time_eq",
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"getrandom 0.3.3",
|
||||
"hmac",
|
||||
"indexmap",
|
||||
"liblzma",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"time",
|
||||
"zeroize",
|
||||
"zopfli",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.0.15+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ path = "src/bin/backend/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.98"
|
||||
argon2 = "0.5.3"
|
||||
argon2 = { version = "0.5.3", features = ["std"] }
|
||||
axum = { version = "0.8.4", features = ["multipart", "query"] }
|
||||
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
|
||||
base64 = "0.22.1"
|
||||
@@ -29,19 +29,15 @@ regex = "1.11.1"
|
||||
rustix = { version = "1.0.8", features = ["fs"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
"chrono",
|
||||
"postgres",
|
||||
"runtime-tokio",
|
||||
"time",
|
||||
"uuid",
|
||||
] }
|
||||
sqlx = { version = "0.8.6", features = ["chrono", "runtime-tokio", "sqlite", "time", "uuid"] }
|
||||
thiserror = "2.0.12"
|
||||
tokio = { version = "1.46.1", features = ["full"] }
|
||||
tokio-util = "0.7.15"
|
||||
tokio-stream = "0.1.17"
|
||||
tokio-util = { version = "0.7.15", features = ["io-util"] }
|
||||
tower = "0.5.2"
|
||||
tower-http = { version = "0.6.6", features = ["cors", "fs", "trace"] }
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3.19"
|
||||
url = "2.5.4"
|
||||
uuid = { version = "1.17.0", features = ["serde"] }
|
||||
uuid = { version = "1.17.0", features = ["serde", "v4"] }
|
||||
zip = "4.5.0"
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
CREATE TABLE warrens (
|
||||
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
|
||||
path VARCHAR NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_warrens_path ON warrens(path);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE warrens ADD COLUMN name VARCHAR NOT NULL;
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
|
||||
name VARCHAR NOT NULL,
|
||||
email VARCHAR NOT NULL,
|
||||
hash VARCHAR NOT NULL,
|
||||
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);
|
||||
@@ -1,6 +0,0 @@
|
||||
CREATE TABLE auth_sessions (
|
||||
session_id VARCHAR NOT NULL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
CREATE TABLE user_warrens (
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
warren_id UUID NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
|
||||
can_create_children BOOLEAN NOT NULL,
|
||||
can_list_files BOOLEAN NOT NULL,
|
||||
can_read_files BOOLEAN NOT NULL,
|
||||
can_modify_files BOOLEAN NOT NULL,
|
||||
can_delete_files BOOLEAN NOT NULL,
|
||||
can_delete_warren BOOLEAN NOT NULL,
|
||||
PRIMARY KEY(user_id, warren_id)
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE user_warrens DROP COLUMN can_create_children, DROP COLUMN can_delete_warren;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users ALTER COLUMN hash DROP NOT NULL;
|
||||
ALTER TABLE users ADD COLUMN oidc_sub VARCHAR UNIQUE;
|
||||
@@ -1,9 +0,0 @@
|
||||
CREATE TABLE shares (
|
||||
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
|
||||
creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
warren_id UUID NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
|
||||
path VARCHAR NOT NULL,
|
||||
password_hash VARCHAR NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -1,21 +0,0 @@
|
||||
ALTER TABLE
|
||||
user_warrens
|
||||
ADD COLUMN
|
||||
can_list_shares BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN
|
||||
can_create_shares BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN
|
||||
can_modify_shares BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN
|
||||
can_delete_shares BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE
|
||||
user_warrens
|
||||
ALTER COLUMN
|
||||
can_list_shares DROP DEFAULT,
|
||||
ALTER COLUMN
|
||||
can_create_shares DROP DEFAULT,
|
||||
ALTER COLUMN
|
||||
can_modify_shares DROP DEFAULT,
|
||||
ALTER COLUMN
|
||||
can_delete_shares DROP DEFAULT;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE shares ALTER COLUMN password_hash DROP NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX idx_shares_path ON shares(path);
|
||||
@@ -1,11 +0,0 @@
|
||||
INSERT INTO users (
|
||||
name,
|
||||
email,
|
||||
hash,
|
||||
admin
|
||||
) VALUES (
|
||||
'admin',
|
||||
'admin@example.com',
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$H1WsElL4921/WD5oPkY7JQ$aHudNG8z0ns3pRULfuDpuEkxPUbGxq9AHC4QGyt5odc',
|
||||
true
|
||||
);
|
||||
55
backend/migrations/20250906174941_init.sql
Normal file
55
backend/migrations/20250906174941_init.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
CREATE TABLE users (
|
||||
id BLOB NOT NULL PRIMARY KEY,
|
||||
oidc_sub TEXT UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
admin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
hash TEXT,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE warrens (
|
||||
id BLOB NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_warrens (
|
||||
user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
warren_id BLOB NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
|
||||
can_list_files BOOLEAN NOT NULL,
|
||||
can_read_files BOOLEAN NOT NULL,
|
||||
can_modify_files BOOLEAN NOT NULL,
|
||||
can_delete_files BOOLEAN NOT NULL,
|
||||
can_list_shares BOOLEAN NOT NULL,
|
||||
can_create_shares BOOLEAN NOT NULL,
|
||||
can_modify_shares BOOLEAN NOT NULL,
|
||||
can_delete_shares BOOLEAN NOT NULL,
|
||||
PRIMARY KEY(user_id, warren_id)
|
||||
);
|
||||
|
||||
CREATE TABLE shares (
|
||||
id BLOB NOT NULL PRIMARY KEY,
|
||||
creator_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
warren_id BLOB NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_shares_path ON shares(path);
|
||||
|
||||
CREATE TABLE auth_sessions (
|
||||
session_id TEXT NOT NULL PRIMARY KEY,
|
||||
user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE application_options (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
@@ -7,11 +7,11 @@ use warren::{
|
||||
metrics_debug_logger::MetricsDebugLogger,
|
||||
notifier_debug_logger::NotifierDebugLogger,
|
||||
oidc::{Oidc, OidcConfig},
|
||||
postgres::{Postgres, PostgresConfig},
|
||||
sqlite::{Sqlite, SqliteConfig},
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
@@ -25,16 +25,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
let metrics = MetricsDebugLogger::new();
|
||||
let notifier = NotifierDebugLogger::new();
|
||||
|
||||
let postgres_config =
|
||||
PostgresConfig::new(config.database_url.clone(), config.database_name.clone());
|
||||
let postgres = Postgres::new(postgres_config).await?;
|
||||
let sqlite_config = SqliteConfig::new(config.database_url.clone());
|
||||
let sqlite = Sqlite::new(sqlite_config).await?;
|
||||
|
||||
let fs_config = FileSystemConfig::from_env(config.serve_dir.clone())?;
|
||||
let fs = FileSystem::new(fs_config)?;
|
||||
let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier);
|
||||
|
||||
let warren_service = domain::warren::service::warren::Service::new(
|
||||
postgres.clone(),
|
||||
sqlite.clone(),
|
||||
metrics,
|
||||
notifier,
|
||||
fs_service.clone(),
|
||||
@@ -47,13 +46,18 @@ async fn main() -> anyhow::Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let option_service =
|
||||
domain::warren::service::option::Service::new(sqlite.clone(), metrics, notifier);
|
||||
|
||||
let auth_service = domain::warren::service::auth::Service::new(
|
||||
postgres,
|
||||
sqlite,
|
||||
metrics,
|
||||
notifier,
|
||||
config.auth,
|
||||
oidc_service,
|
||||
);
|
||||
option_service,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let server_config = HttpServerConfig::new(
|
||||
&config.server_address,
|
||||
|
||||
@@ -6,7 +6,6 @@ use tracing::level_filters::LevelFilter;
|
||||
use crate::domain::warren::service::auth::AuthConfig;
|
||||
|
||||
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";
|
||||
@@ -28,7 +27,6 @@ pub struct Config {
|
||||
pub static_frontend_dir: Option<String>,
|
||||
|
||||
pub database_url: String,
|
||||
pub database_name: String,
|
||||
|
||||
pub log_level: LevelFilter,
|
||||
|
||||
@@ -45,7 +43,6 @@ impl Config {
|
||||
let static_frontend_dir = Self::load_env(STATIC_FRONTEND_DIRECTORY).ok();
|
||||
|
||||
let database_url = Self::load_env(DATABASE_URL_KEY)?;
|
||||
let database_name = Self::load_env(DATABASE_NAME_KEY)?;
|
||||
|
||||
let log_level =
|
||||
LevelFilter::from_str(&Self::load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string()))
|
||||
@@ -62,7 +59,6 @@ impl Config {
|
||||
static_frontend_dir,
|
||||
|
||||
database_url,
|
||||
database_name,
|
||||
|
||||
log_level,
|
||||
|
||||
|
||||
@@ -33,6 +33,24 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NoopService {}
|
||||
impl OidcService for NoopService {
|
||||
async fn get_redirect(
|
||||
&self,
|
||||
_: GetRedirectRequest,
|
||||
) -> Result<GetRedirectResponse, GetRedirectError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
async fn get_user_info(
|
||||
&self,
|
||||
_: GetUserInfoRequest,
|
||||
) -> Result<GetUserInfoResponse, GetUserInfoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, M, N> OidcService for Service<R, M, N>
|
||||
where
|
||||
R: OidcRepository,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod requests;
|
||||
use bytes::Bytes;
|
||||
use futures_util::Stream;
|
||||
pub use requests::*;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -13,6 +14,7 @@ pub struct File {
|
||||
name: FileName,
|
||||
file_type: FileType,
|
||||
mime_type: Option<FileMimeType>,
|
||||
size: FileSize,
|
||||
created_at: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -21,12 +23,14 @@ impl File {
|
||||
name: FileName,
|
||||
file_type: FileType,
|
||||
mime_type: Option<FileMimeType>,
|
||||
size: FileSize,
|
||||
created_at: Option<u64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
file_type,
|
||||
mime_type,
|
||||
size,
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +47,10 @@ impl File {
|
||||
self.mime_type.as_ref()
|
||||
}
|
||||
|
||||
pub fn size(&self) -> FileSize {
|
||||
self.size
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> Option<u64> {
|
||||
self.created_at
|
||||
}
|
||||
@@ -80,8 +88,23 @@ impl FileName {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
|
||||
pub struct FileSize(u64);
|
||||
|
||||
impl FileSize {
|
||||
pub fn new(value: u64) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileSize> for u64 {
|
||||
fn from(value: FileSize) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid file type
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Display)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Display)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum FileType {
|
||||
File,
|
||||
@@ -93,11 +116,19 @@ pub enum FileType {
|
||||
pub struct FileMimeType(String);
|
||||
|
||||
impl FileMimeType {
|
||||
pub fn new_raw(raw: &str) -> Self {
|
||||
Self(raw.to_owned())
|
||||
}
|
||||
|
||||
pub fn from_name(name: &str) -> Option<Self> {
|
||||
mime_guess::from_path(name)
|
||||
.first_raw()
|
||||
.map(|s| Self(s.to_owned()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid file path that might start with a slash
|
||||
@@ -263,21 +294,42 @@ impl From<AbsoluteFilePath> for FilePath {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileStream(ReaderStream<tokio::fs::File>);
|
||||
pub type FileStreamInner =
|
||||
Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send + Sync + Unpin + 'static>;
|
||||
|
||||
pub struct FileStream {
|
||||
file_type: FileType,
|
||||
mime_type: Option<FileMimeType>,
|
||||
stream: FileStreamInner,
|
||||
}
|
||||
|
||||
impl FileStream {
|
||||
pub fn new(stream: ReaderStream<tokio::fs::File>) -> Self {
|
||||
Self(stream)
|
||||
pub fn new<S>(file_type: FileType, mime_type: Option<FileMimeType>, stream: S) -> Self
|
||||
where
|
||||
S: Stream<Item = Result<Bytes, std::io::Error>> + Send + Sync + Unpin + 'static,
|
||||
{
|
||||
Self {
|
||||
file_type,
|
||||
mime_type,
|
||||
stream: Box::new(stream),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream(&self) -> &ReaderStream<tokio::fs::File> {
|
||||
&self.0
|
||||
pub fn file_type(&self) -> FileType {
|
||||
self.file_type
|
||||
}
|
||||
|
||||
pub fn mime_type(&self) -> Option<&FileMimeType> {
|
||||
self.mime_type.as_ref()
|
||||
}
|
||||
|
||||
pub fn stream(&self) -> &FileStreamInner {
|
||||
&self.stream
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileStream> for ReaderStream<tokio::fs::File> {
|
||||
impl From<FileStream> for FileStreamInner {
|
||||
fn from(value: FileStream) -> Self {
|
||||
value.0
|
||||
value.stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::warren::models::file::AbsoluteFilePath;
|
||||
use super::AbsoluteFilePathList;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct CatRequest {
|
||||
path: AbsoluteFilePath,
|
||||
paths: AbsoluteFilePathList,
|
||||
}
|
||||
|
||||
impl CatRequest {
|
||||
pub fn new(path: AbsoluteFilePath) -> Self {
|
||||
Self { path }
|
||||
pub fn new(paths: AbsoluteFilePathList) -> Self {
|
||||
Self { paths }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
pub fn paths(&self) -> &AbsoluteFilePathList {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
pub fn into_path(self) -> AbsoluteFilePath {
|
||||
self.path
|
||||
pub fn into_paths(self) -> AbsoluteFilePathList {
|
||||
self.paths
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CatError {
|
||||
#[error("The file does not exist")]
|
||||
#[error("A file does not exist")]
|
||||
NotFound,
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
|
||||
@@ -16,4 +16,40 @@ pub use mv::*;
|
||||
pub use rm::*;
|
||||
pub use save::*;
|
||||
pub use stat::*;
|
||||
use thiserror::Error;
|
||||
pub use touch::*;
|
||||
|
||||
use super::AbsoluteFilePath;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct AbsoluteFilePathList(Vec<AbsoluteFilePath>);
|
||||
|
||||
impl From<AbsoluteFilePathList> for Vec<AbsoluteFilePath> {
|
||||
fn from(value: AbsoluteFilePathList) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AbsoluteFilePathList {
|
||||
pub fn new(paths: Vec<AbsoluteFilePath>) -> Result<Self, AbsoluteFilePathListError> {
|
||||
if paths.is_empty() {
|
||||
return Err(AbsoluteFilePathListError::Empty);
|
||||
}
|
||||
|
||||
Ok(Self(paths))
|
||||
}
|
||||
|
||||
pub fn paths(&self) -> &Vec<AbsoluteFilePath> {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn paths_mut(&mut self) -> &mut Vec<AbsoluteFilePath> {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
pub enum AbsoluteFilePathListError {
|
||||
#[error("A list must not be empty")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
@@ -2,36 +2,38 @@ use thiserror::Error;
|
||||
|
||||
use crate::domain::warren::models::file::AbsoluteFilePath;
|
||||
|
||||
use super::AbsoluteFilePathList;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct MvRequest {
|
||||
path: AbsoluteFilePath,
|
||||
paths: AbsoluteFilePathList,
|
||||
target_path: AbsoluteFilePath,
|
||||
}
|
||||
|
||||
impl MvRequest {
|
||||
pub fn new(path: AbsoluteFilePath, target_path: AbsoluteFilePath) -> Self {
|
||||
Self { path, target_path }
|
||||
pub fn new(paths: AbsoluteFilePathList, target_path: AbsoluteFilePath) -> Self {
|
||||
Self { paths, target_path }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
pub fn paths(&self) -> &AbsoluteFilePathList {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
pub fn target_path(&self) -> &AbsoluteFilePath {
|
||||
&self.target_path
|
||||
}
|
||||
|
||||
pub fn unpack(self) -> (AbsoluteFilePath, AbsoluteFilePath) {
|
||||
(self.path, self.target_path)
|
||||
pub fn unpack(self) -> (AbsoluteFilePathList, AbsoluteFilePath) {
|
||||
(self.paths, self.target_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MvError {
|
||||
#[error("The path does not exist")]
|
||||
NotFound,
|
||||
NotFound(AbsoluteFilePath),
|
||||
#[error("The target path already exists")]
|
||||
AlreadyExists,
|
||||
AlreadyExists(AbsoluteFilePath),
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::warren::models::file::AbsoluteFilePath;
|
||||
use crate::domain::warren::models::file::{AbsoluteFilePath, AbsoluteFilePathList};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct RmRequest {
|
||||
path: AbsoluteFilePath,
|
||||
paths: AbsoluteFilePathList,
|
||||
force: bool,
|
||||
}
|
||||
|
||||
impl RmRequest {
|
||||
pub fn new(path: AbsoluteFilePath, force: bool) -> Self {
|
||||
Self { path, force }
|
||||
pub fn new(paths: AbsoluteFilePathList, force: bool) -> Self {
|
||||
Self { paths, force }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
pub fn paths(&self) -> &AbsoluteFilePathList {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
pub fn into_path(self) -> AbsoluteFilePath {
|
||||
self.path
|
||||
pub fn into_paths(self) -> AbsoluteFilePathList {
|
||||
self.paths
|
||||
}
|
||||
|
||||
pub fn force(&self) -> bool {
|
||||
@@ -28,10 +28,10 @@ impl RmRequest {
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum RmError {
|
||||
#[error("The path does not exist")]
|
||||
NotFound,
|
||||
#[error("The directory is not empty")]
|
||||
NotEmpty,
|
||||
#[error("At least one file does not exist")]
|
||||
NotFound(AbsoluteFilePath),
|
||||
#[error("At least one directory is not empty")]
|
||||
NotEmpty(AbsoluteFilePath),
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod auth_session;
|
||||
pub mod file;
|
||||
pub mod option;
|
||||
pub mod share;
|
||||
pub mod user;
|
||||
pub mod user_warren;
|
||||
|
||||
74
backend/src/lib/domain/warren/models/option/mod.rs
Normal file
74
backend/src/lib/domain/warren/models/option/mod.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
mod requests;
|
||||
use derive_more::Display;
|
||||
pub use requests::*;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
|
||||
pub struct OptionKey(String);
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum OptionKeyError {
|
||||
#[error("An OptionKey must not be empty")]
|
||||
Empty,
|
||||
}
|
||||
|
||||
impl OptionKey {
|
||||
pub fn new(raw: &str) -> Result<Self, OptionKeyError> {
|
||||
let raw = raw.trim();
|
||||
|
||||
if raw.is_empty() {
|
||||
return Err(OptionKeyError::Empty);
|
||||
}
|
||||
|
||||
Ok(Self(raw.to_string()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OptionValue<T>(T)
|
||||
where
|
||||
T: OptionType;
|
||||
|
||||
impl<T> OptionValue<T>
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
pub fn new(value: T) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
|
||||
pub fn inner(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn get_inner(self) -> T {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
pub trait OptionType: std::fmt::Debug + Clone + Send + Sync {
|
||||
type Error: std::fmt::Debug;
|
||||
|
||||
fn parse(raw: &str) -> Result<Self, Self::Error>;
|
||||
fn to_string(&self) -> String;
|
||||
}
|
||||
|
||||
impl OptionType for bool {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn parse(raw: &str) -> Result<Self, Self::Error> {
|
||||
Ok(match raw.to_lowercase().as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => anyhow::bail!("Expected 'true' or 'false': {raw}"),
|
||||
})
|
||||
}
|
||||
|
||||
fn to_string(&self) -> String {
|
||||
if *self { "true" } else { "false" }.to_string()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::warren::models::option::OptionKey;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DeleteOptionRequest {
|
||||
key: OptionKey,
|
||||
}
|
||||
|
||||
impl DeleteOptionRequest {
|
||||
pub fn new(key: OptionKey) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &OptionKey {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeleteOptionRequest> for OptionKey {
|
||||
fn from(value: DeleteOptionRequest) -> Self {
|
||||
value.key
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DeleteOptionResponse {
|
||||
key: OptionKey,
|
||||
}
|
||||
|
||||
impl DeleteOptionResponse {
|
||||
pub fn new(key: OptionKey) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &OptionKey {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DeleteOptionError {
|
||||
#[error("Could not find option with key: {0}")]
|
||||
NotFound(OptionKey),
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
57
backend/src/lib/domain/warren/models/option/requests/get.rs
Normal file
57
backend/src/lib/domain/warren/models/option/requests/get.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::warren::models::option::{OptionKey, OptionType};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct GetOptionRequest {
|
||||
key: OptionKey,
|
||||
}
|
||||
|
||||
impl GetOptionRequest {
|
||||
pub fn new(key: OptionKey) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &OptionKey {
|
||||
&self.key
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GetOptionRequest> for OptionKey {
|
||||
fn from(value: GetOptionRequest) -> Self {
|
||||
value.key
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GetOptionResponse<T: OptionType> {
|
||||
key: OptionKey,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl<T> GetOptionResponse<T>
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
pub fn new(key: OptionKey, value: T) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &OptionKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &T {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum GetOptionError {
|
||||
#[error("Could not find option with key: {0}")]
|
||||
NotFound(OptionKey),
|
||||
#[error("Could not parse the option value with the specified type")]
|
||||
Parse,
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod delete;
|
||||
mod get;
|
||||
mod set;
|
||||
pub use delete::*;
|
||||
pub use get::*;
|
||||
pub use set::*;
|
||||
83
backend/src/lib/domain/warren/models/option/requests/set.rs
Normal file
83
backend/src/lib/domain/warren/models/option/requests/set.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::domain::warren::models::option::{OptionKey, OptionType, OptionValue};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SetOptionRequest<T>
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
key: OptionKey,
|
||||
value: OptionValue<T>,
|
||||
}
|
||||
|
||||
impl<T> SetOptionRequest<T>
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
pub fn new(key: OptionKey, value: T) -> Self {
|
||||
Self {
|
||||
key,
|
||||
value: OptionValue::new(value),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &OptionKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &OptionValue<T> {
|
||||
&self.value
|
||||
}
|
||||
|
||||
pub fn unpack(self) -> (OptionKey, OptionValue<T>) {
|
||||
(self.key, self.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<SetOptionRequest<T>> for OptionKey
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
fn from(value: SetOptionRequest<T>) -> Self {
|
||||
value.key
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<SetOptionRequest<T>> for OptionValue<T>
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
fn from(value: SetOptionRequest<T>) -> Self {
|
||||
value.value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SetOptionResponse<T: OptionType> {
|
||||
key: OptionKey,
|
||||
value: T,
|
||||
}
|
||||
|
||||
impl<T> SetOptionResponse<T>
|
||||
where
|
||||
T: OptionType,
|
||||
{
|
||||
pub fn new(key: OptionKey, value: T) -> Self {
|
||||
Self { key, value }
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &OptionKey {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn value(&self) -> &T {
|
||||
&self.value
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SetOptionError {
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::warren::models::{
|
||||
file::{AbsoluteFilePath, CatRequest, FileStream},
|
||||
file::{AbsoluteFilePathList, CatRequest, FileStream},
|
||||
share::{Share, SharePassword},
|
||||
warren::{FetchWarrenError, Warren, WarrenCatError, WarrenCatRequest},
|
||||
};
|
||||
@@ -12,16 +12,16 @@ use super::{VerifySharePasswordError, VerifySharePasswordRequest};
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ShareCatRequest {
|
||||
share_id: Uuid,
|
||||
path: AbsoluteFilePath,
|
||||
base: CatRequest,
|
||||
password: Option<SharePassword>,
|
||||
}
|
||||
|
||||
impl ShareCatRequest {
|
||||
pub fn new(share_id: Uuid, path: AbsoluteFilePath, password: Option<SharePassword>) -> Self {
|
||||
pub fn new(share_id: Uuid, password: Option<SharePassword>, base: CatRequest) -> Self {
|
||||
Self {
|
||||
share_id,
|
||||
path,
|
||||
password,
|
||||
base,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,18 +29,23 @@ impl ShareCatRequest {
|
||||
&self.share_id
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn password(&self) -> Option<&SharePassword> {
|
||||
self.password.as_ref()
|
||||
}
|
||||
|
||||
pub fn build_warren_cat_request(self, share: &Share, warren: &Warren) -> WarrenCatRequest {
|
||||
let path = share.path().clone().join(&self.path.to_relative());
|
||||
pub fn base(&self) -> &CatRequest {
|
||||
&self.base
|
||||
}
|
||||
|
||||
WarrenCatRequest::new(*warren.id(), CatRequest::new(path))
|
||||
pub fn build_warren_cat_request(self, share: &Share, warren: &Warren) -> WarrenCatRequest {
|
||||
let mut paths = self.base.into_paths();
|
||||
|
||||
paths
|
||||
.paths_mut()
|
||||
.into_iter()
|
||||
.for_each(|path| *path = share.path.clone().join(&path.clone().to_relative()));
|
||||
|
||||
WarrenCatRequest::new(*warren.id(), CatRequest::new(paths))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,20 +55,24 @@ impl From<&ShareCatRequest> for VerifySharePasswordRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ShareCatResponse {
|
||||
share: Share,
|
||||
warren: Warren,
|
||||
path: AbsoluteFilePath,
|
||||
paths: AbsoluteFilePathList,
|
||||
stream: FileStream,
|
||||
}
|
||||
|
||||
impl ShareCatResponse {
|
||||
pub fn new(share: Share, warren: Warren, path: AbsoluteFilePath, stream: FileStream) -> Self {
|
||||
pub fn new(
|
||||
share: Share,
|
||||
warren: Warren,
|
||||
paths: AbsoluteFilePathList,
|
||||
stream: FileStream,
|
||||
) -> Self {
|
||||
Self {
|
||||
share,
|
||||
warren,
|
||||
path,
|
||||
paths,
|
||||
stream,
|
||||
}
|
||||
}
|
||||
@@ -76,8 +85,8 @@ impl ShareCatResponse {
|
||||
&self.warren
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
pub fn paths(&self) -> &AbsoluteFilePathList {
|
||||
&self.paths
|
||||
}
|
||||
|
||||
pub fn stream(&self) -> &FileStream {
|
||||
|
||||
@@ -9,6 +9,8 @@ pub struct RegisterUserRequest {
|
||||
name: UserName,
|
||||
email: UserEmail,
|
||||
password: UserPassword,
|
||||
bypass_registration_flag: bool,
|
||||
admin: bool,
|
||||
}
|
||||
|
||||
impl RegisterUserRequest {
|
||||
@@ -17,6 +19,23 @@ impl RegisterUserRequest {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
bypass_registration_flag: false,
|
||||
admin: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_bypass_flag(
|
||||
name: UserName,
|
||||
email: UserEmail,
|
||||
password: UserPassword,
|
||||
admin: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
bypass_registration_flag: true,
|
||||
admin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +50,19 @@ impl RegisterUserRequest {
|
||||
pub fn password(&self) -> &UserPassword {
|
||||
&self.password
|
||||
}
|
||||
|
||||
pub fn admin(&self) -> bool {
|
||||
self.admin
|
||||
}
|
||||
|
||||
pub fn bypass_registration_flag(&self) -> bool {
|
||||
self.bypass_registration_flag
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RegisterUserRequest> for CreateUserRequest {
|
||||
fn from(value: RegisterUserRequest) -> Self {
|
||||
Self::new(value.name, value.email, value.password, false)
|
||||
Self::new(value.name, value.email, value.password, value.admin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,12 +221,15 @@ impl WarrenRmRequest {
|
||||
|
||||
pub fn build_fs_request(self, warren: &Warren) -> RmRequest {
|
||||
let force = self.base.force();
|
||||
let path = warren
|
||||
.path()
|
||||
.clone()
|
||||
.join(&self.base.into_path().to_relative());
|
||||
|
||||
RmRequest::new(path, force)
|
||||
let mut paths = self.base.into_paths();
|
||||
|
||||
paths
|
||||
.paths_mut()
|
||||
.into_iter()
|
||||
.for_each(|path| *path = warren.path.clone().join(&path.clone().to_relative()));
|
||||
|
||||
RmRequest::new(paths, force)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,34 +245,30 @@ impl Into<FetchWarrenRequest> for &WarrenRmRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(Debug)]
|
||||
pub struct WarrenRmResponse {
|
||||
warren: Warren,
|
||||
path: AbsoluteFilePath,
|
||||
results: Vec<Result<AbsoluteFilePath, RmError>>,
|
||||
}
|
||||
|
||||
impl WarrenRmResponse {
|
||||
pub fn new(warren: Warren, path: AbsoluteFilePath) -> Self {
|
||||
Self { warren, path }
|
||||
pub fn new(warren: Warren, results: Vec<Result<AbsoluteFilePath, RmError>>) -> Self {
|
||||
Self { warren, results }
|
||||
}
|
||||
|
||||
pub fn warren(&self) -> &Warren {
|
||||
&self.warren
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
pub fn results(&self) -> &Vec<Result<AbsoluteFilePath, RmError>> {
|
||||
&self.results
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WarrenRmError {
|
||||
#[error(transparent)]
|
||||
FileSystem(#[from] RmError),
|
||||
#[error(transparent)]
|
||||
FetchWarren(#[from] FetchWarrenError),
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub struct WarrenSaveRequest<'s> {
|
||||
@@ -423,11 +422,16 @@ impl WarrenMvRequest {
|
||||
}
|
||||
|
||||
pub fn build_fs_request(self, warren: &Warren) -> MvRequest {
|
||||
let (base_path, base_target_path) = self.base.unpack();
|
||||
let path = warren.path().clone().join(&base_path.to_relative());
|
||||
let (mut base_paths, base_target_path) = self.base.unpack();
|
||||
|
||||
let target_path = warren.path().clone().join(&base_target_path.to_relative());
|
||||
|
||||
MvRequest::new(path, target_path)
|
||||
base_paths
|
||||
.paths_mut()
|
||||
.into_iter()
|
||||
.for_each(|path| *path = warren.path.clone().join(&path.clone().to_relative()));
|
||||
|
||||
MvRequest::new(base_paths, target_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,32 +447,26 @@ impl Into<FetchWarrenRequest> for &WarrenMvRequest {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[derive(Debug)]
|
||||
pub struct WarrenMvResponse {
|
||||
warren: Warren,
|
||||
old_path: AbsoluteFilePath,
|
||||
path: AbsoluteFilePath,
|
||||
results: Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>,
|
||||
}
|
||||
|
||||
impl WarrenMvResponse {
|
||||
pub fn new(warren: Warren, old_path: AbsoluteFilePath, path: AbsoluteFilePath) -> Self {
|
||||
Self {
|
||||
warren,
|
||||
old_path,
|
||||
path,
|
||||
}
|
||||
pub fn new(
|
||||
warren: Warren,
|
||||
results: Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>,
|
||||
) -> Self {
|
||||
Self { warren, results }
|
||||
}
|
||||
|
||||
pub fn warren(&self) -> &Warren {
|
||||
&self.warren
|
||||
}
|
||||
|
||||
pub fn old_path(&self) -> &AbsoluteFilePath {
|
||||
&self.old_path
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &AbsoluteFilePath {
|
||||
&self.path
|
||||
pub fn results(&self) -> &Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>> {
|
||||
&self.results
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,8 +475,6 @@ pub enum WarrenMvError {
|
||||
#[error(transparent)]
|
||||
FetchWarren(#[from] FetchWarrenError),
|
||||
#[error(transparent)]
|
||||
FileSystem(#[from] MvError),
|
||||
#[error(transparent)]
|
||||
Unknown(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
@@ -597,12 +593,14 @@ impl WarrenCatRequest {
|
||||
}
|
||||
|
||||
pub fn build_fs_request(self, warren: &Warren) -> CatRequest {
|
||||
let path = warren
|
||||
.path()
|
||||
.clone()
|
||||
.join(&self.base.into_path().to_relative());
|
||||
let mut paths = self.base.into_paths();
|
||||
|
||||
CatRequest::new(path)
|
||||
paths
|
||||
.paths_mut()
|
||||
.into_iter()
|
||||
.for_each(|path| *path = warren.path.clone().join(&path.clone().to_relative()));
|
||||
|
||||
CatRequest::new(paths)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ pub trait WarrenMetrics: Clone + Send + Sync + 'static {
|
||||
|
||||
fn record_warren_share_cat_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_warren_share_cat_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn record_warren_share_password_verification_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_warren_share_password_verification_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
pub trait FileSystemMetrics: Clone + Send + Sync + 'static {
|
||||
@@ -182,3 +185,14 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static {
|
||||
fn record_auth_share_deletion_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_auth_share_deletion_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
pub trait OptionMetrics: Clone + Send + Sync + 'static {
|
||||
fn record_option_get_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_option_get_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn record_option_set_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_option_set_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn record_option_delete_success(&self) -> impl Future<Output = ()> + Send;
|
||||
fn record_option_delete_failure(&self) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
@@ -15,15 +15,22 @@ use super::models::{
|
||||
},
|
||||
},
|
||||
file::{
|
||||
CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest,
|
||||
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError,
|
||||
SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest,
|
||||
AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream,
|
||||
LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError,
|
||||
RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse,
|
||||
TouchError, TouchRequest,
|
||||
},
|
||||
option::{
|
||||
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
|
||||
GetOptionRequest, GetOptionResponse, OptionType, SetOptionError, SetOptionRequest,
|
||||
SetOptionResponse,
|
||||
},
|
||||
share::{
|
||||
CreateShareBaseRequest, CreateShareError, CreateShareRequest, CreateShareResponse,
|
||||
DeleteShareError, DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest,
|
||||
GetShareResponse, ListSharesError, ListSharesRequest, ListSharesResponse, ShareCatError,
|
||||
ShareCatRequest, ShareCatResponse, ShareLsError, ShareLsRequest, ShareLsResponse,
|
||||
VerifySharePasswordError, VerifySharePasswordRequest, VerifySharePasswordResponse,
|
||||
},
|
||||
user::{
|
||||
CreateUserError, CreateUserRequest, DeleteUserError, DeleteUserRequest, EditUserError,
|
||||
@@ -137,6 +144,10 @@ pub trait WarrenService: Clone + Send + Sync + 'static {
|
||||
&self,
|
||||
request: ShareCatRequest,
|
||||
) -> impl Future<Output = Result<ShareCatResponse, ShareCatError>> + Send;
|
||||
fn verify_warren_share_password(
|
||||
&self,
|
||||
request: VerifySharePasswordRequest,
|
||||
) -> impl Future<Output = Result<VerifySharePasswordResponse, VerifySharePasswordError>> + Send;
|
||||
}
|
||||
|
||||
pub trait FileSystemService: Clone + Send + Sync + 'static {
|
||||
@@ -144,8 +155,14 @@ pub trait FileSystemService: Clone + Send + Sync + 'static {
|
||||
fn cat(&self, request: CatRequest)
|
||||
-> impl Future<Output = Result<FileStream, CatError>> + Send;
|
||||
fn mkdir(&self, request: MkdirRequest) -> impl Future<Output = Result<(), MkdirError>> + Send;
|
||||
fn rm(&self, request: RmRequest) -> impl Future<Output = Result<(), RmError>> + Send;
|
||||
fn mv(&self, request: MvRequest) -> impl Future<Output = Result<(), MvError>> + Send;
|
||||
fn rm(
|
||||
&self,
|
||||
request: RmRequest,
|
||||
) -> impl Future<Output = Vec<Result<AbsoluteFilePath, RmError>>> + Send;
|
||||
fn mv(
|
||||
&self,
|
||||
request: MvRequest,
|
||||
) -> impl Future<Output = Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>> + Send;
|
||||
fn save(
|
||||
&self,
|
||||
request: SaveRequest,
|
||||
@@ -325,3 +342,18 @@ pub trait AuthService: Clone + Send + Sync + 'static {
|
||||
warren_service: &WS,
|
||||
) -> impl Future<Output = Result<DeleteShareResponse, AuthError<DeleteShareError>>> + Send;
|
||||
}
|
||||
|
||||
pub trait OptionService: Clone + Send + Sync + 'static {
|
||||
fn get_option<T: OptionType>(
|
||||
&self,
|
||||
request: GetOptionRequest,
|
||||
) -> impl Future<Output = Result<GetOptionResponse<T>, GetOptionError>> + Send;
|
||||
fn set_option<T: OptionType>(
|
||||
&self,
|
||||
request: SetOptionRequest<T>,
|
||||
) -> impl Future<Output = Result<SetOptionResponse<T>, SetOptionError>> + Send;
|
||||
fn delete_option(
|
||||
&self,
|
||||
request: DeleteOptionRequest,
|
||||
) -> impl Future<Output = Result<DeleteOptionResponse, DeleteOptionError>> + Send;
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ use uuid::Uuid;
|
||||
|
||||
use crate::domain::warren::models::{
|
||||
auth_session::requests::FetchAuthSessionResponse,
|
||||
file::{AbsoluteFilePath, LsResponse},
|
||||
file::{AbsoluteFilePath, AbsoluteFilePathList, LsResponse},
|
||||
option::{DeleteOptionResponse, GetOptionResponse, OptionType, SetOptionResponse},
|
||||
share::{
|
||||
CreateShareResponse, DeleteShareResponse, GetShareResponse, ListSharesResponse,
|
||||
ShareCatResponse, ShareLsResponse,
|
||||
ShareCatResponse, ShareLsResponse, VerifySharePasswordResponse,
|
||||
},
|
||||
user::{ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User},
|
||||
user_warren::UserWarren,
|
||||
@@ -28,7 +29,7 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static {
|
||||
fn warren_cat(
|
||||
&self,
|
||||
warren: &Warren,
|
||||
path: &AbsoluteFilePath,
|
||||
path: &AbsoluteFilePathList,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
fn warren_mkdir(&self, response: &WarrenMkdirResponse) -> impl Future<Output = ()> + Send;
|
||||
fn warren_rm(&self, response: &WarrenRmResponse) -> impl Future<Output = ()> + Send;
|
||||
@@ -64,11 +65,15 @@ pub trait WarrenNotifier: Clone + Send + Sync + 'static {
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
fn warren_share_ls(&self, response: &ShareLsResponse) -> impl Future<Output = ()> + Send;
|
||||
fn warren_share_cat(&self, response: &ShareCatResponse) -> impl Future<Output = ()> + Send;
|
||||
fn warren_share_password_verified(
|
||||
&self,
|
||||
response: &VerifySharePasswordResponse,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
pub trait FileSystemNotifier: Clone + Send + Sync + 'static {
|
||||
fn ls(&self, response: &LsResponse) -> impl Future<Output = ()> + Send;
|
||||
fn cat(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + Send;
|
||||
fn cat(&self, paths: &AbsoluteFilePathList) -> impl Future<Output = ()> + Send;
|
||||
fn mkdir(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + Send;
|
||||
fn rm(&self, path: &AbsoluteFilePath) -> impl Future<Output = ()> + Send;
|
||||
fn mv(
|
||||
@@ -163,7 +168,7 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static {
|
||||
&self,
|
||||
user: &User,
|
||||
warren_id: &Uuid,
|
||||
path: &AbsoluteFilePath,
|
||||
paths: &AbsoluteFilePathList,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
fn auth_warren_mkdir(
|
||||
&self,
|
||||
@@ -215,3 +220,15 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static {
|
||||
response: &DeleteShareResponse,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
pub trait OptionNotifier: Clone + Send + Sync + 'static {
|
||||
fn got_option<T: OptionType>(
|
||||
&self,
|
||||
response: &GetOptionResponse<T>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
fn set_option<T: OptionType>(
|
||||
&self,
|
||||
response: &SetOptionResponse<T>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
fn deleted_option(&self, response: &DeleteOptionResponse) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
@@ -7,9 +7,15 @@ use crate::domain::warren::models::{
|
||||
},
|
||||
},
|
||||
file::{
|
||||
CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest,
|
||||
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError,
|
||||
SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest,
|
||||
AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream,
|
||||
LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError,
|
||||
RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse,
|
||||
TouchError, TouchRequest,
|
||||
},
|
||||
option::{
|
||||
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
|
||||
GetOptionRequest, GetOptionResponse, OptionType, SetOptionError, SetOptionRequest,
|
||||
SetOptionResponse,
|
||||
},
|
||||
share::{
|
||||
CreateShareError, CreateShareRequest, CreateShareResponse, DeleteShareError,
|
||||
@@ -97,8 +103,14 @@ pub trait FileSystemRepository: Clone + Send + Sync + 'static {
|
||||
fn cat(&self, request: CatRequest)
|
||||
-> impl Future<Output = Result<FileStream, CatError>> + Send;
|
||||
fn mkdir(&self, request: MkdirRequest) -> impl Future<Output = Result<(), MkdirError>> + Send;
|
||||
fn rm(&self, request: RmRequest) -> impl Future<Output = Result<(), RmError>> + Send;
|
||||
fn mv(&self, request: MvRequest) -> impl Future<Output = Result<(), MvError>> + Send;
|
||||
fn rm(
|
||||
&self,
|
||||
request: RmRequest,
|
||||
) -> impl Future<Output = Vec<Result<AbsoluteFilePath, RmError>>> + Send;
|
||||
fn mv(
|
||||
&self,
|
||||
request: MvRequest,
|
||||
) -> impl Future<Output = Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>>> + Send;
|
||||
fn save(
|
||||
&self,
|
||||
request: SaveRequest,
|
||||
@@ -181,3 +193,18 @@ pub trait AuthRepository: Clone + Send + Sync + 'static {
|
||||
request: FetchUserWarrenRequest,
|
||||
) -> impl Future<Output = Result<UserWarren, FetchUserWarrenError>> + Send;
|
||||
}
|
||||
|
||||
pub trait OptionRepository: Clone + Send + Sync + 'static {
|
||||
fn get_option<T: OptionType>(
|
||||
&self,
|
||||
request: GetOptionRequest,
|
||||
) -> impl Future<Output = Result<GetOptionResponse<T>, GetOptionError>> + Send;
|
||||
fn set_option<T: OptionType>(
|
||||
&self,
|
||||
request: SetOptionRequest<T>,
|
||||
) -> impl Future<Output = Result<SetOptionResponse<T>, SetOptionError>> + Send;
|
||||
fn delete_option(
|
||||
&self,
|
||||
request: DeleteOptionRequest,
|
||||
) -> impl Future<Output = Result<DeleteOptionResponse, DeleteOptionError>> + Send;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
},
|
||||
},
|
||||
file::FileStream,
|
||||
option::{GetOptionError, GetOptionRequest, OptionKey, SetOptionRequest},
|
||||
share::{
|
||||
CreateShareBaseRequest, CreateShareError, CreateShareResponse,
|
||||
DeleteShareError, DeleteShareRequest, DeleteShareResponse, ListSharesError,
|
||||
@@ -24,7 +25,7 @@ use crate::{
|
||||
ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, ListUsersError,
|
||||
ListUsersRequest, LoginUserError, LoginUserOidcError, LoginUserOidcRequest,
|
||||
LoginUserOidcResponse, LoginUserRequest, LoginUserResponse, RegisterUserError,
|
||||
RegisterUserRequest, User,
|
||||
RegisterUserRequest, User, UserEmail, UserName, UserPassword,
|
||||
},
|
||||
user_warren::{
|
||||
UserWarren,
|
||||
@@ -46,7 +47,10 @@ use crate::{
|
||||
WarrenTouchResponse,
|
||||
},
|
||||
},
|
||||
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService},
|
||||
ports::{
|
||||
AuthMetrics, AuthNotifier, AuthRepository, AuthService, OptionService,
|
||||
WarrenService,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -97,50 +101,100 @@ impl AuthConfig {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Service<R, M, N, OIDC>
|
||||
pub struct Service<R, M, N, OIDC, O>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
OIDC: OidcService,
|
||||
O: OptionService,
|
||||
{
|
||||
repository: R,
|
||||
metrics: M,
|
||||
notifier: N,
|
||||
oidc: Option<OIDC>,
|
||||
option_service: O,
|
||||
config: AuthConfig,
|
||||
}
|
||||
|
||||
impl<R, M, N, OIDC> Service<R, M, N, OIDC>
|
||||
impl<R, M, N, OIDC, O> Service<R, M, N, OIDC, O>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
OIDC: OidcService,
|
||||
O: OptionService,
|
||||
{
|
||||
pub fn new(
|
||||
pub async fn new(
|
||||
repository: R,
|
||||
metrics: M,
|
||||
notifier: N,
|
||||
config: AuthConfig,
|
||||
oidc: Option<OIDC>,
|
||||
) -> Self {
|
||||
Self {
|
||||
option_service: O,
|
||||
) -> anyhow::Result<Self> {
|
||||
let service = Self {
|
||||
repository,
|
||||
metrics,
|
||||
notifier,
|
||||
config,
|
||||
oidc,
|
||||
option_service,
|
||||
};
|
||||
|
||||
service.init().await?;
|
||||
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
pub fn oidc(&self) -> Option<&OIDC> {
|
||||
self.oidc.as_ref()
|
||||
}
|
||||
|
||||
async fn init(&self) -> anyhow::Result<()> {
|
||||
self.create_admin_user_if_init().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_admin_user_if_init(&self) -> anyhow::Result<()> {
|
||||
const CREATED_ADMIN_USER_KEY: &str = "CREATED_ADMIN_USER";
|
||||
|
||||
let key = OptionKey::new(CREATED_ADMIN_USER_KEY)?;
|
||||
let request = GetOptionRequest::new(key.clone());
|
||||
match self.option_service.get_option::<bool>(request).await {
|
||||
// If the option is already set and true we don't have to do anything anymore
|
||||
Ok(opt) if *opt.value() => return Ok(()),
|
||||
Err(e) => match e {
|
||||
// The option is not yet set so we proceed with the admin user creation
|
||||
GetOptionError::NotFound(_) => (),
|
||||
_ => return Err(e.into()),
|
||||
},
|
||||
// The option was set but it was false so we proceed with the admin user creation
|
||||
_ => (),
|
||||
}
|
||||
|
||||
let name = UserName::new("admin")?;
|
||||
let email = UserEmail::new("admin@example.com")?;
|
||||
let password = UserPassword::new("admin1234567")?;
|
||||
let request = RegisterUserRequest::new_bypass_flag(name, email, password, true);
|
||||
|
||||
self.register_user(request).await?;
|
||||
self.option_service
|
||||
.set_option(SetOptionRequest::new(key, true))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, M, N, OIDC> AuthService for Service<R, M, N, OIDC>
|
||||
impl<R, M, N, OIDC, O> AuthService for Service<R, M, N, OIDC, O>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
OIDC: OidcService,
|
||||
O: OptionService,
|
||||
{
|
||||
async fn create_warren<WS: WarrenService>(
|
||||
&self,
|
||||
@@ -240,7 +294,7 @@ where
|
||||
&self,
|
||||
request: GetOidcRedirectRequest,
|
||||
) -> Result<GetOidcRedirectResponse, GetOidcRedirectError> {
|
||||
let oidc = self.oidc.as_ref().ok_or(GetOidcRedirectError::Disabled)?;
|
||||
let oidc = self.oidc().ok_or(GetOidcRedirectError::Disabled)?;
|
||||
|
||||
oidc.get_redirect(request.into())
|
||||
.await
|
||||
@@ -249,7 +303,7 @@ where
|
||||
}
|
||||
|
||||
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
|
||||
if !self.config.allow_registration {
|
||||
if !self.config.allow_registration && !request.bypass_registration_flag() {
|
||||
self.metrics.record_user_registration_failure().await;
|
||||
return Err(RegisterUserError::Disabled);
|
||||
}
|
||||
@@ -298,7 +352,7 @@ where
|
||||
&self,
|
||||
request: LoginUserOidcRequest,
|
||||
) -> Result<LoginUserOidcResponse, LoginUserOidcError> {
|
||||
let oidc = self.oidc.as_ref().ok_or(LoginUserOidcError::Disabled)?;
|
||||
let oidc = self.oidc().ok_or(LoginUserOidcError::Disabled)?;
|
||||
|
||||
let user_info = oidc.get_user_info(request.into()).await?;
|
||||
|
||||
@@ -704,7 +758,7 @@ where
|
||||
return Err(AuthError::InsufficientPermissions);
|
||||
}
|
||||
|
||||
let path = request.base().path().clone();
|
||||
let paths = request.base().paths().clone();
|
||||
|
||||
let result = warren_service
|
||||
.warren_cat(request)
|
||||
@@ -714,7 +768,7 @@ where
|
||||
if let Ok(_stream) = result.as_ref() {
|
||||
self.metrics.record_auth_warren_cat_success().await;
|
||||
self.notifier
|
||||
.auth_warren_cat(&user, user_warren.warren_id(), &path)
|
||||
.auth_warren_cat(&user, user_warren.warren_id(), &paths)
|
||||
.await;
|
||||
} else {
|
||||
self.metrics.record_auth_warren_cat_failure().await;
|
||||
@@ -968,12 +1022,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, M, N, OIDC> Service<R, M, N, OIDC>
|
||||
impl<R, M, N, OIDC, O> Service<R, M, N, OIDC, O>
|
||||
where
|
||||
R: AuthRepository,
|
||||
M: AuthMetrics,
|
||||
N: AuthNotifier,
|
||||
OIDC: OidcService,
|
||||
O: OptionService,
|
||||
{
|
||||
/// A helper to get a [UserWarren], [User] and the underlying request from an [AuthRequest]
|
||||
async fn get_session_data_and_user_warren<T, E>(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::domain::warren::{
|
||||
models::file::{
|
||||
CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream, LsError, LsRequest,
|
||||
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError, RmRequest, SaveError,
|
||||
SaveRequest, SaveResponse, StatError, StatRequest, StatResponse, TouchError, TouchRequest,
|
||||
AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, FileStream,
|
||||
LsError, LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RmError,
|
||||
RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse,
|
||||
TouchError, TouchRequest,
|
||||
},
|
||||
ports::{FileSystemMetrics, FileSystemNotifier, FileSystemRepository, FileSystemService},
|
||||
};
|
||||
@@ -54,12 +55,12 @@ where
|
||||
}
|
||||
|
||||
async fn cat(&self, request: CatRequest) -> Result<FileStream, CatError> {
|
||||
let path = request.path().clone();
|
||||
let paths = request.paths().clone();
|
||||
let result = self.repository.cat(request).await;
|
||||
|
||||
if result.is_ok() {
|
||||
self.metrics.record_cat_success().await;
|
||||
self.notifier.cat(&path).await;
|
||||
self.notifier.cat(&paths).await;
|
||||
} else {
|
||||
self.metrics.record_cat_failure().await;
|
||||
}
|
||||
@@ -81,33 +82,37 @@ where
|
||||
result
|
||||
}
|
||||
|
||||
async fn rm(&self, request: RmRequest) -> Result<(), RmError> {
|
||||
let path = request.path().clone();
|
||||
let result = self.repository.rm(request).await;
|
||||
async fn rm(&self, request: RmRequest) -> Vec<Result<AbsoluteFilePath, RmError>> {
|
||||
let results = self.repository.rm(request).await;
|
||||
|
||||
if result.is_ok() {
|
||||
self.metrics.record_rm_success().await;
|
||||
self.notifier.rm(&path).await;
|
||||
} else {
|
||||
self.metrics.record_rm_failure().await;
|
||||
for result in results.iter() {
|
||||
if let Ok(path) = result.as_ref() {
|
||||
self.metrics.record_rm_success().await;
|
||||
self.notifier.rm(path).await;
|
||||
} else {
|
||||
self.metrics.record_rm_failure().await;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
results
|
||||
}
|
||||
|
||||
async fn mv(&self, request: MvRequest) -> Result<(), MvError> {
|
||||
let old_path = request.path().clone();
|
||||
let new_path = request.target_path().clone();
|
||||
let result = self.repository.mv(request).await;
|
||||
async fn mv(
|
||||
&self,
|
||||
request: MvRequest,
|
||||
) -> Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>> {
|
||||
let results = self.repository.mv(request).await;
|
||||
|
||||
if result.is_ok() {
|
||||
self.metrics.record_mv_success().await;
|
||||
self.notifier.mv(&old_path, &new_path).await;
|
||||
} else {
|
||||
self.metrics.record_mv_failure().await;
|
||||
for result in results.iter() {
|
||||
if let Ok((old_path, new_path)) = result.as_ref() {
|
||||
self.metrics.record_mv_success().await;
|
||||
self.notifier.mv(old_path, new_path).await;
|
||||
} else {
|
||||
self.metrics.record_mv_failure().await;
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
results
|
||||
}
|
||||
|
||||
async fn save(&self, request: SaveRequest<'_>) -> Result<SaveResponse, SaveError> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod auth;
|
||||
pub mod file_system;
|
||||
pub mod option;
|
||||
pub mod warren;
|
||||
|
||||
90
backend/src/lib/domain/warren/service/option.rs
Normal file
90
backend/src/lib/domain/warren/service/option.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
use crate::domain::warren::{
|
||||
models::option::{
|
||||
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
|
||||
GetOptionRequest, GetOptionResponse, OptionType, SetOptionError, SetOptionRequest,
|
||||
SetOptionResponse,
|
||||
},
|
||||
ports::{OptionMetrics, OptionNotifier, OptionRepository, OptionService},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Service<R, M, N>
|
||||
where
|
||||
R: OptionRepository,
|
||||
M: OptionMetrics,
|
||||
N: OptionNotifier,
|
||||
{
|
||||
repository: R,
|
||||
metrics: M,
|
||||
notifier: N,
|
||||
}
|
||||
|
||||
impl<R, M, N> Service<R, M, N>
|
||||
where
|
||||
R: OptionRepository,
|
||||
M: OptionMetrics,
|
||||
N: OptionNotifier,
|
||||
{
|
||||
pub fn new(repository: R, metrics: M, notifier: N) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
metrics,
|
||||
notifier,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R, M, N> OptionService for Service<R, M, N>
|
||||
where
|
||||
R: OptionRepository,
|
||||
M: OptionMetrics,
|
||||
N: OptionNotifier,
|
||||
{
|
||||
async fn get_option<T: OptionType>(
|
||||
&self,
|
||||
request: GetOptionRequest,
|
||||
) -> Result<GetOptionResponse<T>, GetOptionError> {
|
||||
let result = self.repository.get_option(request).await;
|
||||
|
||||
if let Ok(response) = result.as_ref() {
|
||||
self.metrics.record_option_get_success().await;
|
||||
self.notifier.got_option(response).await;
|
||||
} else {
|
||||
self.metrics.record_option_get_failure().await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn set_option<T: OptionType>(
|
||||
&self,
|
||||
request: SetOptionRequest<T>,
|
||||
) -> Result<SetOptionResponse<T>, SetOptionError> {
|
||||
let result = self.repository.set_option(request).await;
|
||||
|
||||
if let Ok(response) = result.as_ref() {
|
||||
self.metrics.record_option_set_success().await;
|
||||
self.notifier.set_option(response).await;
|
||||
} else {
|
||||
self.metrics.record_option_set_failure().await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn delete_option(
|
||||
&self,
|
||||
request: DeleteOptionRequest,
|
||||
) -> Result<DeleteOptionResponse, DeleteOptionError> {
|
||||
let result = self.repository.delete_option(request).await;
|
||||
|
||||
if let Ok(response) = result.as_ref() {
|
||||
self.metrics.record_option_delete_success().await;
|
||||
self.notifier.deleted_option(response).await;
|
||||
} else {
|
||||
self.metrics.record_option_delete_failure().await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ use crate::domain::warren::{
|
||||
DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest,
|
||||
GetShareResponse, ListSharesError, ListSharesRequest, ListSharesResponse, Share,
|
||||
ShareCatError, ShareCatRequest, ShareCatResponse, ShareLsError, ShareLsRequest,
|
||||
ShareLsResponse,
|
||||
ShareLsResponse, VerifySharePasswordError, VerifySharePasswordRequest,
|
||||
VerifySharePasswordResponse,
|
||||
},
|
||||
warren::{
|
||||
CreateWarrenError, CreateWarrenRequest, DeleteWarrenError, DeleteWarrenRequest,
|
||||
@@ -157,14 +158,14 @@ where
|
||||
async fn warren_cat(&self, request: WarrenCatRequest) -> Result<FileStream, WarrenCatError> {
|
||||
let warren = self.repository.fetch_warren((&request).into()).await?;
|
||||
|
||||
let path = request.base().path().clone();
|
||||
let paths = request.base().paths().clone();
|
||||
let cat_request = request.build_fs_request(&warren);
|
||||
|
||||
let result = self.fs_service.cat(cat_request).await.map_err(Into::into);
|
||||
|
||||
if result.is_ok() {
|
||||
self.metrics.record_warren_cat_success().await;
|
||||
self.notifier.warren_cat(&warren, &path).await;
|
||||
self.notifier.warren_cat(&warren, &paths).await;
|
||||
} else {
|
||||
self.metrics.record_warren_cat_failure().await;
|
||||
}
|
||||
@@ -250,49 +251,40 @@ where
|
||||
}
|
||||
|
||||
async fn warren_rm(&self, request: WarrenRmRequest) -> Result<WarrenRmResponse, WarrenRmError> {
|
||||
let warren = self.repository.fetch_warren((&request).into()).await?;
|
||||
let warren = match self.repository.fetch_warren((&request).into()).await {
|
||||
Ok(warren) => warren,
|
||||
Err(e) => {
|
||||
self.metrics.record_warren_rm_failure().await;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let path = request.base().path().clone();
|
||||
let rm_request = request.build_fs_request(&warren);
|
||||
|
||||
let result = self
|
||||
.fs_service
|
||||
.rm(rm_request)
|
||||
.await
|
||||
.map(|_| WarrenRmResponse::new(warren, path))
|
||||
.map_err(Into::into);
|
||||
let response = WarrenRmResponse::new(warren, self.fs_service.rm(rm_request).await);
|
||||
|
||||
if let Ok(response) = result.as_ref() {
|
||||
self.metrics.record_warren_rm_success().await;
|
||||
self.notifier.warren_rm(response).await;
|
||||
} else {
|
||||
self.metrics.record_warren_rm_failure().await;
|
||||
}
|
||||
self.metrics.record_warren_rm_success().await;
|
||||
self.notifier.warren_rm(&response).await;
|
||||
|
||||
result
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn warren_mv(&self, request: WarrenMvRequest) -> Result<WarrenMvResponse, WarrenMvError> {
|
||||
let warren = self.repository.fetch_warren((&request).into()).await?;
|
||||
let warren = match self.repository.fetch_warren((&request).into()).await {
|
||||
Ok(warren) => warren,
|
||||
Err(e) => {
|
||||
self.metrics.record_warren_mv_failure().await;
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
|
||||
let old_path = request.base().path().clone();
|
||||
let new_path = request.base().target_path().clone();
|
||||
let mv_request = request.build_fs_request(&warren);
|
||||
let result = self
|
||||
.fs_service
|
||||
.mv(mv_request)
|
||||
.await
|
||||
.map(|_| WarrenMvResponse::new(warren, old_path, new_path))
|
||||
.map_err(Into::into);
|
||||
let response = WarrenMvResponse::new(warren, self.fs_service.mv(mv_request).await);
|
||||
|
||||
if let Ok(response) = result.as_ref() {
|
||||
self.metrics.record_warren_mv_success().await;
|
||||
self.notifier.warren_mv(response).await;
|
||||
} else {
|
||||
self.metrics.record_warren_mv_failure().await;
|
||||
}
|
||||
self.metrics.record_warren_mv_success().await;
|
||||
self.notifier.warren_mv(&response).await;
|
||||
|
||||
result
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn warren_touch(
|
||||
@@ -517,7 +509,7 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let path = request.path().clone();
|
||||
let paths = request.base().paths().clone();
|
||||
|
||||
let stream = match self
|
||||
.warren_cat(request.build_warren_cat_request(&share, &warren))
|
||||
@@ -530,11 +522,31 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let response = ShareCatResponse::new(share, warren, path, stream);
|
||||
let response = ShareCatResponse::new(share, warren, paths, stream);
|
||||
|
||||
self.metrics.record_warren_share_cat_success().await;
|
||||
self.notifier.warren_share_cat(&response).await;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn verify_warren_share_password(
|
||||
&self,
|
||||
request: VerifySharePasswordRequest,
|
||||
) -> Result<VerifySharePasswordResponse, VerifySharePasswordError> {
|
||||
let result = self.repository.verify_warren_share_password(request).await;
|
||||
|
||||
if let Ok(response) = result.as_ref() {
|
||||
self.metrics
|
||||
.record_warren_share_password_verification_success()
|
||||
.await;
|
||||
self.notifier.warren_share_password_verified(response).await;
|
||||
} else {
|
||||
self.metrics
|
||||
.record_warren_share_password_verification_failure()
|
||||
.await;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,12 @@ impl From<WarrenMkdirError> for ApiError {
|
||||
impl From<RmError> for ApiError {
|
||||
fn from(value: RmError) -> Self {
|
||||
match value {
|
||||
RmError::NotFound => Self::NotFound("The directory does not exist".to_string()),
|
||||
RmError::NotEmpty => Self::BadRequest("The directory is not empty".to_string()),
|
||||
RmError::NotFound(_) => {
|
||||
Self::NotFound("At least one of the specified files does not exist".to_string())
|
||||
}
|
||||
RmError::NotEmpty(_) => Self::BadRequest(
|
||||
"At least one of the specified directories does not exist".to_string(),
|
||||
),
|
||||
RmError::Unknown(e) => Self::InternalServerError(e.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -51,9 +55,7 @@ impl From<RmError> for ApiError {
|
||||
impl From<WarrenRmError> for ApiError {
|
||||
fn from(value: WarrenRmError) -> Self {
|
||||
match value {
|
||||
WarrenRmError::FileSystem(fs) => fs.into(),
|
||||
WarrenRmError::FetchWarren(err) => err.into(),
|
||||
WarrenRmError::Unknown(error) => Self::InternalServerError(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +74,9 @@ where
|
||||
return Ok(Self(None));
|
||||
};
|
||||
|
||||
SharePassword::new(cookie.value())
|
||||
Ok(SharePassword::new(cookie.value())
|
||||
.map(|v| Self(Some(v)))
|
||||
.map_err(|_| ApiError::BadRequest("Invalid password".to_string()))
|
||||
.unwrap_or(Self(None)))
|
||||
}
|
||||
// Debug build
|
||||
else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
@@ -9,7 +9,10 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::{
|
||||
file::{AbsoluteFilePathError, FilePath, FilePathError, FileStream},
|
||||
file::{
|
||||
AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList,
|
||||
AbsoluteFilePathListError, CatRequest, FilePath, FilePathError, FileStream,
|
||||
},
|
||||
share::{ShareCatRequest, SharePassword, SharePasswordError},
|
||||
},
|
||||
ports::{AuthService, WarrenService},
|
||||
@@ -24,6 +27,8 @@ enum ParseShareCatHttpRequestError {
|
||||
#[error(transparent)]
|
||||
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||
#[error(transparent)]
|
||||
PathList(#[from] AbsoluteFilePathListError),
|
||||
#[error(transparent)]
|
||||
Password(#[from] SharePasswordError),
|
||||
}
|
||||
|
||||
@@ -38,6 +43,11 @@ impl From<ParseShareCatHttpRequestError> for ApiError {
|
||||
Self::BadRequest("The path must be absolute".to_string())
|
||||
}
|
||||
},
|
||||
ParseShareCatHttpRequestError::PathList(err) => match err {
|
||||
AbsoluteFilePathListError::Empty => {
|
||||
Self::BadRequest("You must provide at least 1 path".to_string())
|
||||
}
|
||||
},
|
||||
ParseShareCatHttpRequestError::Password(err) => Self::BadRequest(
|
||||
match err {
|
||||
SharePasswordError::Empty => "The provided password is empty",
|
||||
@@ -56,7 +66,7 @@ impl From<ParseShareCatHttpRequestError> for ApiError {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct ShareCatHttpRequestBody {
|
||||
share_id: Uuid,
|
||||
path: String,
|
||||
paths: String,
|
||||
}
|
||||
|
||||
impl ShareCatHttpRequestBody {
|
||||
@@ -64,9 +74,19 @@ impl ShareCatHttpRequestBody {
|
||||
self,
|
||||
password: Option<SharePassword>,
|
||||
) -> Result<ShareCatRequest, ParseShareCatHttpRequestError> {
|
||||
let path = FilePath::new(&self.path)?.try_into()?;
|
||||
let mut paths = Vec::<AbsoluteFilePath>::new();
|
||||
|
||||
Ok(ShareCatRequest::new(self.share_id, path, password))
|
||||
for path in self.paths.split(':') {
|
||||
paths.push(FilePath::new(path)?.try_into()?);
|
||||
}
|
||||
|
||||
let path_list = AbsoluteFilePathList::new(paths)?;
|
||||
|
||||
Ok(ShareCatRequest::new(
|
||||
self.share_id,
|
||||
password,
|
||||
CatRequest::new(path_list),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,13 +94,13 @@ pub async fn cat_share<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
SharePasswordHeader(password): SharePasswordHeader,
|
||||
Query(request): Query<ShareCatHttpRequestBody>,
|
||||
) -> Result<Body, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let domain_request = request.try_into_domain(password)?;
|
||||
|
||||
state
|
||||
.warren_service
|
||||
.warren_share_cat(domain_request)
|
||||
.await
|
||||
.map(|response| FileStream::from(response).into())
|
||||
.map(|response| FileStream::from(response))
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
handlers::extractors::SharePasswordHeader,
|
||||
responses::{ApiError, ApiSuccess},
|
||||
},
|
||||
};
|
||||
@@ -59,17 +60,14 @@ impl From<ParseShareLsHttpRequestError> for ApiError {
|
||||
pub(super) struct LsShareHttpRequestBody {
|
||||
share_id: Uuid,
|
||||
path: String,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
impl LsShareHttpRequestBody {
|
||||
fn try_into_domain(self) -> Result<ShareLsRequest, ParseShareLsHttpRequestError> {
|
||||
fn try_into_domain(
|
||||
self,
|
||||
password: Option<SharePassword>,
|
||||
) -> Result<ShareLsRequest, ParseShareLsHttpRequestError> {
|
||||
let path = FilePath::new(&self.path)?.try_into()?;
|
||||
let password = if let Some(password) = self.password.as_ref() {
|
||||
Some(SharePassword::new(password)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ShareLsRequest::new(self.share_id, path, password))
|
||||
}
|
||||
@@ -98,9 +96,10 @@ impl From<ShareLsResponse> for ShareLsResponseData {
|
||||
|
||||
pub async fn ls_share<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
SharePasswordHeader(password): SharePasswordHeader,
|
||||
Json(request): Json<LsShareHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<ShareLsResponseData>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
let domain_request = request.try_into_domain(password)?;
|
||||
|
||||
state
|
||||
.warren_service
|
||||
|
||||
@@ -7,6 +7,7 @@ mod list_shares;
|
||||
mod list_warrens;
|
||||
mod ls_share;
|
||||
mod upload_warren_files;
|
||||
mod verify_share_password;
|
||||
mod warren_cat;
|
||||
mod warren_cp;
|
||||
mod warren_ls;
|
||||
@@ -17,6 +18,8 @@ mod warren_rm;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
http::{self, HeaderValue, Response},
|
||||
response::IntoResponse,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
@@ -35,13 +38,14 @@ use get_share::get_share;
|
||||
use list_shares::list_shares;
|
||||
use ls_share::ls_share;
|
||||
use upload_warren_files::warren_save;
|
||||
use verify_share_password::verify_share_password;
|
||||
use warren_cat::fetch_file;
|
||||
use warren_cp::warren_cp;
|
||||
use warren_mv::warren_mv;
|
||||
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::file::{File, FileMimeType, FileType},
|
||||
models::file::{File, FileMimeType, FileStream, FileStreamInner, FileType},
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::AppState,
|
||||
@@ -53,6 +57,7 @@ pub struct WarrenFileElement {
|
||||
name: String,
|
||||
file_type: FileType,
|
||||
mime_type: Option<String>,
|
||||
size: u64,
|
||||
created_at: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -62,11 +67,36 @@ impl From<&File> for WarrenFileElement {
|
||||
name: value.name().to_string(),
|
||||
file_type: value.file_type().to_owned(),
|
||||
mime_type: value.mime_type().map(FileMimeType::to_string),
|
||||
size: value.size().into(),
|
||||
created_at: value.created_at(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for FileStream {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let mut builder = Response::builder().header(http::header::TRANSFER_ENCODING, "chunked");
|
||||
|
||||
if let Some(headers) = builder.headers_mut() {
|
||||
if let Some(mime_type) = self.mime_type() {
|
||||
headers.insert(
|
||||
http::header::CONTENT_TYPE,
|
||||
HeaderValue::from_str(mime_type.as_str()).unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
headers.insert(
|
||||
http::header::TRANSFER_ENCODING,
|
||||
HeaderValue::from_str("chunked").unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
builder
|
||||
.body(axum::body::Body::from_stream(FileStreamInner::from(self)))
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>> {
|
||||
Router::new()
|
||||
.route("/", get(list_warrens))
|
||||
@@ -88,4 +118,5 @@ pub fn routes<WS: WarrenService, AS: AuthService>() -> Router<AppState<WS, AS>>
|
||||
.route("/files/get_share", post(get_share))
|
||||
.route("/files/ls_share", post(ls_share))
|
||||
.route("/files/cat_share", get(cat_share))
|
||||
.route("/files/verify_share_password", post(verify_share_password))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::share::{SharePassword, SharePasswordError, VerifySharePasswordRequest},
|
||||
ports::{AuthService, WarrenService},
|
||||
},
|
||||
inbound::http::{
|
||||
AppState,
|
||||
responses::{ApiError, ApiSuccess},
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum ParseVerifySharePasswordHttpRequestError {
|
||||
#[error(transparent)]
|
||||
SharePassword(#[from] SharePasswordError),
|
||||
}
|
||||
|
||||
impl From<ParseVerifySharePasswordHttpRequestError> for ApiError {
|
||||
fn from(value: ParseVerifySharePasswordHttpRequestError) -> Self {
|
||||
match value {
|
||||
ParseVerifySharePasswordHttpRequestError::SharePassword(err) => Self::BadRequest(
|
||||
match err {
|
||||
SharePasswordError::Empty => "The provided password is empty",
|
||||
SharePasswordError::LeadingWhitespace
|
||||
| SharePasswordError::TrailingWhitespace
|
||||
| SharePasswordError::TooShort
|
||||
| SharePasswordError::TooLong => "",
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct VerifySharePasswordHttpRequestBody {
|
||||
share_id: Uuid,
|
||||
password: String,
|
||||
}
|
||||
|
||||
impl VerifySharePasswordHttpRequestBody {
|
||||
fn try_into_domain(
|
||||
self,
|
||||
) -> Result<VerifySharePasswordRequest, ParseVerifySharePasswordHttpRequestError> {
|
||||
let password = SharePassword::new(&self.password)?;
|
||||
|
||||
Ok(VerifySharePasswordRequest::new(
|
||||
self.share_id,
|
||||
Some(password),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn verify_share_password<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
Json(request): Json<VerifySharePasswordHttpRequestBody>,
|
||||
) -> Result<ApiSuccess<()>, ApiError> {
|
||||
let domain_request = request.try_into_domain()?;
|
||||
|
||||
state
|
||||
.warren_service
|
||||
.verify_warren_share_password(domain_request)
|
||||
.await
|
||||
.map(|_| ApiSuccess::new(StatusCode::OK, ()))
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Query, State},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
domain::warren::{
|
||||
models::{
|
||||
auth_session::AuthRequest,
|
||||
file::{AbsoluteFilePathError, CatRequest, FilePath, FilePathError, FileStream},
|
||||
file::{
|
||||
AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList,
|
||||
AbsoluteFilePathListError, CatRequest, FilePath, FilePathError,
|
||||
},
|
||||
warren::WarrenCatRequest,
|
||||
},
|
||||
ports::{AuthService, WarrenService},
|
||||
@@ -23,15 +25,17 @@ use crate::{
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct WarrenCatHttpRequestBody {
|
||||
warren_id: Uuid,
|
||||
path: String,
|
||||
paths: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Error)]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ParseWarrenCatHttpRequestError {
|
||||
#[error(transparent)]
|
||||
FilePath(#[from] FilePathError),
|
||||
#[error(transparent)]
|
||||
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||
#[error(transparent)]
|
||||
PathList(#[from] AbsoluteFilePathListError),
|
||||
}
|
||||
|
||||
impl From<ParseWarrenCatHttpRequestError> for ApiError {
|
||||
@@ -47,21 +51,29 @@ impl From<ParseWarrenCatHttpRequestError> for ApiError {
|
||||
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||
}
|
||||
},
|
||||
ParseWarrenCatHttpRequestError::PathList(err) => match err {
|
||||
AbsoluteFilePathListError::Empty => {
|
||||
Self::BadRequest("You must provide at least 1 path".to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WarrenCatHttpRequestBody {
|
||||
fn try_into_domain(self) -> Result<WarrenCatRequest, ParseWarrenCatHttpRequestError> {
|
||||
let path = FilePath::new(&self.path)?.try_into()?;
|
||||
let mut paths = Vec::<AbsoluteFilePath>::new();
|
||||
|
||||
Ok(WarrenCatRequest::new(self.warren_id, CatRequest::new(path)))
|
||||
}
|
||||
}
|
||||
for path in self.paths.split(':') {
|
||||
paths.push(FilePath::new(path)?.try_into()?);
|
||||
}
|
||||
|
||||
impl From<FileStream> for Body {
|
||||
fn from(value: FileStream) -> Self {
|
||||
Body::from_stream::<ReaderStream<tokio::fs::File>>(value.into())
|
||||
let path_list = AbsoluteFilePathList::new(paths)?;
|
||||
|
||||
Ok(WarrenCatRequest::new(
|
||||
self.warren_id,
|
||||
CatRequest::new(path_list),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,13 +81,12 @@ pub async fn fetch_file<WS: WarrenService, AS: AuthService>(
|
||||
State(state): State<AppState<WS, AS>>,
|
||||
SessionIdHeader(session): SessionIdHeader,
|
||||
Query(request): Query<WarrenCatHttpRequestBody>,
|
||||
) -> Result<Body, ApiError> {
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let domain_request = AuthRequest::new(session, request.try_into_domain()?);
|
||||
|
||||
state
|
||||
.auth_service
|
||||
.auth_warren_cat(domain_request, state.warren_service.as_ref())
|
||||
.await
|
||||
.map(|contents| contents.into())
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::{
|
||||
domain::warren::{
|
||||
models::{
|
||||
auth_session::AuthRequest,
|
||||
file::{AbsoluteFilePath, AbsoluteFilePathError, FilePath, FilePathError, MvRequest},
|
||||
file::{
|
||||
AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList,
|
||||
AbsoluteFilePathListError, FilePath, FilePathError, MvRequest,
|
||||
},
|
||||
warren::WarrenMvRequest,
|
||||
},
|
||||
ports::{AuthService, WarrenService},
|
||||
@@ -23,7 +26,7 @@ use crate::{
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MvWarrenEntryHttpRequestBody {
|
||||
warren_id: Uuid,
|
||||
path: String,
|
||||
paths: Vec<String>,
|
||||
target_path: String,
|
||||
}
|
||||
|
||||
@@ -33,16 +36,25 @@ pub enum ParseWarrenMvHttpRequestError {
|
||||
FilePath(#[from] FilePathError),
|
||||
#[error(transparent)]
|
||||
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||
#[error(transparent)]
|
||||
AbsoluteFilePathList(#[from] AbsoluteFilePathListError),
|
||||
}
|
||||
|
||||
impl MvWarrenEntryHttpRequestBody {
|
||||
fn try_into_domain(self) -> Result<WarrenMvRequest, ParseWarrenMvHttpRequestError> {
|
||||
let path: AbsoluteFilePath = FilePath::new(&self.path)?.try_into()?;
|
||||
let mut paths = Vec::<AbsoluteFilePath>::new();
|
||||
|
||||
for path in self.paths.iter() {
|
||||
paths.push(FilePath::new(path)?.try_into()?);
|
||||
}
|
||||
|
||||
let path_list = AbsoluteFilePathList::new(paths)?;
|
||||
|
||||
let target_path: AbsoluteFilePath = FilePath::new(&self.target_path)?.try_into()?;
|
||||
|
||||
Ok(WarrenMvRequest::new(
|
||||
self.warren_id,
|
||||
MvRequest::new(path, target_path),
|
||||
MvRequest::new(path_list, target_path),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -52,12 +64,17 @@ impl From<ParseWarrenMvHttpRequestError> for ApiError {
|
||||
match value {
|
||||
ParseWarrenMvHttpRequestError::FilePath(err) => match err {
|
||||
FilePathError::InvalidPath => {
|
||||
ApiError::BadRequest("The file path must be valid".to_string())
|
||||
Self::BadRequest("The file path must be valid".to_string())
|
||||
}
|
||||
},
|
||||
ParseWarrenMvHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||
AbsoluteFilePathError::NotAbsolute => {
|
||||
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||
Self::BadRequest("The file path must be absolute".to_string())
|
||||
}
|
||||
},
|
||||
ParseWarrenMvHttpRequestError::AbsoluteFilePathList(err) => match err {
|
||||
AbsoluteFilePathListError::Empty => {
|
||||
Self::BadRequest("You must provide at least 1 path".to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ use crate::{
|
||||
domain::warren::{
|
||||
models::{
|
||||
auth_session::AuthRequest,
|
||||
file::{AbsoluteFilePathError, FilePath, FilePathError, RmRequest},
|
||||
file::{
|
||||
AbsoluteFilePath, AbsoluteFilePathError, AbsoluteFilePathList,
|
||||
AbsoluteFilePathListError, FilePath, FilePathError, RmRequest,
|
||||
},
|
||||
warren::WarrenRmRequest,
|
||||
},
|
||||
ports::{AuthService, WarrenService},
|
||||
@@ -23,7 +26,7 @@ use crate::{
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct WarrenRmHttpRequestBody {
|
||||
warren_id: Uuid,
|
||||
path: String,
|
||||
paths: Vec<String>,
|
||||
force: bool,
|
||||
}
|
||||
|
||||
@@ -33,6 +36,8 @@ pub(super) enum ParseWarrenRmHttpRequestError {
|
||||
FilePath(#[from] FilePathError),
|
||||
#[error(transparent)]
|
||||
AbsoluteFilePath(#[from] AbsoluteFilePathError),
|
||||
#[error(transparent)]
|
||||
AbsoluteFilePathList(#[from] AbsoluteFilePathListError),
|
||||
}
|
||||
|
||||
impl From<ParseWarrenRmHttpRequestError> for ApiError {
|
||||
@@ -40,12 +45,17 @@ impl From<ParseWarrenRmHttpRequestError> for ApiError {
|
||||
match value {
|
||||
ParseWarrenRmHttpRequestError::FilePath(err) => match err {
|
||||
FilePathError::InvalidPath => {
|
||||
ApiError::BadRequest("The file path must be valid".to_string())
|
||||
ApiError::BadRequest("File paths must be valid".to_string())
|
||||
}
|
||||
},
|
||||
ParseWarrenRmHttpRequestError::AbsoluteFilePath(err) => match err {
|
||||
AbsoluteFilePathError::NotAbsolute => {
|
||||
ApiError::BadRequest("The file path must be absolute".to_string())
|
||||
ApiError::BadRequest("File paths must be absolute".to_string())
|
||||
}
|
||||
},
|
||||
ParseWarrenRmHttpRequestError::AbsoluteFilePathList(err) => match err {
|
||||
AbsoluteFilePathListError::Empty => {
|
||||
Self::BadRequest("At least one file path is required".to_string())
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -54,11 +64,17 @@ impl From<ParseWarrenRmHttpRequestError> for ApiError {
|
||||
|
||||
impl WarrenRmHttpRequestBody {
|
||||
fn try_into_domain(self) -> Result<WarrenRmRequest, ParseWarrenRmHttpRequestError> {
|
||||
let path = FilePath::new(&self.path)?;
|
||||
let mut paths = Vec::<AbsoluteFilePath>::new();
|
||||
|
||||
for path in self.paths.iter() {
|
||||
paths.push(FilePath::new(path)?.try_into()?);
|
||||
}
|
||||
|
||||
let path_list = AbsoluteFilePathList::new(paths)?;
|
||||
|
||||
Ok(WarrenRmRequest::new(
|
||||
self.warren_id,
|
||||
RmRequest::new(path.try_into()?, self.force),
|
||||
RmRequest::new(path_list, self.force),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use std::{os::unix::fs::MetadataExt, path::PathBuf};
|
||||
|
||||
use anyhow::{Context, anyhow, bail};
|
||||
use futures_util::TryStreamExt;
|
||||
use anyhow::{Context as _, anyhow, bail};
|
||||
use futures_util::{TryStreamExt, future::join_all};
|
||||
use rustix::fs::{Statx, statx};
|
||||
use tokio::{
|
||||
fs,
|
||||
io::{self, AsyncWriteExt as _},
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
io::Write,
|
||||
os::unix::fs::MetadataExt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use tokio::{
|
||||
fs::{self},
|
||||
io::{self, AsyncReadExt as _, AsyncWriteExt as _},
|
||||
};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::{
|
||||
@@ -15,10 +20,10 @@ use crate::{
|
||||
models::{
|
||||
file::{
|
||||
AbsoluteFilePath, CatError, CatRequest, CpError, CpRequest, CpResponse, File,
|
||||
FileMimeType, FileName, FilePath, FileStream, FileType, LsError, LsRequest,
|
||||
LsResponse, MkdirError, MkdirRequest, MvError, MvRequest, RelativeFilePath,
|
||||
RmError, RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest,
|
||||
StatResponse, TouchError, TouchRequest,
|
||||
FileMimeType, FileName, FilePath, FileSize, FileStream, FileType, LsError,
|
||||
LsRequest, LsResponse, MkdirError, MkdirRequest, MvError, MvRequest,
|
||||
RelativeFilePath, RmError, RmRequest, SaveError, SaveRequest, SaveResponse,
|
||||
StatError, StatRequest, StatResponse, TouchError, TouchRequest,
|
||||
},
|
||||
warren::UploadFileStream,
|
||||
},
|
||||
@@ -26,30 +31,46 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_FILE_FETCH_BYTES: &str = "MAX_FILE_FETCH_BYTES";
|
||||
const MAX_FILE_FETCH_BYTES_KEY: &str = "MAX_FILE_FETCH_BYTES";
|
||||
const ZIP_READ_BUFFER_BYTES_KEY: &str = "ZIP_READ_BUFFER_BYTES";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSystemConfig {
|
||||
base_directory: String,
|
||||
max_file_fetch_bytes: u64,
|
||||
zip_read_buffer_bytes: usize,
|
||||
}
|
||||
|
||||
impl FileSystemConfig {
|
||||
pub fn new(base_directory: String, max_file_fetch_bytes: u64) -> Self {
|
||||
pub fn new(
|
||||
base_directory: String,
|
||||
max_file_fetch_bytes: u64,
|
||||
zip_read_buffer_bytes: usize,
|
||||
) -> Self {
|
||||
Self {
|
||||
base_directory,
|
||||
max_file_fetch_bytes,
|
||||
zip_read_buffer_bytes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_env(serve_dir: String) -> anyhow::Result<Self> {
|
||||
// 268435456 bytes = 0.25GB
|
||||
let max_file_fetch_bytes: u64 = match Config::load_env(MAX_FILE_FETCH_BYTES) {
|
||||
let max_file_fetch_bytes: u64 = match Config::load_env(MAX_FILE_FETCH_BYTES_KEY) {
|
||||
Ok(value) => value.parse()?,
|
||||
Err(_) => 268435456,
|
||||
};
|
||||
|
||||
Ok(Self::new(serve_dir, max_file_fetch_bytes))
|
||||
let zip_read_buffer_bytes: usize = match Config::load_env(ZIP_READ_BUFFER_BYTES_KEY) {
|
||||
Ok(value) => value.parse()?,
|
||||
Err(_) => 4096,
|
||||
};
|
||||
|
||||
Ok(Self::new(
|
||||
serve_dir,
|
||||
max_file_fetch_bytes,
|
||||
zip_read_buffer_bytes,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +78,7 @@ impl FileSystemConfig {
|
||||
pub struct FileSystem {
|
||||
base_directory: FilePath,
|
||||
max_file_fetch_bytes: u64,
|
||||
zip_read_buffer_bytes: usize,
|
||||
}
|
||||
|
||||
impl FileSystem {
|
||||
@@ -64,6 +86,7 @@ impl FileSystem {
|
||||
let file_system = Self {
|
||||
base_directory: FilePath::new(&config.base_directory)?,
|
||||
max_file_fetch_bytes: config.max_file_fetch_bytes,
|
||||
zip_read_buffer_bytes: config.zip_read_buffer_bytes,
|
||||
};
|
||||
|
||||
Ok(file_system)
|
||||
@@ -85,28 +108,8 @@ impl FileSystem {
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
let parent = if include_parent {
|
||||
let dir_name = FileName::new(
|
||||
&dir_path
|
||||
.file_name()
|
||||
.context("Failed to get directory name")?
|
||||
.to_owned()
|
||||
.into_string()
|
||||
.ok()
|
||||
.context("Failed to get directory name")?,
|
||||
)?;
|
||||
|
||||
Some(File::new(
|
||||
dir_name,
|
||||
FileType::Directory,
|
||||
None,
|
||||
get_btime(&dir_path),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut dir = fs::read_dir(&dir_path).await?;
|
||||
let parent_size = FileSize::new(file_size_recursive(&dir_path).await?);
|
||||
|
||||
while let Ok(Some(entry)) = dir.next_entry().await {
|
||||
let name = entry
|
||||
@@ -126,6 +129,8 @@ impl FileSystem {
|
||||
}
|
||||
};
|
||||
|
||||
let file_size = FileSize::new(file_size_recursive(entry.path()).await?);
|
||||
|
||||
let created_at = get_btime(entry.path());
|
||||
|
||||
let mime_type = match file_type {
|
||||
@@ -137,10 +142,33 @@ impl FileSystem {
|
||||
FileName::new(&name)?,
|
||||
file_type,
|
||||
mime_type,
|
||||
file_size,
|
||||
created_at,
|
||||
));
|
||||
}
|
||||
|
||||
let parent = if include_parent {
|
||||
let dir_name = FileName::new(
|
||||
&dir_path
|
||||
.file_name()
|
||||
.context("Failed to get directory name")?
|
||||
.to_owned()
|
||||
.into_string()
|
||||
.ok()
|
||||
.context("Failed to get directory name")?,
|
||||
)?;
|
||||
|
||||
Some(File::new(
|
||||
dir_name,
|
||||
FileType::Directory,
|
||||
None,
|
||||
parent_size,
|
||||
get_btime(&dir_path),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(LsResponse::new(files, parent))
|
||||
}
|
||||
|
||||
@@ -159,10 +187,10 @@ impl FileSystem {
|
||||
|
||||
/// Actually removes a file or 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`)
|
||||
/// * `path`: The file'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 rm(&self, path: &AbsoluteFilePath, force: bool) -> io::Result<()> {
|
||||
let file_path = self.get_target_path(path);
|
||||
let file_path = self.get_target_path(&path);
|
||||
|
||||
if fs::metadata(&file_path).await?.is_file() {
|
||||
return fs::remove_file(&file_path).await;
|
||||
@@ -191,6 +219,7 @@ impl FileSystem {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&file_path)
|
||||
.await?;
|
||||
|
||||
@@ -204,40 +233,139 @@ impl FileSystem {
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
async fn cat(&self, path: &AbsoluteFilePath) -> anyhow::Result<FileStream> {
|
||||
let path = self.get_target_path(path);
|
||||
async fn cat(&self, paths: &Vec<AbsoluteFilePath>) -> anyhow::Result<FileStream> {
|
||||
let paths: Vec<FilePath> = paths
|
||||
.into_iter()
|
||||
.map(|path| self.get_target_path(path))
|
||||
.collect();
|
||||
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(false)
|
||||
.write(false)
|
||||
.read(true)
|
||||
.open(&path)
|
||||
.await?;
|
||||
let path_request = PathRequest::from_paths(paths)?;
|
||||
|
||||
let file_size = file.metadata().await?.size();
|
||||
fn build_zip_stream(
|
||||
prefix: String,
|
||||
paths: Vec<FilePath>,
|
||||
buffer_size: usize,
|
||||
) -> FileStream {
|
||||
let (sync_tx, sync_rx) =
|
||||
std::sync::mpsc::channel::<Result<bytes::Bytes, std::io::Error>>();
|
||||
let (tx, rx) = tokio::sync::mpsc::channel::<Result<bytes::Bytes, std::io::Error>>(1024);
|
||||
|
||||
if file_size > self.max_file_fetch_bytes {
|
||||
bail!("File size exceeds configured limit");
|
||||
tokio::task::spawn(create_zip(
|
||||
prefix,
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|p| PathBuf::from(p.as_str()))
|
||||
.collect(),
|
||||
sync_tx,
|
||||
buffer_size,
|
||||
));
|
||||
tokio::task::spawn(async move {
|
||||
while let Ok(v) = sync_rx.recv() {
|
||||
let _ = tx.send(v).await;
|
||||
}
|
||||
});
|
||||
|
||||
FileStream::new(
|
||||
FileType::Directory,
|
||||
Some(FileMimeType::new_raw("application/zip")),
|
||||
ReceiverStream::new(rx),
|
||||
)
|
||||
}
|
||||
|
||||
let stream = FileStream::new(ReaderStream::new(file));
|
||||
match path_request {
|
||||
PathRequest::Single(file_path) => {
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(false)
|
||||
.write(false)
|
||||
.read(true)
|
||||
.open(&file_path)
|
||||
.await?;
|
||||
|
||||
Ok(stream)
|
||||
let metadata = file.metadata().await?;
|
||||
|
||||
if metadata.is_dir() {
|
||||
drop(file);
|
||||
|
||||
return Ok(build_zip_stream(
|
||||
file_path.to_string(),
|
||||
vec![file_path],
|
||||
self.zip_read_buffer_bytes,
|
||||
));
|
||||
}
|
||||
|
||||
if metadata.size() > self.max_file_fetch_bytes {
|
||||
bail!("File size exceeds configured limit");
|
||||
}
|
||||
|
||||
let mime_type = {
|
||||
let file_name = {
|
||||
let path = file_path.as_str();
|
||||
if let Some(last_slash_index) = path.rfind("/") {
|
||||
&path[last_slash_index + 1..]
|
||||
} else {
|
||||
path
|
||||
}
|
||||
};
|
||||
|
||||
FileMimeType::from_name(file_name)
|
||||
};
|
||||
|
||||
let stream = FileStream::new(FileType::File, mime_type, ReaderStream::new(file));
|
||||
|
||||
Ok(stream)
|
||||
}
|
||||
PathRequest::Multiple {
|
||||
lowest_common_prefix,
|
||||
paths,
|
||||
} => Ok(build_zip_stream(
|
||||
lowest_common_prefix,
|
||||
paths,
|
||||
self.zip_read_buffer_bytes,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn mv(&self, path: &AbsoluteFilePath, target_path: &AbsoluteFilePath) -> io::Result<()> {
|
||||
let current_path = self.get_target_path(path);
|
||||
let target_path = self.get_target_path(target_path);
|
||||
async fn mv(
|
||||
&self,
|
||||
path: &AbsoluteFilePath,
|
||||
target_path: &AbsoluteFilePath,
|
||||
) -> io::Result<(AbsoluteFilePath, AbsoluteFilePath)> {
|
||||
let mut target_path = target_path.clone();
|
||||
|
||||
if !fs::try_exists(¤t_path).await? {
|
||||
let current_fs_path = self.get_target_path(path);
|
||||
let mut target_fs_path = self.get_target_path(&target_path);
|
||||
|
||||
if !fs::try_exists(¤t_fs_path).await? {
|
||||
return Err(io::ErrorKind::NotFound.into());
|
||||
}
|
||||
|
||||
if fs::try_exists(&target_path).await? {
|
||||
if !fs::try_exists(&target_fs_path).await? {
|
||||
return fs::rename(current_fs_path, target_fs_path)
|
||||
.await
|
||||
.map(|_| (path.clone(), target_path.clone()));
|
||||
}
|
||||
|
||||
let target_is_dir = fs::metadata(target_fs_path).await?.is_dir();
|
||||
|
||||
if !target_is_dir {
|
||||
return Err(io::ErrorKind::AlreadyExists.into());
|
||||
}
|
||||
|
||||
fs::rename(current_path, &target_path).await
|
||||
let name = {
|
||||
let current_path = path.as_str();
|
||||
// This unwrap is safe because an `AbsoluteFilePath` always starts with a slash
|
||||
let last_slash_index = current_path.rfind("/").unwrap();
|
||||
|
||||
¤t_path[last_slash_index + 1..]
|
||||
};
|
||||
|
||||
target_path =
|
||||
target_path.join(&RelativeFilePath::new(FilePath::new(name).unwrap()).unwrap());
|
||||
target_fs_path = self.get_target_path(&target_path);
|
||||
|
||||
fs::rename(current_fs_path, target_fs_path)
|
||||
.await
|
||||
.map(|_| (path.clone(), target_path.clone()))
|
||||
}
|
||||
|
||||
async fn touch(&self, path: &AbsoluteFilePath) -> io::Result<()> {
|
||||
@@ -299,9 +427,10 @@ impl FileSystem {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let file_size = FileSize::new(file_size_recursive(target_path).await?);
|
||||
let created_at = get_btime(&fs_path);
|
||||
|
||||
Ok(File::new(name, file_type, mime_type, created_at))
|
||||
Ok(File::new(name, file_type, mime_type, file_size, created_at))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,9 +450,9 @@ impl FileSystemRepository for FileSystem {
|
||||
}
|
||||
|
||||
async fn cat(&self, request: CatRequest) -> Result<FileStream, CatError> {
|
||||
self.cat(request.path())
|
||||
.await
|
||||
.map_err(|e| anyhow!("Failed to fetch file {}: {e:?}", request.path()).into())
|
||||
self.cat(request.paths().paths()).await.map_err(|e| {
|
||||
anyhow!("Failed to fetch files {:?}: {e:?}", request.paths().paths()).into()
|
||||
})
|
||||
}
|
||||
|
||||
async fn mkdir(&self, request: MkdirRequest) -> Result<(), MkdirError> {
|
||||
@@ -335,28 +464,61 @@ impl FileSystemRepository for FileSystem {
|
||||
})
|
||||
}
|
||||
|
||||
async fn rm(&self, request: RmRequest) -> Result<(), RmError> {
|
||||
self.rm(request.path(), request.force())
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => RmError::NotFound,
|
||||
std::io::ErrorKind::DirectoryNotEmpty => RmError::NotEmpty,
|
||||
_ => anyhow!("Failed to delete file at {}: {e:?}", request.path()).into(),
|
||||
})
|
||||
async fn rm(&self, request: RmRequest) -> Vec<Result<AbsoluteFilePath, RmError>> {
|
||||
let force = request.force();
|
||||
let paths: Vec<AbsoluteFilePath> = request.into_paths().into();
|
||||
|
||||
async fn _rm(
|
||||
fs: &FileSystem,
|
||||
path: AbsoluteFilePath,
|
||||
force: bool,
|
||||
) -> Result<AbsoluteFilePath, RmError> {
|
||||
fs.rm(&path, force)
|
||||
.await
|
||||
.map(|_| path.clone())
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => RmError::NotFound(path),
|
||||
std::io::ErrorKind::DirectoryNotEmpty => RmError::NotEmpty(path),
|
||||
_ => anyhow!("Failed to delete file at {}: {e:?}", path).into(),
|
||||
})
|
||||
}
|
||||
|
||||
join_all(
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| _rm(&self, path, force))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mv(&self, request: MvRequest) -> Result<(), MvError> {
|
||||
self.mv(request.path(), request.target_path())
|
||||
.await
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => MvError::NotFound,
|
||||
_ => anyhow!(
|
||||
"Failed to move {} to {}: {e:?}",
|
||||
request.path(),
|
||||
request.target_path()
|
||||
)
|
||||
.into(),
|
||||
async fn mv(
|
||||
&self,
|
||||
request: MvRequest,
|
||||
) -> Vec<Result<(AbsoluteFilePath, AbsoluteFilePath), MvError>> {
|
||||
async fn _mv(
|
||||
fs: &FileSystem,
|
||||
path: AbsoluteFilePath,
|
||||
target_path: &AbsoluteFilePath,
|
||||
) -> Result<(AbsoluteFilePath, AbsoluteFilePath), MvError> {
|
||||
fs.mv(&path, target_path).await.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => MvError::NotFound(path),
|
||||
_ => MvError::Unknown(
|
||||
anyhow!("Failed to move {} to {}: {e:?}", path, target_path).into(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
let (path_list, target_path) = request.unpack();
|
||||
let paths = Vec::<AbsoluteFilePath>::from(path_list);
|
||||
|
||||
join_all(
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| _mv(&self, path, &target_path))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn touch(&self, request: TouchRequest) -> Result<(), TouchError> {
|
||||
@@ -410,3 +572,178 @@ where
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async fn walk_dir<P>(dir: P) -> Result<Vec<PathBuf>, tokio::io::Error>
|
||||
where
|
||||
P: AsRef<Path>,
|
||||
{
|
||||
let mut dirs = vec![dir.as_ref().to_owned()];
|
||||
let mut files = vec![];
|
||||
|
||||
while !dirs.is_empty() {
|
||||
let path = dirs.remove(0);
|
||||
match tokio::fs::read_dir(path.clone()).await {
|
||||
Ok(mut dir_iter) => {
|
||||
while let Some(entry) = dir_iter.next_entry().await? {
|
||||
let entry_path_buf = entry.path();
|
||||
|
||||
if entry_path_buf.is_dir() {
|
||||
dirs.push(entry_path_buf);
|
||||
} else {
|
||||
files.push(entry_path_buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => match e.kind() {
|
||||
std::io::ErrorKind::NotADirectory => files.push(path),
|
||||
_ => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
/// Creates a ZIP archive from a directory
|
||||
///
|
||||
/// * `path`: The directory's path
|
||||
/// * `tx`: The sender for the new ZIP archive's bytes
|
||||
/// * `buffer_size`: The size of the file read buffer. A large buffer increases both the speed and the memory usage
|
||||
async fn create_zip(
|
||||
prefix: String,
|
||||
paths: Vec<PathBuf>,
|
||||
tx: std::sync::mpsc::Sender<Result<bytes::Bytes, std::io::Error>>,
|
||||
buffer_size: usize,
|
||||
) -> anyhow::Result<()> {
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored)
|
||||
.compression_level(None)
|
||||
.large_file(true)
|
||||
.unix_permissions(0o644);
|
||||
|
||||
let mut file_buf = vec![0; buffer_size];
|
||||
let mut zip = zip::write::ZipWriter::new_stream(ChannelWriter(tx));
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for path in &paths {
|
||||
entries.append(&mut walk_dir(&path).await?);
|
||||
}
|
||||
|
||||
for entry_path_buf in entries {
|
||||
let entry_path = entry_path_buf.as_path();
|
||||
let entry_str = entry_path
|
||||
.strip_prefix(&prefix)?
|
||||
.to_str()
|
||||
.context("Failed to get directory entry name")?;
|
||||
|
||||
if entry_path.is_dir() {
|
||||
zip.add_directory(entry_str, options)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
zip.start_file(entry_str, options)?;
|
||||
let mut entry_file = tokio::fs::File::open(entry_path).await?;
|
||||
|
||||
while let Ok(len) = entry_file.read(&mut file_buf).await
|
||||
&& len > 0
|
||||
{
|
||||
zip.write(&file_buf[..len])?;
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct ChannelWriter(std::sync::mpsc::Sender<Result<bytes::Bytes, std::io::Error>>);
|
||||
|
||||
impl Write for ChannelWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
let len = buf.len();
|
||||
let data = bytes::Bytes::copy_from_slice(buf);
|
||||
|
||||
self.0
|
||||
.send(Ok(data))
|
||||
.map(|_| len)
|
||||
.map_err(|_| std::io::ErrorKind::Other.into())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
enum PathRequest {
|
||||
Single(FilePath),
|
||||
Multiple {
|
||||
lowest_common_prefix: String,
|
||||
paths: Vec<FilePath>,
|
||||
},
|
||||
}
|
||||
|
||||
impl PathRequest {
|
||||
fn from_paths(paths: Vec<FilePath>) -> anyhow::Result<Self> {
|
||||
let mut input_paths: Vec<FilePath> = HashSet::<FilePath>::from_iter(paths.into_iter())
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
if input_paths.len() == 1 {
|
||||
return Ok(Self::Single(input_paths.pop().unwrap()));
|
||||
}
|
||||
|
||||
let mut lowest_common_prefix = input_paths
|
||||
.first()
|
||||
.expect("paths to contain at least 1 entry")
|
||||
.to_string();
|
||||
|
||||
for path in &input_paths {
|
||||
let chars = lowest_common_prefix
|
||||
.chars()
|
||||
.zip(path.as_str().chars())
|
||||
.enumerate();
|
||||
|
||||
for (index, (a, b)) in chars {
|
||||
if a != b {
|
||||
lowest_common_prefix = lowest_common_prefix[..index].to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lowest_common_prefix = if let Some(last_slash_index) = lowest_common_prefix.rfind("/")
|
||||
&& last_slash_index > 1
|
||||
{
|
||||
lowest_common_prefix.truncate(last_slash_index);
|
||||
lowest_common_prefix
|
||||
} else {
|
||||
lowest_common_prefix
|
||||
};
|
||||
|
||||
Ok(Self::Multiple {
|
||||
lowest_common_prefix,
|
||||
paths: input_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn file_size_recursive<P>(path: P) -> io::Result<u64>
|
||||
where
|
||||
P: AsRef<Path> + std::fmt::Debug,
|
||||
{
|
||||
let metadata = fs::metadata(&path).await?;
|
||||
|
||||
if !metadata.is_dir() {
|
||||
return Ok(metadata.size());
|
||||
}
|
||||
|
||||
let mut entries = fs::read_dir(path).await?;
|
||||
|
||||
let mut total = metadata.size();
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
total += Box::pin(file_size_recursive(entry.path())).await?;
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::domain::{
|
||||
oidc::ports::OidcMetrics,
|
||||
warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics},
|
||||
warren::ports::{AuthMetrics, FileSystemMetrics, OptionMetrics, WarrenMetrics},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -151,6 +151,13 @@ impl WarrenMetrics for MetricsDebugLogger {
|
||||
async fn record_warren_share_cat_failure(&self) {
|
||||
tracing::debug!("[Metrics] Warren share cat failed");
|
||||
}
|
||||
|
||||
async fn record_warren_share_password_verification_success(&self) {
|
||||
tracing::debug!("[Metrics] Warren share password verification succeeded");
|
||||
}
|
||||
async fn record_warren_share_password_verification_failure(&self) {
|
||||
tracing::debug!("[Metrics] Warren share password verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSystemMetrics for MetricsDebugLogger {
|
||||
@@ -445,3 +452,26 @@ impl OidcMetrics for MetricsDebugLogger {
|
||||
tracing::debug!("[Metrics] OIDC get user info failed");
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionMetrics for MetricsDebugLogger {
|
||||
async fn record_option_get_success(&self) {
|
||||
tracing::debug!("[Metrics] Get option succeeded");
|
||||
}
|
||||
async fn record_option_get_failure(&self) {
|
||||
tracing::debug!("[Metrics] Get option failed");
|
||||
}
|
||||
|
||||
async fn record_option_set_success(&self) {
|
||||
tracing::debug!("[Metrics] Set option succeeded");
|
||||
}
|
||||
async fn record_option_set_failure(&self) {
|
||||
tracing::debug!("[Metrics] Set option failed");
|
||||
}
|
||||
|
||||
async fn record_option_delete_success(&self) {
|
||||
tracing::debug!("[Metrics] Delete option succeeded");
|
||||
}
|
||||
async fn record_option_delete_failure(&self) {
|
||||
tracing::debug!("[Metrics] Delete option failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ pub mod file_system;
|
||||
pub mod metrics_debug_logger;
|
||||
pub mod notifier_debug_logger;
|
||||
pub mod oidc;
|
||||
pub mod postgres;
|
||||
pub mod sqlite;
|
||||
|
||||
@@ -8,10 +8,11 @@ use crate::domain::{
|
||||
warren::{
|
||||
models::{
|
||||
auth_session::requests::FetchAuthSessionResponse,
|
||||
file::{AbsoluteFilePath, LsResponse},
|
||||
file::{AbsoluteFilePath, AbsoluteFilePathList, LsResponse},
|
||||
option::{DeleteOptionResponse, GetOptionResponse, OptionType, SetOptionResponse},
|
||||
share::{
|
||||
CreateShareResponse, DeleteShareResponse, GetShareResponse, ListSharesResponse,
|
||||
ShareCatResponse, ShareLsResponse,
|
||||
ShareCatResponse, ShareLsResponse, VerifySharePasswordResponse,
|
||||
},
|
||||
user::{
|
||||
ListAllUsersAndWarrensResponse, LoginUserOidcResponse, LoginUserResponse, User,
|
||||
@@ -22,7 +23,7 @@ use crate::domain::{
|
||||
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
|
||||
},
|
||||
},
|
||||
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
|
||||
ports::{AuthNotifier, FileSystemNotifier, OptionNotifier, WarrenNotifier},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,10 +59,10 @@ impl WarrenNotifier for NotifierDebugLogger {
|
||||
tracing::debug!("[Notifier] Fetched warren {}", warren.name());
|
||||
}
|
||||
|
||||
async fn warren_cat(&self, warren: &Warren, path: &AbsoluteFilePath) {
|
||||
async fn warren_cat(&self, warren: &Warren, paths: &AbsoluteFilePathList) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Fetched file {} in warren {}",
|
||||
path,
|
||||
"[Notifier] Fetched {} file(s) in warren {}",
|
||||
paths.paths().len(),
|
||||
warren.name(),
|
||||
);
|
||||
}
|
||||
@@ -87,20 +88,51 @@ impl WarrenNotifier for NotifierDebugLogger {
|
||||
}
|
||||
|
||||
async fn warren_rm(&self, response: &WarrenRmResponse) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Deleted file {} from warren {}",
|
||||
response.path(),
|
||||
response.warren().name(),
|
||||
);
|
||||
let span = tracing::debug_span!("warren_rm", "{}", response.warren().name()).entered();
|
||||
|
||||
let results = response.results();
|
||||
|
||||
for result in results {
|
||||
match result.as_ref() {
|
||||
Ok(path) => tracing::debug!("Deleted file: {path}"),
|
||||
Err(e) => match e {
|
||||
crate::domain::warren::models::file::RmError::NotFound(path) => {
|
||||
tracing::debug!("File not found: {path}")
|
||||
}
|
||||
crate::domain::warren::models::file::RmError::NotEmpty(path) => {
|
||||
tracing::debug!("Directory not empty: {path}")
|
||||
}
|
||||
crate::domain::warren::models::file::RmError::Unknown(_) => (),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
span.exit();
|
||||
}
|
||||
|
||||
async fn warren_mv(&self, response: &WarrenMvResponse) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Renamed file {} to {} in warren {}",
|
||||
response.old_path(),
|
||||
response.path(),
|
||||
response.warren().name(),
|
||||
);
|
||||
let span = tracing::debug_span!("warren_mv", "{}", response.warren().name()).entered();
|
||||
|
||||
let results = response.results();
|
||||
|
||||
for result in results {
|
||||
match result.as_ref() {
|
||||
Ok((old_path, new_path)) => {
|
||||
tracing::debug!("Moved file {old_path} to {new_path}")
|
||||
}
|
||||
Err(e) => match e {
|
||||
crate::domain::warren::models::file::MvError::NotFound(path) => {
|
||||
tracing::debug!("File not found: {path}")
|
||||
}
|
||||
crate::domain::warren::models::file::MvError::AlreadyExists(path) => {
|
||||
tracing::debug!("File already exists: {path}")
|
||||
}
|
||||
crate::domain::warren::models::file::MvError::Unknown(_) => (),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
span.exit();
|
||||
}
|
||||
|
||||
async fn warren_touch(&self, warren: &Warren, path: &AbsoluteFilePath) {
|
||||
@@ -165,11 +197,18 @@ impl WarrenNotifier for NotifierDebugLogger {
|
||||
|
||||
async fn warren_share_cat(&self, response: &ShareCatResponse) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Fetched file {} from share {}",
|
||||
response.path(),
|
||||
"[Notifier] Fetched {} file(s) from share {}",
|
||||
response.paths().paths().len(),
|
||||
response.share().id(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn warren_share_password_verified(&self, response: &VerifySharePasswordResponse) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Verified password for share {}",
|
||||
response.share().id()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSystemNotifier for NotifierDebugLogger {
|
||||
@@ -177,8 +216,8 @@ impl FileSystemNotifier for NotifierDebugLogger {
|
||||
tracing::debug!("[Notifier] Listed {} file(s)", response.files().len());
|
||||
}
|
||||
|
||||
async fn cat(&self, path: &AbsoluteFilePath) {
|
||||
tracing::debug!("[Notifier] Fetched file {path}");
|
||||
async fn cat(&self, paths: &AbsoluteFilePathList) {
|
||||
tracing::debug!("[Notifier] Fetched {} file(s)", paths.paths().len());
|
||||
}
|
||||
|
||||
async fn mkdir(&self, path: &AbsoluteFilePath) {
|
||||
@@ -356,10 +395,11 @@ impl AuthNotifier for NotifierDebugLogger {
|
||||
);
|
||||
}
|
||||
|
||||
async fn auth_warren_cat(&self, user: &User, warren_id: &Uuid, path: &AbsoluteFilePath) {
|
||||
async fn auth_warren_cat(&self, user: &User, warren_id: &Uuid, paths: &AbsoluteFilePathList) {
|
||||
tracing::debug!(
|
||||
"[Notifier] User {} fetched file {path} in warren {warren_id}",
|
||||
"[Notifier] User {} fetched {} file(s) in warren {warren_id}",
|
||||
user.id(),
|
||||
paths.paths().len(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,19 +431,22 @@ impl AuthNotifier for NotifierDebugLogger {
|
||||
}
|
||||
|
||||
async fn auth_warren_rm(&self, user: &User, response: &WarrenRmResponse) {
|
||||
let results = response.results();
|
||||
let successes = results.iter().filter(|r| r.is_ok()).count();
|
||||
|
||||
tracing::debug!(
|
||||
"[Notifier] Deleted file {} from warren {} for authenticated user {}",
|
||||
response.path(),
|
||||
"[Notifier] Deleted {successes} file(s) from warren {} for authenticated user {}",
|
||||
response.warren().name(),
|
||||
user.id(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn auth_warren_mv(&self, user: &User, response: &WarrenMvResponse) {
|
||||
let results = response.results();
|
||||
let successes = results.iter().filter(|r| r.is_ok()).count();
|
||||
|
||||
tracing::debug!(
|
||||
"[Notifier] Renamed file {} to {} in warren {} for authenticated user {}",
|
||||
response.old_path(),
|
||||
response.path(),
|
||||
"[Notifier] Moved {successes} file(s) in warren {} for authenticated user {}",
|
||||
response.warren().name(),
|
||||
user.id(),
|
||||
);
|
||||
@@ -473,3 +516,25 @@ impl OidcNotifier for NotifierDebugLogger {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionNotifier for NotifierDebugLogger {
|
||||
async fn got_option<T: OptionType>(&self, response: &GetOptionResponse<T>) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Got option {}: {}",
|
||||
response.key().to_string(),
|
||||
response.value().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn set_option<T: OptionType>(&self, response: &SetOptionResponse<T>) {
|
||||
tracing::debug!(
|
||||
"[Notifier] Set option {} to {}",
|
||||
response.key().to_string(),
|
||||
response.value().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn deleted_option(&self, response: &DeleteOptionResponse) {
|
||||
tracing::debug!("[Notifier] Deleted option {}", response.key().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
use std::{str::FromStr as _, time::Duration};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use sqlx::{
|
||||
ConnectOptions as _, Connection as _, PgConnection, PgPool,
|
||||
postgres::{PgConnectOptions, PgPoolOptions},
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
pub mod auth;
|
||||
pub mod share;
|
||||
pub mod warrens;
|
||||
|
||||
#[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<Self> {
|
||||
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")?;
|
||||
|
||||
match sqlx::query("SELECT datname FROM pg_database WHERE datname = $1")
|
||||
.bind(&config.database_name)
|
||||
.fetch_one(&mut connection)
|
||||
.await
|
||||
{
|
||||
Ok(_) => (),
|
||||
Err(sqlx::Error::RowNotFound) => {
|
||||
sqlx::query(&format!("CREATE DATABASE {}", config.database_name))
|
||||
.execute(&mut connection)
|
||||
.await?;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
connection.close().await?;
|
||||
|
||||
let pool = PgPoolOptions::new()
|
||||
.connect_with(opts.database(&config.database_name))
|
||||
.await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
// 3600 seconds = 1 hour
|
||||
Self::start_cleanup_tasks(pool.clone(), Duration::from_secs(3600));
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub(super) fn start_cleanup_tasks(pool: PgPool, interval: Duration) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
{
|
||||
let Ok(mut connection) = pool.acquire().await else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Ok(count) = Self::delete_expired_auth_sessions(&mut connection).await {
|
||||
tracing::debug!("Removed {count} expired auth session(s)");
|
||||
}
|
||||
|
||||
if let Ok(count) = Self::delete_expired_shares(&mut connection).await {
|
||||
tracing::debug!("Deleted {count} expired share(s)");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
|
||||
tracing::debug!("Session cleanup task stopped");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_not_found_error(err: &sqlx::Error) -> bool {
|
||||
matches!(err, sqlx::Error::RowNotFound)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use argon2::{
|
||||
},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use sqlx::{Acquire as _, PgConnection};
|
||||
use sqlx::{Acquire as _, SqliteConnection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::warren::{
|
||||
@@ -40,15 +40,15 @@ use crate::domain::warren::{
|
||||
ports::{AuthRepository, WarrenService},
|
||||
};
|
||||
|
||||
use super::{Postgres, is_not_found_error};
|
||||
use super::{Sqlite, is_not_found_error};
|
||||
|
||||
impl AuthRepository for Postgres {
|
||||
impl AuthRepository for Sqlite {
|
||||
async fn create_user(&self, request: CreateUserRequest) -> Result<User, CreateUserError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user = self
|
||||
.create_user(
|
||||
@@ -72,7 +72,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user = self
|
||||
.create_or_update_user(
|
||||
@@ -93,7 +93,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user = self
|
||||
.edit_user(
|
||||
@@ -115,7 +115,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
self.delete_user_from_database(&mut connection, request.user_id())
|
||||
.await
|
||||
@@ -136,7 +136,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user = self
|
||||
.get_user_from_email(&mut connection, request.email())
|
||||
@@ -166,7 +166,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let session = self
|
||||
.create_session(&mut connection, request.user(), request.expiration())
|
||||
@@ -184,7 +184,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let session = self
|
||||
.get_auth_session(&mut connection, request.session_id())
|
||||
@@ -212,7 +212,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user_warren = self
|
||||
.add_user_to_warren(&mut connection, request.user_warren())
|
||||
@@ -230,7 +230,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user_warren = self
|
||||
.update_user_warren(&mut connection, request.user_warren())
|
||||
@@ -248,7 +248,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user_warren = self
|
||||
.remove_user_from_warren(&mut connection, request.user_id(), request.warren_id())
|
||||
@@ -272,7 +272,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user_warrens = self
|
||||
.get_user_warrens(&mut connection, request.user_id())
|
||||
@@ -290,7 +290,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let user_warrens = self
|
||||
.get_all_user_warrens(&mut connection)
|
||||
@@ -308,7 +308,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
self.get_user_warren(&mut connection, request.user_id(), request.warren_id())
|
||||
.await
|
||||
@@ -326,7 +326,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let users = self
|
||||
.fetch_users(&mut connection)
|
||||
@@ -345,7 +345,7 @@ impl AuthRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let users = self
|
||||
.fetch_users(&mut connection)
|
||||
@@ -368,9 +368,9 @@ impl AuthRepository for Postgres {
|
||||
}
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
impl Sqlite {
|
||||
pub(super) async fn delete_expired_auth_sessions(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let delete_count = sqlx::query(
|
||||
"
|
||||
@@ -389,7 +389,7 @@ impl Postgres {
|
||||
|
||||
async fn create_user(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
name: &UserName,
|
||||
email: &UserEmail,
|
||||
password: &UserPassword,
|
||||
@@ -402,6 +402,7 @@ impl Postgres {
|
||||
|
||||
let user: User = sqlx::query_as(
|
||||
"INSERT INTO users (
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
hash,
|
||||
@@ -411,12 +412,14 @@ impl Postgres {
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4
|
||||
$4,
|
||||
$5
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(name)
|
||||
.bind(email)
|
||||
.bind(password_hash)
|
||||
@@ -431,7 +434,7 @@ impl Postgres {
|
||||
|
||||
async fn create_or_update_user(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
sub: &String,
|
||||
name: &UserName,
|
||||
email: &UserEmail,
|
||||
@@ -546,7 +549,7 @@ impl Postgres {
|
||||
|
||||
async fn edit_user(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
id: &Uuid,
|
||||
name: &UserName,
|
||||
email: &UserEmail,
|
||||
@@ -592,7 +595,7 @@ impl Postgres {
|
||||
|
||||
async fn delete_user_sessions(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_id: &Uuid,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let rows_affected = sqlx::query(
|
||||
@@ -613,7 +616,7 @@ impl Postgres {
|
||||
|
||||
async fn delete_user_from_database(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_id: &Uuid,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
let user: User = sqlx::query_as(
|
||||
@@ -635,7 +638,7 @@ impl Postgres {
|
||||
|
||||
async fn get_user_from_id(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
id: &Uuid,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
let user: User = sqlx::query_as(
|
||||
@@ -657,7 +660,7 @@ impl Postgres {
|
||||
|
||||
async fn get_user_from_email(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
email: &UserEmail,
|
||||
) -> Result<User, sqlx::Error> {
|
||||
let user: User = sqlx::query_as(
|
||||
@@ -698,7 +701,7 @@ impl Postgres {
|
||||
|
||||
async fn create_session(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user: &User,
|
||||
expiration: &SessionExpirationTime,
|
||||
) -> anyhow::Result<AuthSession> {
|
||||
@@ -721,7 +724,7 @@ impl Postgres {
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
TO_TIMESTAMP($3::double precision / 1000)
|
||||
datetime($3, 'unixepoch')
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
@@ -729,7 +732,7 @@ impl Postgres {
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(user.id())
|
||||
.bind(expiration_time)
|
||||
.bind(expiration_time / 1000)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -740,7 +743,7 @@ impl Postgres {
|
||||
|
||||
async fn get_auth_session(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
session_id: &AuthSessionId,
|
||||
) -> Result<AuthSession, sqlx::Error> {
|
||||
let session: AuthSession = sqlx::query_as(
|
||||
@@ -762,7 +765,7 @@ impl Postgres {
|
||||
|
||||
async fn get_user_warrens(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_id: &Uuid,
|
||||
) -> Result<Vec<UserWarren>, sqlx::Error> {
|
||||
let user_warrens: Vec<UserWarren> = sqlx::query_as(
|
||||
@@ -784,7 +787,7 @@ impl Postgres {
|
||||
|
||||
async fn get_all_user_warrens(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
) -> Result<Vec<UserWarren>, sqlx::Error> {
|
||||
let user_warrens: Vec<UserWarren> = sqlx::query_as(
|
||||
"
|
||||
@@ -802,7 +805,7 @@ impl Postgres {
|
||||
|
||||
async fn get_user_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_id: &Uuid,
|
||||
warren_id: &Uuid,
|
||||
) -> Result<UserWarren, sqlx::Error> {
|
||||
@@ -825,7 +828,10 @@ impl Postgres {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn fetch_users(&self, connection: &mut PgConnection) -> Result<Vec<User>, sqlx::Error> {
|
||||
async fn fetch_users(
|
||||
&self,
|
||||
connection: &mut SqliteConnection,
|
||||
) -> Result<Vec<User>, sqlx::Error> {
|
||||
let users: Vec<User> = sqlx::query_as(
|
||||
"
|
||||
SELECT
|
||||
@@ -844,7 +850,7 @@ impl Postgres {
|
||||
|
||||
async fn add_user_to_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_warren: &UserWarren,
|
||||
) -> Result<UserWarren, sqlx::Error> {
|
||||
let user_warren: UserWarren = sqlx::query_as(
|
||||
@@ -855,14 +861,22 @@ impl Postgres {
|
||||
can_list_files,
|
||||
can_read_files,
|
||||
can_modify_files,
|
||||
can_delete_files
|
||||
can_delete_files,
|
||||
can_list_shares,
|
||||
can_create_shares,
|
||||
can_modify_shares,
|
||||
can_delete_shares
|
||||
) VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
@@ -874,6 +888,10 @@ impl Postgres {
|
||||
.bind(user_warren.can_read_files())
|
||||
.bind(user_warren.can_modify_files())
|
||||
.bind(user_warren.can_delete_files())
|
||||
.bind(user_warren.can_list_shares())
|
||||
.bind(user_warren.can_create_shares())
|
||||
.bind(user_warren.can_modify_shares())
|
||||
.bind(user_warren.can_delete_shares())
|
||||
.fetch_one(connection)
|
||||
.await?;
|
||||
|
||||
@@ -882,7 +900,7 @@ impl Postgres {
|
||||
|
||||
async fn update_user_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_warren: &UserWarren,
|
||||
) -> Result<UserWarren, sqlx::Error> {
|
||||
let user_warren: UserWarren = sqlx::query_as(
|
||||
@@ -923,7 +941,7 @@ impl Postgres {
|
||||
|
||||
async fn remove_user_from_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
user_id: &Uuid,
|
||||
warren_id: &Uuid,
|
||||
) -> Result<UserWarren, sqlx::Error> {
|
||||
72
backend/src/lib/outbound/sqlite/mod.rs
Normal file
72
backend/src/lib/outbound/sqlite/mod.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use std::{str::FromStr as _, time::Duration};
|
||||
|
||||
use sqlx::{
|
||||
ConnectOptions as _, SqlitePool,
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
pub mod auth;
|
||||
pub mod options;
|
||||
pub mod share;
|
||||
pub mod warrens;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqliteConfig {
|
||||
database_url: String,
|
||||
}
|
||||
|
||||
impl SqliteConfig {
|
||||
pub fn new(database_url: String) -> Self {
|
||||
Self { database_url }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sqlite {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl Sqlite {
|
||||
pub async fn new(config: SqliteConfig) -> anyhow::Result<Self> {
|
||||
let opts = SqliteConnectOptions::from_str(&config.database_url)?
|
||||
.create_if_missing(true)
|
||||
.read_only(false)
|
||||
.disable_statement_logging();
|
||||
|
||||
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
// 3600 seconds = 1 hour
|
||||
Self::start_cleanup_tasks(pool.clone(), Duration::from_secs(3600));
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
pub(super) fn start_cleanup_tasks(pool: SqlitePool, interval: Duration) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
{
|
||||
let Ok(mut connection) = pool.acquire().await else {
|
||||
break;
|
||||
};
|
||||
|
||||
if let Ok(count) = Self::delete_expired_auth_sessions(&mut connection).await {
|
||||
tracing::debug!("Removed {count} expired auth session(s)");
|
||||
}
|
||||
|
||||
if let Ok(count) = Self::delete_expired_shares(&mut connection).await {
|
||||
tracing::debug!("Deleted {count} expired share(s)");
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
|
||||
tracing::debug!("Session cleanup task stopped");
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_not_found_error(err: &sqlx::Error) -> bool {
|
||||
matches!(err, sqlx::Error::RowNotFound)
|
||||
}
|
||||
134
backend/src/lib/outbound/sqlite/options.rs
Normal file
134
backend/src/lib/outbound/sqlite/options.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use anyhow::Context;
|
||||
use sqlx::FromRow;
|
||||
|
||||
use crate::domain::warren::{
|
||||
models::option::{
|
||||
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
|
||||
GetOptionRequest, GetOptionResponse, OptionKey, OptionType, SetOptionError,
|
||||
SetOptionRequest, SetOptionResponse,
|
||||
},
|
||||
ports::OptionRepository,
|
||||
};
|
||||
|
||||
use super::{Sqlite, is_not_found_error};
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct OptionRow {
|
||||
key: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl OptionRepository for Sqlite {
|
||||
async fn get_option<T: OptionType>(
|
||||
&self,
|
||||
request: GetOptionRequest,
|
||||
) -> Result<GetOptionResponse<T>, GetOptionError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let key: OptionKey = request.into();
|
||||
|
||||
let row: OptionRow = sqlx::query_as(
|
||||
"
|
||||
SELECT
|
||||
key,
|
||||
value
|
||||
FROM
|
||||
application_options
|
||||
WHERE
|
||||
key = $1",
|
||||
)
|
||||
.bind(key.as_str())
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_not_found_error(&e) {
|
||||
GetOptionError::NotFound(key)
|
||||
} else {
|
||||
GetOptionError::Unknown(e.into())
|
||||
}
|
||||
})?;
|
||||
|
||||
let parsed = T::parse(&row.value).map_err(|_| GetOptionError::Parse)?;
|
||||
|
||||
Ok(GetOptionResponse::new(
|
||||
OptionKey::new(&row.key).unwrap(),
|
||||
parsed,
|
||||
))
|
||||
}
|
||||
|
||||
async fn set_option<T: OptionType>(
|
||||
&self,
|
||||
request: SetOptionRequest<T>,
|
||||
) -> Result<SetOptionResponse<T>, SetOptionError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let (key, value) = request.unpack();
|
||||
|
||||
sqlx::query_as::<_, OptionRow>(
|
||||
"
|
||||
INSERT INTO application_options (
|
||||
key,
|
||||
value
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
)
|
||||
RETURNING
|
||||
key,
|
||||
value
|
||||
",
|
||||
)
|
||||
.bind(key.as_str())
|
||||
.bind(value.inner().to_string())
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|e| SetOptionError::Unknown(e.into()))?;
|
||||
|
||||
Ok(SetOptionResponse::new(key, value.get_inner()))
|
||||
}
|
||||
|
||||
async fn delete_option(
|
||||
&self,
|
||||
request: DeleteOptionRequest,
|
||||
) -> Result<DeleteOptionResponse, DeleteOptionError> {
|
||||
let mut connection = self
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let key: OptionKey = request.into();
|
||||
|
||||
sqlx::query_as::<_, OptionRow>(
|
||||
"
|
||||
DELETE FROM
|
||||
application_options
|
||||
WHERE
|
||||
key = $1
|
||||
RETURNING
|
||||
key,
|
||||
value
|
||||
",
|
||||
)
|
||||
.bind(key.as_str())
|
||||
.fetch_one(&mut *connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_not_found_error(&e) {
|
||||
DeleteOptionError::NotFound(key.clone())
|
||||
} else {
|
||||
DeleteOptionError::Unknown(e.into())
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(DeleteOptionResponse::new(key))
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use anyhow::anyhow;
|
||||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordVerifier as _,
|
||||
password_hash::{PasswordHasher as _, SaltString, rand_core::OsRng},
|
||||
password_hash::{PasswordHasher as _, SaltString},
|
||||
};
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
use sqlx::{Acquire as _, PgConnection};
|
||||
use sqlx::{Acquire as _, SqliteConnection};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::domain::warren::models::{
|
||||
warren::HasWarrenId as _,
|
||||
};
|
||||
|
||||
use super::{Postgres, is_not_found_error};
|
||||
use super::{Sqlite, is_not_found_error};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ShareRow {
|
||||
@@ -62,7 +62,7 @@ impl TryFrom<ShareRow> for Share {
|
||||
}
|
||||
|
||||
pub(super) async fn get_share(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
request: GetShareRequest,
|
||||
) -> anyhow::Result<Share> {
|
||||
let share_row: ShareRow = sqlx::query_as(
|
||||
@@ -90,7 +90,7 @@ pub(super) async fn get_share(
|
||||
}
|
||||
|
||||
pub(super) async fn list_shares(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
request: ListSharesRequest,
|
||||
) -> anyhow::Result<Vec<Share>> {
|
||||
let share_rows: Vec<ShareRow> = sqlx::query_as(
|
||||
@@ -107,7 +107,8 @@ pub(super) async fn list_shares(
|
||||
shares
|
||||
WHERE
|
||||
warren_id = $1 AND
|
||||
path = $2
|
||||
path = $2 AND
|
||||
(expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
|
||||
ORDER BY
|
||||
created_at DESC
|
||||
",
|
||||
@@ -126,13 +127,13 @@ pub(super) async fn list_shares(
|
||||
}
|
||||
|
||||
pub(super) async fn create_share(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
request: CreateShareRequest,
|
||||
) -> anyhow::Result<Share> {
|
||||
let mut tx = connection.begin().await?;
|
||||
|
||||
let password_hash = if let Some(password) = request.base().password() {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
|
||||
Some(
|
||||
@@ -154,6 +155,7 @@ pub(super) async fn create_share(
|
||||
let share: ShareRow = sqlx::query_as(
|
||||
"
|
||||
INSERT INTO shares (
|
||||
id,
|
||||
creator_id,
|
||||
warren_id,
|
||||
path,
|
||||
@@ -164,17 +166,19 @@ pub(super) async fn create_share(
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
TO_TIMESTAMP($5::double precision / 1000)
|
||||
$5,
|
||||
datetime($6, 'unixepoch')
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(request.creator_id())
|
||||
.bind(request.warren_id())
|
||||
.bind(request.base().path())
|
||||
.bind(password_hash)
|
||||
.bind(expires_at)
|
||||
.bind(expires_at.map(|v| v / 1000))
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -184,7 +188,7 @@ pub(super) async fn create_share(
|
||||
}
|
||||
|
||||
pub(super) async fn delete_share(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
request: DeleteShareRequest,
|
||||
) -> anyhow::Result<Share> {
|
||||
let mut tx = connection.begin().await?;
|
||||
@@ -209,7 +213,7 @@ pub(super) async fn delete_share(
|
||||
}
|
||||
|
||||
pub(super) async fn verify_password(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
request: VerifySharePasswordRequest,
|
||||
) -> Result<Share, VerifySharePasswordError> {
|
||||
let share_row: ShareRow = sqlx::query_as(
|
||||
@@ -264,9 +268,9 @@ pub(super) async fn verify_password(
|
||||
}
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
impl Sqlite {
|
||||
pub(super) async fn delete_expired_shares(
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
) -> Result<u64, sqlx::Error> {
|
||||
let delete_count = sqlx::query(
|
||||
"
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use sqlx::{Acquire as _, PgConnection};
|
||||
use sqlx::{Acquire as _, SqliteConnection};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::domain::warren::{
|
||||
@@ -21,9 +21,9 @@ use crate::domain::warren::{
|
||||
ports::WarrenRepository,
|
||||
};
|
||||
|
||||
use super::{Postgres, is_not_found_error};
|
||||
use super::{Sqlite, is_not_found_error};
|
||||
|
||||
impl WarrenRepository for Postgres {
|
||||
impl WarrenRepository for Sqlite {
|
||||
async fn create_warren(
|
||||
&self,
|
||||
request: CreateWarrenRequest,
|
||||
@@ -32,7 +32,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let warren = self
|
||||
.create_warren(&mut connection, request.name(), request.path())
|
||||
@@ -47,7 +47,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let warren = self
|
||||
.edit_warren(
|
||||
@@ -70,7 +70,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let warren = self
|
||||
.delete_warren(&mut connection, request.id())
|
||||
@@ -88,7 +88,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let warrens = self
|
||||
.fetch_warrens(&mut connection, request.ids())
|
||||
@@ -106,7 +106,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let warrens = self
|
||||
.fetch_all_warrens(&mut connection)
|
||||
@@ -121,7 +121,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let warren = self
|
||||
.get_warren(&mut connection, request.id())
|
||||
@@ -144,7 +144,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
super::share::get_share(&mut connection, request)
|
||||
.await
|
||||
@@ -159,7 +159,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
super::share::create_share(&mut connection, request)
|
||||
.await
|
||||
@@ -177,7 +177,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
let path = request.path().clone();
|
||||
|
||||
@@ -195,7 +195,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
super::share::delete_share(&mut connection, request)
|
||||
.await
|
||||
@@ -211,7 +211,7 @@ impl WarrenRepository for Postgres {
|
||||
.pool
|
||||
.acquire()
|
||||
.await
|
||||
.context("Failed to get a PostgreSQL connection")?;
|
||||
.context("Failed to get a Sqlite connection")?;
|
||||
|
||||
super::share::verify_password(&mut connection, request)
|
||||
.await
|
||||
@@ -220,10 +220,10 @@ impl WarrenRepository for Postgres {
|
||||
}
|
||||
}
|
||||
|
||||
impl Postgres {
|
||||
impl Sqlite {
|
||||
async fn create_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
name: &WarrenName,
|
||||
path: &AbsoluteFilePath,
|
||||
) -> Result<Warren, sqlx::Error> {
|
||||
@@ -232,16 +232,19 @@ impl Postgres {
|
||||
let warren: Warren = sqlx::query_as(
|
||||
"
|
||||
INSERT INTO warrens (
|
||||
id,
|
||||
name,
|
||||
path
|
||||
) VALUES (
|
||||
$1,
|
||||
$2
|
||||
$2,
|
||||
$3
|
||||
)
|
||||
RETURNING
|
||||
*
|
||||
",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(name)
|
||||
.bind(path)
|
||||
.fetch_one(&mut *tx)
|
||||
@@ -254,7 +257,7 @@ impl Postgres {
|
||||
|
||||
async fn edit_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
id: &Uuid,
|
||||
name: &WarrenName,
|
||||
path: &AbsoluteFilePath,
|
||||
@@ -287,7 +290,7 @@ impl Postgres {
|
||||
|
||||
async fn delete_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
id: &Uuid,
|
||||
) -> Result<Warren, sqlx::Error> {
|
||||
let mut tx = connection.begin().await?;
|
||||
@@ -313,7 +316,7 @@ impl Postgres {
|
||||
|
||||
async fn get_warren(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
id: &Uuid,
|
||||
) -> Result<Warren, sqlx::Error> {
|
||||
let warren: Warren = sqlx::query_as(
|
||||
@@ -335,20 +338,28 @@ impl Postgres {
|
||||
|
||||
async fn fetch_warrens(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
ids: &[Uuid],
|
||||
) -> Result<Vec<Warren>, sqlx::Error> {
|
||||
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Postgres, Warren>(
|
||||
let mut ids_as_string = ids.into_iter().fold(String::new(), |mut acc, id| {
|
||||
let encoded = hex::encode(id.as_bytes());
|
||||
acc.push_str("x'");
|
||||
acc.push_str(encoded.as_str());
|
||||
acc.push_str("',");
|
||||
acc
|
||||
});
|
||||
ids_as_string.pop();
|
||||
|
||||
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Sqlite, Warren>(&format!(
|
||||
"
|
||||
SELECT
|
||||
*
|
||||
FROM
|
||||
warrens
|
||||
WHERE
|
||||
id = ANY($1)
|
||||
id IN ({ids_as_string})
|
||||
",
|
||||
)
|
||||
.bind(ids)
|
||||
))
|
||||
.fetch_all(&mut *connection)
|
||||
.await?;
|
||||
|
||||
@@ -357,9 +368,9 @@ impl Postgres {
|
||||
|
||||
async fn fetch_all_warrens(
|
||||
&self,
|
||||
connection: &mut PgConnection,
|
||||
connection: &mut SqliteConnection,
|
||||
) -> Result<Vec<Warren>, sqlx::Error> {
|
||||
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Postgres, Warren>(
|
||||
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Sqlite, Warren>(
|
||||
"
|
||||
SELECT
|
||||
*
|
||||
19
compose.yaml
19
compose.yaml
@@ -1,7 +1,5 @@
|
||||
services:
|
||||
warren:
|
||||
depends_on:
|
||||
- 'postgres'
|
||||
image: 'warren:latest'
|
||||
container_name: 'warren'
|
||||
build: '.'
|
||||
@@ -13,26 +11,15 @@ services:
|
||||
environment:
|
||||
- 'SERVER_ADDRESS=0.0.0.0'
|
||||
- 'SERVER_PORT=8080'
|
||||
- 'DATABASE_URL=postgres://postgres:pg@warren-postgres:5432'
|
||||
- 'DATABASE_NAME=warren'
|
||||
- 'DATABASE_URL=sqlite:///var/lib/warren/data/warren.db'
|
||||
- 'SERVE_DIRECTORY=/serve'
|
||||
- 'CORS_ALLOW_ORIGIN=http://localhost:8081'
|
||||
- 'LOG_LEVEL=debug'
|
||||
- 'MAX_FILE_FETCH_BYTES=10737418240'
|
||||
- 'ZIP_READ_BUFFER_BYTES=4096'
|
||||
volumes:
|
||||
- './backend/serve:/serve:rw'
|
||||
postgres:
|
||||
image: 'postgres:17'
|
||||
container_name: 'warren-db'
|
||||
hostname: 'warren-postgres'
|
||||
networks:
|
||||
- 'warren-net'
|
||||
volumes:
|
||||
- './postgres-data:/var/lib/postgresql/data'
|
||||
environment:
|
||||
- 'POSTGRES_PASSWORD=pg'
|
||||
ports:
|
||||
- '5432:5432/tcp'
|
||||
- './backend/data:/var/lib/warren/data:rw'
|
||||
networks:
|
||||
warren-net:
|
||||
name: 'warren-net'
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
@@ -20,6 +20,7 @@ body,
|
||||
#__layout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import byteSize from 'byte-size';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
const { entry } = defineProps<{ entry: DirectoryEntry }>();
|
||||
@@ -28,12 +29,9 @@ const onDrop = onDirectoryEntryDrop(entry, true);
|
||||
>({{ entry.name }})</span
|
||||
></span
|
||||
>
|
||||
<NuxtTime
|
||||
v-if="entry.createdAt != null"
|
||||
:datetime="entry.createdAt * 1000"
|
||||
class="text-muted-foreground w-full truncate text-sm"
|
||||
relative
|
||||
></NuxtTime>
|
||||
<span class="text-muted-foreground w-full truncate text-sm">{{
|
||||
byteSize(entry.size)
|
||||
}}</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -6,25 +6,31 @@ import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { deleteWarrenDirectory, deleteWarrenFile } from '~/lib/api/warrens';
|
||||
import type { DirectoryEntry } from '#shared/types';
|
||||
import { toast } from 'vue-sonner';
|
||||
import byteSize from 'byte-size';
|
||||
|
||||
const warrenStore = useWarrenStore();
|
||||
const copyStore = useCopyStore();
|
||||
const renameDialog = useRenameDirectoryDialog();
|
||||
|
||||
const { entry, disabled } = defineProps<{
|
||||
const {
|
||||
entry,
|
||||
entryIndex,
|
||||
disabled,
|
||||
draggable = true,
|
||||
} = defineProps<{
|
||||
entry: DirectoryEntry;
|
||||
entryIndex: number;
|
||||
disabled: boolean;
|
||||
draggable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [entry: DirectoryEntry];
|
||||
'entry-click': [entry: DirectoryEntry, event: MouseEvent];
|
||||
'entry-download': [entry: DirectoryEntry];
|
||||
'entry-delete': [entry: DirectoryEntry, force: boolean];
|
||||
}>();
|
||||
|
||||
const deleting = ref(false);
|
||||
const isCopied = computed(
|
||||
() =>
|
||||
warrenStore.current != null &&
|
||||
@@ -33,38 +39,18 @@ const isCopied = computed(
|
||||
warrenStore.current.path === copyStore.file.path &&
|
||||
entry.name === copyStore.file.name
|
||||
);
|
||||
const isSelected = computed(() => warrenStore.isSelected(entry));
|
||||
|
||||
async function submitDelete(force: boolean = false) {
|
||||
if (warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
deleting.value = true;
|
||||
|
||||
if (entry.fileType === 'directory') {
|
||||
await deleteWarrenDirectory(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry.name,
|
||||
force
|
||||
);
|
||||
} else {
|
||||
await deleteWarrenFile(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry.name
|
||||
);
|
||||
}
|
||||
|
||||
deleting.value = false;
|
||||
function onDelete(force: boolean = false) {
|
||||
emit('entry-delete', entry, force);
|
||||
}
|
||||
|
||||
async function openRenameDialog() {
|
||||
function openRenameDialog() {
|
||||
renameDialog.openDialog(entry);
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
emit('entry-click', entry);
|
||||
function onClick(event: MouseEvent) {
|
||||
emit('entry-click', entry, event);
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
@@ -97,18 +83,25 @@ function onShare() {
|
||||
function onDownload() {
|
||||
emit('entry-download', entry);
|
||||
}
|
||||
|
||||
function onClearCopy() {
|
||||
copyStore.clearFile();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger class="flex sm:w-52">
|
||||
<button
|
||||
:data-entry-index="entryIndex"
|
||||
:disabled="warrenStore.loading || disabled"
|
||||
:class="[
|
||||
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 select-none',
|
||||
'bg-accent/30 border-border flex w-full translate-0 flex-row gap-4 overflow-hidden rounded-md border-1 px-4 py-2 outline-0 select-none',
|
||||
isCopied && 'border-primary/50 border',
|
||||
isSelected && 'bg-primary/20',
|
||||
]"
|
||||
draggable="true"
|
||||
:draggable
|
||||
@pointerdown.stop
|
||||
@dragstart="onDragStart"
|
||||
@drop="onDrop"
|
||||
@click="onClick"
|
||||
@@ -119,39 +112,49 @@ function onDownload() {
|
||||
class="flex w-full flex-col items-start justify-stretch gap-0 overflow-hidden text-left leading-6"
|
||||
>
|
||||
<span class="w-full truncate">{{ entry.name }}</span>
|
||||
<NuxtTime
|
||||
v-if="entry.createdAt != null"
|
||||
:datetime="entry.createdAt * 1000"
|
||||
<span
|
||||
class="text-muted-foreground w-full truncate text-sm"
|
||||
relative
|
||||
></NuxtTime>
|
||||
>{{ byteSize(entry.size) }}</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@pointerdown.stop
|
||||
@select="openRenameDialog"
|
||||
>
|
||||
<Icon name="lucide:pencil" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="onCopy"
|
||||
>
|
||||
<Icon name="lucide:copy" />
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:disabled="entry.fileType !== 'file'"
|
||||
@select="onDownload"
|
||||
>
|
||||
<template v-if="warrenStore.current != null">
|
||||
<ContextMenuItem
|
||||
v-if="
|
||||
copyStore.file == null ||
|
||||
copyStore.file.warrenId !==
|
||||
warrenStore.current.warrenId ||
|
||||
copyStore.file.path !== warrenStore.current.path ||
|
||||
copyStore.file.name !== entry.name
|
||||
"
|
||||
@pointerdown.stop
|
||||
@select="onCopy"
|
||||
>
|
||||
<Icon name="lucide:copy" />
|
||||
Copy
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem v-else @pointerdown.stop @select="onClearCopy">
|
||||
<Icon name="lucide:copy-x" />
|
||||
Clear clipboard
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
<ContextMenuItem @pointerdown.stop @select="onDownload">
|
||||
<Icon name="lucide:download" />
|
||||
Download
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@pointerdown.stop
|
||||
@select="onShare"
|
||||
>
|
||||
<Icon name="lucide:share" />
|
||||
@@ -164,15 +167,16 @@ function onDownload() {
|
||||
|
||||
<ContextMenuItem
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="() => submitDelete(false)"
|
||||
@pointerdown.stop
|
||||
@select="() => onDelete(false)"
|
||||
>
|
||||
<Icon name="lucide:trash-2" />
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
v-if="entry.fileType === 'directory'"
|
||||
:class="[warrenStore.current == null && 'hidden']"
|
||||
@select="() => submitDelete(true)"
|
||||
@pointerdown.stop
|
||||
@select="() => onDelete(true)"
|
||||
>
|
||||
<Icon
|
||||
class="text-destructive-foreground"
|
||||
|
||||
@@ -33,10 +33,10 @@ const warrenStore = useWarrenStore();
|
||||
:data="
|
||||
route.meta.layout === 'share'
|
||||
? getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${route.query.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
`warrens/files/cat_share?shareId=${route.query.id}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
: getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
`warrens/files/cat?warrenId=${warrenStore.current!.warrenId}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
"
|
||||
>
|
||||
|
||||
@@ -7,32 +7,35 @@ const {
|
||||
parent,
|
||||
isOverDropZone,
|
||||
disableEntries = false,
|
||||
entriesDraggable = true,
|
||||
} = defineProps<{
|
||||
entries: DirectoryEntry[];
|
||||
parent: DirectoryEntry | null;
|
||||
isOverDropZone?: boolean;
|
||||
disableEntries?: boolean;
|
||||
entriesDraggable?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'entry-click': [entry: DirectoryEntry];
|
||||
'entry-click': [entry: DirectoryEntry, event: MouseEvent];
|
||||
'entry-download': [entry: DirectoryEntry];
|
||||
'entry-delete': [entry: DirectoryEntry, force: boolean];
|
||||
back: [];
|
||||
}>();
|
||||
|
||||
const { isLoading } = useLoadingIndicator();
|
||||
|
||||
const sortedEntries = computed(() =>
|
||||
entries.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
);
|
||||
|
||||
function onEntryClicked(entry: DirectoryEntry) {
|
||||
emit('entry-click', entry);
|
||||
function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
|
||||
emit('entry-click', entry, event);
|
||||
}
|
||||
|
||||
function onEntryDownload(entry: DirectoryEntry) {
|
||||
emit('entry-download', entry);
|
||||
}
|
||||
|
||||
function onEntryDelete(entry: DirectoryEntry, force: boolean) {
|
||||
emit('entry-delete', entry, force);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -52,12 +55,15 @@ function onEntryDownload(entry: DirectoryEntry) {
|
||||
@back="() => emit('back')"
|
||||
/>
|
||||
<DirectoryEntry
|
||||
v-for="entry in sortedEntries"
|
||||
v-for="(entry, i) in entries"
|
||||
:key="entry.name"
|
||||
:entry-index="i"
|
||||
:entry="entry"
|
||||
:disabled="isLoading || disableEntries"
|
||||
:draggable="entriesDraggable"
|
||||
@entry-click="onEntryClicked"
|
||||
@entry-download="onEntryDownload"
|
||||
@entry-delete="onEntryDelete"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
const warrenStore = useWarrenStore();
|
||||
const copyStore = useCopyStore();
|
||||
const createDirectoryDialog = useCreateDirectoryDialog();
|
||||
const createFileDialog = useCreateFileDialog();
|
||||
|
||||
const pasting = ref<boolean>(false);
|
||||
const validPaste = computed(
|
||||
@@ -27,12 +28,16 @@ async function onPaste() {
|
||||
|
||||
pasting.value = true;
|
||||
|
||||
await pasteFile(copyStore.file!, {
|
||||
const success = await pasteFile(copyStore.file!, {
|
||||
warrenId: warrenStore.current!.warrenId,
|
||||
name: copyStore.file!.name,
|
||||
path: warrenStore.current!.path,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
copyStore.clearFile();
|
||||
}
|
||||
|
||||
pasting.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -47,6 +52,10 @@ async function onPaste() {
|
||||
<Icon name="lucide:clipboard-paste" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @select="createFileDialog.openDialog">
|
||||
<Icon name="lucide:file-plus" />
|
||||
Create file
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @select="createDirectoryDialog.openDialog">
|
||||
<Icon name="lucide:folder-plus" />
|
||||
Create directory
|
||||
|
||||
122
frontend/components/SelectionRect.vue
Normal file
122
frontend/components/SelectionRect.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
const rect = useSelectionRect();
|
||||
const warrenStore = useWarrenStore();
|
||||
|
||||
const left = computed(() => Math.min(rect.a.x, rect.b.x));
|
||||
const top = computed(() => Math.min(rect.a.y, rect.b.y));
|
||||
const width = computed(() => Math.abs(rect.a.x - rect.b.x));
|
||||
const height = computed(() => Math.abs(rect.a.y - rect.b.y));
|
||||
|
||||
function onDocumentPointerDown(e: MouseEvent) {
|
||||
if (e.button !== 0 || matchMedia('(pointer:coarse)').matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const point = { x: e.x, y: e.y };
|
||||
rect.set(
|
||||
point,
|
||||
point,
|
||||
!e.shiftKey ? 'set' : e.ctrlKey ? 'subtract' : 'add'
|
||||
);
|
||||
}
|
||||
|
||||
function onDocumentPointerMove(e: MouseEvent) {
|
||||
if (!rect.enabled || matchMedia('(pointer:coarse)').matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
rect.updateB(e.x, e.y);
|
||||
|
||||
if (
|
||||
rect.mode !== 'set' ||
|
||||
warrenStore.selection.size === 0 ||
|
||||
!rect.isMinSize()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
warrenStore.clearSelection();
|
||||
}
|
||||
|
||||
function onDocumentPointerUp(e: MouseEvent) {
|
||||
if (
|
||||
!rect.enabled ||
|
||||
e.button !== 0 ||
|
||||
matchMedia('(pointer:coarse)').matches
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const left = Math.min(rect.a.x, rect.b.x);
|
||||
const top = Math.min(rect.a.y, rect.b.y);
|
||||
const width = Math.abs(rect.a.x - rect.b.x);
|
||||
const height = Math.abs(rect.a.y - rect.b.y);
|
||||
const selectionRect = new DOMRect(left, top, width, height);
|
||||
|
||||
const isMinSize = rect.isMinSize();
|
||||
|
||||
rect.disable();
|
||||
|
||||
if (warrenStore.current == null || warrenStore.current.dir == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMinSize) {
|
||||
warrenStore.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
const entryElements = document.querySelectorAll('[data-entry-index]');
|
||||
|
||||
const targetEntries = [];
|
||||
|
||||
for (const entry of entryElements) {
|
||||
const attributeValue = entry.getAttribute('data-entry-index');
|
||||
if (attributeValue == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = parseInt(attributeValue);
|
||||
if (isNaN(index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryRect = entry.getBoundingClientRect();
|
||||
if (intersectRect(selectionRect, entryRect)) {
|
||||
targetEntries.push(warrenStore.current.dir.entries[index]);
|
||||
}
|
||||
}
|
||||
|
||||
if (rect.mode === 'set') {
|
||||
warrenStore.setSelection(targetEntries);
|
||||
} else if (rect.mode === 'add') {
|
||||
warrenStore.addMultipleToSelection(targetEntries);
|
||||
} else if (rect.mode === 'subtract') {
|
||||
warrenStore.removeMultipleFromSelection(targetEntries);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||
document.addEventListener('pointermove', onDocumentPointerMove);
|
||||
document.addEventListener('pointerup', onDocumentPointerUp);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||
document.removeEventListener('pointermove', onDocumentPointerMove);
|
||||
document.removeEventListener('pointerup', onDocumentPointerUp);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="rect.enabled && rect.isMinSize()"
|
||||
:class="[
|
||||
'pointer-events-none absolute z-50',
|
||||
rect.mode === 'set' && 'bg-primary/20',
|
||||
rect.mode === 'add' && 'bg-green-300/20',
|
||||
rect.mode === 'subtract' && 'bg-destructive/20',
|
||||
]"
|
||||
:style="`left: ${left}px; top: ${top}px; width: ${width}px; height: ${height}px;`"
|
||||
></div>
|
||||
</template>
|
||||
87
frontend/components/actions/CreateFileDialog.vue
Normal file
87
frontend/components/actions/CreateFileDialog.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { uploadToWarren } from '~/lib/api/warrens';
|
||||
|
||||
const warrenStore = useWarrenStore();
|
||||
const dialog = useCreateFileDialog();
|
||||
|
||||
const creating = ref(false);
|
||||
const fileNameValid = computed(() => dialog.value.trim().length > 0);
|
||||
|
||||
async function submit() {
|
||||
if (!fileNameValid.value || creating.value || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
creating.value = true;
|
||||
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(new File([], dialog.value));
|
||||
|
||||
const { success } = await uploadToWarren(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
dt.files,
|
||||
undefined
|
||||
);
|
||||
|
||||
creating.value = false;
|
||||
|
||||
if (success) {
|
||||
dialog.reset();
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
function onOpenChange(state: boolean) {
|
||||
if (!state) {
|
||||
dialog.reset();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="dialog.open" @update:open="onOpenChange">
|
||||
<DialogTrigger as-child>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a file</DialogTitle>
|
||||
<DialogDescription
|
||||
>Give your file a memorable name</DialogDescription
|
||||
>
|
||||
</DialogHeader>
|
||||
|
||||
<Input
|
||||
v-model="dialog.value"
|
||||
type="text"
|
||||
name="file-name"
|
||||
placeholder="my-awesome-file"
|
||||
aria-required="true"
|
||||
autocomplete="off"
|
||||
required
|
||||
@keydown="onKeyDown"
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button :disabled="!fileNameValid || creating" @click="submit"
|
||||
>Create</Button
|
||||
>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -35,7 +35,7 @@ const fileInputElement = ref<HTMLInputElement>(
|
||||
const uploading = ref(false);
|
||||
const dropZoneRef = ref<HTMLElement>();
|
||||
|
||||
const dropZone = useDropZone(dropZoneRef, {
|
||||
useDropZone(dropZoneRef, {
|
||||
onDrop,
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
@@ -51,6 +51,11 @@ const form = useForm({
|
||||
canReadFiles: false,
|
||||
canModifyFiles: false,
|
||||
canDeleteFiles: false,
|
||||
|
||||
canListShares: false,
|
||||
canCreateShares: false,
|
||||
canModifyShares: false,
|
||||
canDeleteShares: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -231,6 +236,70 @@ const onSubmit = form.handleSubmit(async (values) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ value, handleChange }"
|
||||
name="canListShares"
|
||||
>
|
||||
<FormItem class="flex flex-row justify-between">
|
||||
<FormLabel class="grow">List shares</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ value, handleChange }"
|
||||
name="canCreateShares"
|
||||
>
|
||||
<FormItem class="flex flex-row justify-between">
|
||||
<FormLabel class="grow">Create shares</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ value, handleChange }"
|
||||
name="canModifyShares"
|
||||
>
|
||||
<FormItem class="flex flex-row justify-between">
|
||||
<FormLabel class="grow">Modify shares</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
v-slot="{ value, handleChange }"
|
||||
name="canDeleteShares"
|
||||
>
|
||||
<FormItem class="flex flex-row justify-between">
|
||||
<FormLabel class="grow">Delete shares</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const warrenStore = useWarrenStore();
|
||||
import { useImageViewer } from '~/stores/viewers';
|
||||
|
||||
const imageViewer = useImageViewer();
|
||||
|
||||
function onOpenUpdate(state: boolean) {
|
||||
if (!state) {
|
||||
warrenStore.imageViewer.src = null;
|
||||
imageViewer.close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:open="warrenStore.imageViewer.src != null"
|
||||
@update:open="onOpenUpdate"
|
||||
>
|
||||
<Dialog :open="imageViewer.src != null" @update:open="onOpenUpdate">
|
||||
<DialogTrigger>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
@@ -20,9 +19,9 @@ function onOpenUpdate(state: boolean) {
|
||||
class="w-full overflow-hidden p-0 sm:!max-h-[90vh] sm:!max-w-[90vw]"
|
||||
>
|
||||
<img
|
||||
v-if="warrenStore.imageViewer.src"
|
||||
v-if="imageViewer.src"
|
||||
class="h-full w-full overflow-hidden !object-contain"
|
||||
:src="warrenStore.imageViewer.src"
|
||||
:src="imageViewer.src"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
152
frontend/components/viewers/TextEditor.vue
Normal file
152
frontend/components/viewers/TextEditor.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { toast } from 'vue-sonner';
|
||||
import { useTextEditor } from '~/stores/viewers';
|
||||
|
||||
const editor = useTextEditor();
|
||||
const textarea = ref<HTMLTextAreaElement>(
|
||||
null as unknown as HTMLTextAreaElement
|
||||
);
|
||||
|
||||
const currentLine = ref<number>(1);
|
||||
const totalLines = computed(
|
||||
() => editor.data?.editedContent.split('\n').length ?? 0
|
||||
);
|
||||
|
||||
function onOpenUpdate(state: boolean) {
|
||||
if (!state) {
|
||||
editor.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (editor.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = editor.data.entry.name;
|
||||
|
||||
const success = await editor.save();
|
||||
|
||||
if (success) {
|
||||
toast.success('Save', {
|
||||
id: 'TEXT_EDITOR_SAVE_TOAST',
|
||||
description: `Successfully saved ${name}`,
|
||||
});
|
||||
} else {
|
||||
toast.error('Save', {
|
||||
id: 'TEXT_EDITOR_SAVE_TOAST',
|
||||
description: `Failed to save ${name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onEditorKeyDown(e: KeyboardEvent) {
|
||||
if (editor.saving) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(updateCurrentLine);
|
||||
|
||||
if (!e.ctrlKey || e.key !== 's') {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
saveFile();
|
||||
}
|
||||
|
||||
function updateCurrentLine() {
|
||||
if (editor.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentLine.value = editor.data.editedContent
|
||||
.substring(0, textarea.value.selectionStart)
|
||||
.split('\n').length;
|
||||
}
|
||||
|
||||
function onEditorClick() {
|
||||
requestAnimationFrame(updateCurrentLine);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
requestAnimationFrame(updateCurrentLine);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="editor.data != null" @update:open="onOpenUpdate">
|
||||
<DialogContent
|
||||
v-if="editor.data"
|
||||
class="flex h-full max-h-[min(98vh,700px)] w-full !max-w-[min(98vw,1000px)] flex-col"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editor.data.entry.name }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div v-if="editor.data.content == null">
|
||||
<span class="text-muted-foreground animate-pulse"
|
||||
>Loading content...</span
|
||||
>
|
||||
</div>
|
||||
<div v-else class="flex h-full w-full flex-col gap-2">
|
||||
<div
|
||||
class="flex h-fit w-full flex-row items-center justify-end gap-1"
|
||||
>
|
||||
<Button
|
||||
title="Reset"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
:disabled="
|
||||
editor.data.content === editor.data.editedContent ||
|
||||
editor.saving
|
||||
"
|
||||
@click="() => editor.discardEdits()"
|
||||
>
|
||||
<Icon name="lucide:bomb" />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" class="mx-1" />
|
||||
|
||||
<Button
|
||||
title="Save"
|
||||
size="icon"
|
||||
:disabled="
|
||||
editor.saving ||
|
||||
editor.data.content === editor.data.editedContent
|
||||
"
|
||||
@click="saveFile"
|
||||
>
|
||||
<Icon name="lucide:save" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id="editor"
|
||||
ref="textarea"
|
||||
v-model="editor.data.editedContent"
|
||||
class="h-full w-full resize-none overflow-auto rounded-md border p-4 whitespace-pre focus:outline-0"
|
||||
@keydown="onEditorKeyDown"
|
||||
@click="onEditorClick"
|
||||
></textarea>
|
||||
|
||||
<div class="flex w-full flex-row justify-end">
|
||||
<p class="text-muted-foreground">
|
||||
Line {{ currentLine }} / {{ totalLines }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#editor {
|
||||
scrollbar-width: 0 !important;
|
||||
}
|
||||
|
||||
#editor::-webkit-scrollbar {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
export function useWarrenPath() {
|
||||
export function useWarrenPath(): { warrenId: string; path: string } | null {
|
||||
const store = useWarrenStore();
|
||||
|
||||
if (store.current == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${store.current.warrenId}${store.current.path}`;
|
||||
return { warrenId: store.current.warrenId, path: store.current.path };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import TextEditor from '~/components/viewers/TextEditor.vue';
|
||||
import ImageViewer from '@/components/viewers/ImageViewer.vue';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import CreateFileDialog from '~/components/actions/CreateFileDialog.vue';
|
||||
import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue';
|
||||
import UploadDialog from '~/components/actions/UploadDialog.vue';
|
||||
import { getWarrens } from '~/lib/api/warrens';
|
||||
@@ -16,8 +19,12 @@ await useAsyncData('warrens', async () => {
|
||||
|
||||
<template>
|
||||
<SidebarProvider>
|
||||
<SelectionRect />
|
||||
<ActionsShareDialog />
|
||||
|
||||
<TextEditor />
|
||||
<ImageViewer />
|
||||
|
||||
<AppSidebar />
|
||||
<SidebarInset class="flex flex-col-reverse md:flex-col">
|
||||
<header
|
||||
@@ -31,6 +38,34 @@ await useAsyncData('warrens', async () => {
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex flex-row items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="
|
||||
store.current != null &&
|
||||
store.current.dir != null &&
|
||||
store.selection.size >=
|
||||
store.current.dir.entries.length
|
||||
"
|
||||
@click="
|
||||
() =>
|
||||
store.current != null &&
|
||||
store.current.dir != null &&
|
||||
store.addMultipleToSelection(
|
||||
store.current.dir.entries
|
||||
)
|
||||
"
|
||||
>
|
||||
<Icon name="lucide:list" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
:disabled="store.selection.size < 1"
|
||||
@click="() => store.clearSelection()"
|
||||
>
|
||||
<Icon name="lucide:list-x" />
|
||||
</Button>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
class="mr-2 hidden !h-4 md:block"
|
||||
@@ -48,6 +83,15 @@ await useAsyncData('warrens', async () => {
|
||||
></div>
|
||||
</Button>
|
||||
</UploadDialog>
|
||||
<CreateFileDialog>
|
||||
<Button
|
||||
v-if="route.path.startsWith('/warrens/')"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<Icon name="lucide:file-plus" />
|
||||
</Button>
|
||||
</CreateFileDialog>
|
||||
<CreateDirectoryDialog>
|
||||
<Button
|
||||
v-if="route.path.startsWith('/warrens/')"
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script lang="ts" setup></script>
|
||||
<script lang="ts" setup>
|
||||
import TextEditor from '~/components/viewers/TextEditor.vue';
|
||||
import ImageViewer from '@/components/viewers/ImageViewer.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex h-full w-full items-center justify-center">
|
||||
<SelectionRect />
|
||||
|
||||
<TextEditor />
|
||||
<ImageViewer />
|
||||
|
||||
<slot />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -120,19 +120,24 @@ export async function listShareFiles(
|
||||
| { success: true; files: DirectoryEntry[]; parent: DirectoryEntry | null }
|
||||
| { success: false }
|
||||
> {
|
||||
const { data } = await useFetch<
|
||||
const { data, error } = await useFetch<
|
||||
ApiResponse<{ files: DirectoryEntry[]; parent: DirectoryEntry | null }>
|
||||
>(getApiUrl('warrens/files/ls_share'), {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
// This is only required for development
|
||||
headers:
|
||||
password != null
|
||||
? { ...getApiHeaders(), 'X-Share-Password': password }
|
||||
: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
shareId: shareId,
|
||||
path: path,
|
||||
password: password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (data.value == null) {
|
||||
const errorMessage = await error.value?.data;
|
||||
console.log(errorMessage);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
@@ -149,7 +154,7 @@ export async function fetchShareFile(
|
||||
password: string | null
|
||||
): Promise<{ success: true; data: Blob } | { success: false }> {
|
||||
const { data } = await useFetch<Blob>(
|
||||
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&path=${path}`),
|
||||
getApiUrl(`warrens/files/cat_share?shareId=${shareId}&paths=${path}`),
|
||||
{
|
||||
method: 'GET',
|
||||
headers:
|
||||
@@ -174,3 +179,32 @@ export async function fetchShareFile(
|
||||
data: data.value,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifySharePassword(
|
||||
shareId: string,
|
||||
password: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const { data } = await useFetch<ApiResponse<null>>(
|
||||
getApiUrl(`warrens/files/verify_share_password`),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
}),
|
||||
responseType: 'json',
|
||||
cache: 'default',
|
||||
}
|
||||
);
|
||||
|
||||
if (data.value == null || data.value.status !== 200) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function getWarrenDirectory(
|
||||
warrenId,
|
||||
path,
|
||||
}),
|
||||
key: `${warrenId}-${path}`,
|
||||
});
|
||||
|
||||
if (data.value == null) {
|
||||
@@ -87,6 +88,40 @@ export async function createDirectory(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function warrenRm(
|
||||
warrenId: string,
|
||||
paths: string[],
|
||||
force: boolean
|
||||
): Promise<{ success: boolean }> {
|
||||
const { status } = await useFetch(getApiUrl(`warrens/files/rm`), {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId,
|
||||
paths,
|
||||
force,
|
||||
}),
|
||||
});
|
||||
|
||||
const TOAST_TITLE = 'Delete';
|
||||
|
||||
if (status.value !== 'success') {
|
||||
toast.error(TOAST_TITLE, {
|
||||
id: 'WARREN_RM_TOAST',
|
||||
description: `Failed to delete directory`,
|
||||
});
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
await refreshNuxtData('current-directory');
|
||||
|
||||
toast.success(TOAST_TITLE, {
|
||||
id: 'WARREN_RM_TOAST',
|
||||
description: `Successfully deleted files`,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function deleteWarrenDirectory(
|
||||
warrenId: string,
|
||||
path: string,
|
||||
@@ -104,7 +139,7 @@ export async function deleteWarrenDirectory(
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId,
|
||||
path,
|
||||
paths: [path],
|
||||
force,
|
||||
}),
|
||||
});
|
||||
@@ -113,7 +148,7 @@ export async function deleteWarrenDirectory(
|
||||
|
||||
if (status.value !== 'success') {
|
||||
toast.error(TOAST_TITLE, {
|
||||
id: 'DELETE_DIRECTORY_TOAST',
|
||||
id: 'WARREN_RM_TOAST',
|
||||
description: `Failed to delete directory`,
|
||||
});
|
||||
return { success: false };
|
||||
@@ -122,7 +157,7 @@ export async function deleteWarrenDirectory(
|
||||
await refreshNuxtData('current-directory');
|
||||
|
||||
toast.success(TOAST_TITLE, {
|
||||
id: 'DELETE_DIRECTORY_TOAST',
|
||||
id: 'WARREN_RM_TOAST',
|
||||
description: `Successfully deleted ${directoryName}`,
|
||||
});
|
||||
return { success: true };
|
||||
@@ -144,7 +179,7 @@ export async function deleteWarrenFile(
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId,
|
||||
path,
|
||||
paths: [path],
|
||||
force: false,
|
||||
}),
|
||||
});
|
||||
@@ -153,7 +188,7 @@ export async function deleteWarrenFile(
|
||||
|
||||
if (status.value !== 'success') {
|
||||
toast.error(TOAST_TITLE, {
|
||||
id: 'DELETE_FILE_TOAST',
|
||||
id: 'WARREN_RM_TOAST',
|
||||
description: `Failed to delete file`,
|
||||
});
|
||||
return { success: false };
|
||||
@@ -162,7 +197,7 @@ export async function deleteWarrenFile(
|
||||
await refreshNuxtData('current-directory');
|
||||
|
||||
toast.success(TOAST_TITLE, {
|
||||
id: 'DELETE_FILE_TOAST',
|
||||
id: 'WARREN_RM_TOAST',
|
||||
description: `Successfully deleted ${fileName}`,
|
||||
});
|
||||
return { success: true };
|
||||
@@ -211,20 +246,11 @@ export async function uploadToWarren(
|
||||
try {
|
||||
await promise;
|
||||
} catch {
|
||||
toast.error('Upload', {
|
||||
id: 'UPLOAD_FILE_TOAST',
|
||||
description: `Failed to upload`,
|
||||
});
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
await refreshNuxtData('current-directory');
|
||||
|
||||
toast.success('Upload', {
|
||||
id: 'UPLOAD_FILE_TOAST',
|
||||
description: `Successfully uploaded ${files.length} file${files.length !== 1 ? 's' : ''}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -246,7 +272,7 @@ export async function renameWarrenEntry(
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId,
|
||||
path,
|
||||
paths: [path],
|
||||
targetPath,
|
||||
}),
|
||||
});
|
||||
@@ -273,14 +299,27 @@ export async function fetchFile(
|
||||
warrenId: string,
|
||||
path: string,
|
||||
fileName: string
|
||||
): Promise<{ success: true; data: Blob } | { success: false }> {
|
||||
return fetchFiles(warrenId, path, [fileName]);
|
||||
}
|
||||
|
||||
export async function fetchFiles(
|
||||
warrenId: string,
|
||||
path: string,
|
||||
fileNames: string[]
|
||||
): Promise<{ success: true; data: Blob } | { success: false }> {
|
||||
if (!path.endsWith('/')) {
|
||||
path += '/';
|
||||
}
|
||||
path += fileName;
|
||||
const paths = [];
|
||||
for (const fileName of fileNames) {
|
||||
paths.push(path + fileName);
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Blob>(
|
||||
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&path=${path}`),
|
||||
getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenId}&paths=${paths.join(':')}`
|
||||
),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: getApiHeaders(),
|
||||
@@ -314,7 +353,7 @@ export async function fetchFileStream(
|
||||
path += fileName;
|
||||
|
||||
const { data, error } = await useFetch<ReadableStream<Uint8Array>>(
|
||||
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&path=${path}`),
|
||||
getApiUrl(`warrens/files/cat?warrenId=${warrenId}&paths=${path}`),
|
||||
{
|
||||
method: 'GET',
|
||||
headers: getApiHeaders(),
|
||||
@@ -335,9 +374,9 @@ export async function fetchFileStream(
|
||||
};
|
||||
}
|
||||
|
||||
export async function moveFile(
|
||||
export async function moveFiles(
|
||||
warrenId: string,
|
||||
currentPath: string,
|
||||
currentPaths: string[],
|
||||
targetPath: string
|
||||
): Promise<{ success: boolean }> {
|
||||
const { status } = await useFetch(getApiUrl(`warrens/files/mv`), {
|
||||
@@ -345,7 +384,7 @@ export async function moveFile(
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify({
|
||||
warrenId,
|
||||
path: currentPath,
|
||||
paths: currentPaths,
|
||||
targetPath: targetPath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ export const userWarrenSchema = object({
|
||||
canReadFiles: boolean().required(),
|
||||
canModifyFiles: boolean().required(),
|
||||
canDeleteFiles: boolean().required(),
|
||||
|
||||
canListShares: boolean().required(),
|
||||
canCreateShares: boolean().required(),
|
||||
canModifyShares: boolean().required(),
|
||||
canDeleteShares: boolean().required(),
|
||||
});
|
||||
|
||||
export const createWarrenSchema = object({
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { fetchShareFile, getShare, listShareFiles } from '~/lib/api/shares';
|
||||
import byteSize from 'byte-size';
|
||||
import { toast } from 'vue-sonner';
|
||||
import {
|
||||
fetchShareFile,
|
||||
getShare,
|
||||
listShareFiles,
|
||||
verifySharePassword,
|
||||
} from '~/lib/api/shares';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
import type { Share } from '~/shared/types/shares';
|
||||
import { useImageViewer, useTextEditor } from '~/stores/viewers';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'share',
|
||||
@@ -10,11 +18,26 @@ definePageMeta({
|
||||
const warrenStore = useWarrenStore();
|
||||
const route = useRoute();
|
||||
|
||||
const imageViewer = useImageViewer();
|
||||
const textEditor = useTextEditor();
|
||||
|
||||
const entries = computed(() =>
|
||||
warrenStore.current != null && warrenStore.current.dir != null
|
||||
? warrenStore.current.dir.entries
|
||||
: null
|
||||
);
|
||||
const parent = computed(() =>
|
||||
warrenStore.current != null && warrenStore.current.dir != null
|
||||
? warrenStore.current.dir.parent
|
||||
: null
|
||||
);
|
||||
|
||||
const share = await getShareFromQuery();
|
||||
const entries = ref<DirectoryEntry[] | null>(null);
|
||||
const parent = ref<DirectoryEntry | null>(null);
|
||||
const password = ref<string>('');
|
||||
const loading = ref<boolean>(false);
|
||||
const passwordValid = ref<boolean>(
|
||||
share == null ? false : !share.data.password
|
||||
);
|
||||
|
||||
if (share != null) {
|
||||
warrenStore.setCurrentWarren(share.data.warrenId, '/');
|
||||
@@ -44,7 +67,39 @@ async function getShareFromQuery(): Promise<{
|
||||
}
|
||||
|
||||
async function submitPassword() {
|
||||
loadFiles();
|
||||
if (share == null || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordValid.value) {
|
||||
loading.value = true;
|
||||
const result = await verifySharePassword(share.data.id, password.value);
|
||||
loading.value = false;
|
||||
|
||||
if (result.success) {
|
||||
passwordValid.value = true;
|
||||
let cookie = `X-Share-Password=${password.value}; Path=/; SameSite=Lax; Secure;`;
|
||||
|
||||
if (share.data.expiresAt != null) {
|
||||
const dayjs = useDayjs();
|
||||
|
||||
const diff = dayjs(share.data.expiresAt).diff(dayjs()) / 1000;
|
||||
cookie += `Max-Age=${diff};`;
|
||||
}
|
||||
|
||||
document.cookie = cookie;
|
||||
} else {
|
||||
toast.error('Share', {
|
||||
id: 'SHARE_PASSWORD_TOAST',
|
||||
description: 'Invalid password',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (share.file.fileType === 'directory') {
|
||||
loadFiles();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
@@ -52,10 +107,6 @@ async function loadFiles() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (share.file.fileType !== 'directory') {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const result = await listShareFiles(
|
||||
@@ -65,30 +116,31 @@ async function loadFiles() {
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
let cookie = `X-Share-Password=${password.value}; Path=/; SameSite=Lax; Secure;`;
|
||||
|
||||
if (share.data.expiresAt != null) {
|
||||
const dayjs = useDayjs();
|
||||
|
||||
const diff = dayjs(share.data.expiresAt).diff(dayjs()) / 1000;
|
||||
cookie += `Max-Age=${diff};`;
|
||||
}
|
||||
|
||||
document.cookie = cookie;
|
||||
|
||||
entries.value = result.files;
|
||||
parent.value = result.parent;
|
||||
warrenStore.setCurrentWarrenEntries(result.files, result.parent);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function onEntryClicked(entry: DirectoryEntry) {
|
||||
if (warrenStore.current == null) {
|
||||
async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (
|
||||
warrenStore.current == null ||
|
||||
share == null ||
|
||||
(share.data.password && !passwordValid.value)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath = joinPaths(warrenStore.current.path, entry.name);
|
||||
if (warrenStore.handleSelectionClick(entry, event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryPath =
|
||||
entry !== share.file
|
||||
? joinPaths(warrenStore.current.path, entry.name)
|
||||
: warrenStore.current.path;
|
||||
|
||||
if (entry.fileType === 'directory') {
|
||||
warrenStore.setCurrentWarrenPath(entryPath);
|
||||
@@ -109,8 +161,29 @@ async function onEntryClicked(entry: DirectoryEntry) {
|
||||
|
||||
if (result.success) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
warrenStore.imageViewer.src = url;
|
||||
imageViewer.open(url);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType.startsWith('text/')) {
|
||||
const result = await fetchShareFile(
|
||||
share!.data.id,
|
||||
entryPath,
|
||||
password.value.length > 0 ? password.value : null
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
textEditor.open(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry,
|
||||
result.data
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,10 +198,15 @@ function onDowloadClicked() {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
share.file.name,
|
||||
getApiUrl(`warrens/files/cat_share?shareId=${share.data.id}&path=/`)
|
||||
const downloadName =
|
||||
share.file.fileType === 'directory'
|
||||
? `${share.file.name}.zip`
|
||||
: share.file.name;
|
||||
const downloadApiUrl = getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${share.data.id}&paths=/`
|
||||
);
|
||||
|
||||
downloadFile(downloadName, downloadApiUrl);
|
||||
}
|
||||
|
||||
function onEntryDownload(entry: DirectoryEntry) {
|
||||
@@ -136,12 +214,29 @@ function onEntryDownload(entry: DirectoryEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
entry.name,
|
||||
getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${share.data.id}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
);
|
||||
let downloadName: string;
|
||||
let downloadApiUrl: string;
|
||||
|
||||
const selectionSize = warrenStore.selection.size;
|
||||
|
||||
if (selectionSize === 0 || !warrenStore.isSelected(entry)) {
|
||||
downloadName =
|
||||
entry.fileType === 'directory' ? `${entry.name}.zip` : entry.name;
|
||||
downloadApiUrl = getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${share.data.id}&paths=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
);
|
||||
} else {
|
||||
downloadName = 'download.zip';
|
||||
const paths = Array.from(warrenStore.selection.values()).map((entry) =>
|
||||
joinPaths(warrenStore.current!.path, entry.name)
|
||||
);
|
||||
|
||||
downloadApiUrl = getApiUrl(
|
||||
`warrens/files/cat_share?shareId=${share.data.id}&paths=${paths.join(':')}`
|
||||
);
|
||||
}
|
||||
|
||||
downloadFile(downloadName, downloadApiUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -153,16 +248,34 @@ function onEntryDownload(entry: DirectoryEntry) {
|
||||
<div
|
||||
:class="[
|
||||
'w-full rounded-lg border transition-all',
|
||||
entries == null ? 'max-w-lg' : 'max-w-screen-xl',
|
||||
passwordValid && share.file.fileType === 'directory'
|
||||
? 'h-[min(98vh,600px)] max-w-screen-xl'
|
||||
: 'max-w-2xl',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center justify-between gap-4 px-6 pt-6"
|
||||
>
|
||||
<div class="flex w-full flex-row">
|
||||
<div class="flex grow flex-col gap-1.5">
|
||||
<h3 class="leading-none font-semibold">Share</h3>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<div class="flex flex-row items-center justify-between gap-4 p-6">
|
||||
<button
|
||||
:disabled="share.data.password && !passwordValid"
|
||||
:class="[
|
||||
'flex min-w-0 grow flex-row items-center gap-2 text-left',
|
||||
(!share.data.password || passwordValid) &&
|
||||
'cursor-pointer',
|
||||
]"
|
||||
@click="(e) => onEntryClicked(share!.file, e)"
|
||||
>
|
||||
<DirectoryEntryIcon :entry="share.file" />
|
||||
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<h3
|
||||
:title="share.file.name"
|
||||
class="truncate leading-none font-semibold"
|
||||
>
|
||||
{{ share.file.name }}
|
||||
</h3>
|
||||
<p class="text-muted-foreground text-sm text-nowrap">
|
||||
{{ byteSize(share.file.size) }}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm text-nowrap">
|
||||
Created
|
||||
{{
|
||||
$dayjs(share.data.createdAt).format(
|
||||
@@ -171,19 +284,12 @@ function onEntryDownload(entry: DirectoryEntry) {
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
<p>{{ share.file.name }}</p>
|
||||
<DirectoryEntryIcon
|
||||
:entry="{ ...share.file, name: '/' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-row items-end">
|
||||
<Button
|
||||
:class="
|
||||
share.file.fileType !== 'file' &&
|
||||
entries == null &&
|
||||
'hidden'
|
||||
share.data.password && !passwordValid && 'hidden'
|
||||
"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
@@ -193,12 +299,19 @@ function onEntryDownload(entry: DirectoryEntry) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col p-6">
|
||||
<div
|
||||
v-if="
|
||||
share.file.fileType === 'directory' ||
|
||||
(share.data.password && !passwordValid)
|
||||
"
|
||||
class="flex w-full flex-col px-6 pb-6"
|
||||
>
|
||||
<DirectoryList
|
||||
v-if="entries != null"
|
||||
:entries
|
||||
:parent
|
||||
:disable-entries="loading"
|
||||
:entries-draggable="false"
|
||||
@entry-click="onEntryClicked"
|
||||
@entry-download="onEntryDownload"
|
||||
@back="onBack"
|
||||
|
||||
@@ -3,14 +3,19 @@ import { useDropZone } from '@vueuse/core';
|
||||
import { toast } from 'vue-sonner';
|
||||
import DirectoryListContextMenu from '~/components/DirectoryListContextMenu.vue';
|
||||
import RenameEntryDialog from '~/components/actions/RenameEntryDialog.vue';
|
||||
import { fetchFile, getWarrenDirectory } from '~/lib/api/warrens';
|
||||
import { fetchFile, getWarrenDirectory, warrenRm } from '~/lib/api/warrens';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
import { useImageViewer, useTextEditor } from '~/stores/viewers';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authenticated'],
|
||||
});
|
||||
|
||||
const warrenStore = useWarrenStore();
|
||||
|
||||
const imageViewer = useImageViewer();
|
||||
const textEditor = useTextEditor();
|
||||
|
||||
const loadingIndicator = useLoadingIndicator();
|
||||
const uploadStore = useUploadStore();
|
||||
const warrenPath = computed(() => useWarrenPath());
|
||||
@@ -28,10 +33,10 @@ if (warrenStore.current == null) {
|
||||
});
|
||||
}
|
||||
|
||||
const dirData = useAsyncData(
|
||||
useAsyncData(
|
||||
'current-directory',
|
||||
async () => {
|
||||
if (warrenStore.current == null) {
|
||||
if (warrenPath.value == null) {
|
||||
return {
|
||||
files: [],
|
||||
parent: null,
|
||||
@@ -42,17 +47,17 @@ const dirData = useAsyncData(
|
||||
warrenStore.loading = true;
|
||||
|
||||
const { files, parent } = await getWarrenDirectory(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path
|
||||
warrenPath.value.warrenId,
|
||||
warrenPath.value.path
|
||||
);
|
||||
|
||||
warrenStore.loading = false;
|
||||
loadingIndicator.finish();
|
||||
|
||||
return { files, parent };
|
||||
warrenStore.setCurrentWarrenEntries(files, parent);
|
||||
},
|
||||
{ watch: [warrenPath] }
|
||||
).data;
|
||||
);
|
||||
|
||||
function onDrop(files: File[] | null, e: DragEvent) {
|
||||
if (files == null) {
|
||||
@@ -79,11 +84,17 @@ function onDrop(files: File[] | null, e: DragEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onEntryClicked(entry: DirectoryEntry) {
|
||||
async function onEntryClicked(entry: DirectoryEntry, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (warrenStore.loading || warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (warrenStore.handleSelectionClick(entry, event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.fileType === 'directory') {
|
||||
warrenStore.addToCurrentWarrenPath(entry.name);
|
||||
return;
|
||||
@@ -99,10 +110,32 @@ async function onEntryClicked(entry: DirectoryEntry) {
|
||||
warrenStore.current.path,
|
||||
entry.name
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
const url = URL.createObjectURL(result.data);
|
||||
warrenStore.imageViewer.src = url;
|
||||
imageViewer.open(url);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.mimeType.startsWith('text/')) {
|
||||
const result = await fetchFile(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry.name
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
textEditor.open(
|
||||
warrenStore.current.warrenId,
|
||||
warrenStore.current.path,
|
||||
entry,
|
||||
result.data
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +144,41 @@ function onEntryDownload(entry: DirectoryEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.fileType !== 'file') {
|
||||
toast.warning('Download', {
|
||||
description: 'Directory downloads are not supported yet',
|
||||
});
|
||||
let downloadName: string;
|
||||
let downloadApiUrl: string;
|
||||
|
||||
const targets = getTargetsFromSelection(entry, warrenStore.selection);
|
||||
if (targets.length === 1) {
|
||||
downloadName =
|
||||
entry.fileType === 'directory'
|
||||
? `${targets[0].name}.zip`
|
||||
: targets[0].name;
|
||||
downloadApiUrl = getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&paths=${joinPaths(warrenStore.current.path, targets[0].name)}`
|
||||
);
|
||||
} else {
|
||||
downloadName = 'download.zip';
|
||||
const paths = targets
|
||||
.map((entry) => joinPaths(warrenStore.current!.path, entry.name))
|
||||
.join(':');
|
||||
|
||||
downloadApiUrl = getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&paths=${paths}`
|
||||
);
|
||||
}
|
||||
|
||||
downloadFile(downloadName, downloadApiUrl);
|
||||
}
|
||||
async function onEntryDelete(entry: DirectoryEntry, force: boolean) {
|
||||
if (warrenStore.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadFile(
|
||||
entry.name,
|
||||
getApiUrl(
|
||||
`warrens/files/cat?warrenId=${warrenStore.current.warrenId}&path=${joinPaths(warrenStore.current.path, entry.name)}`
|
||||
)
|
||||
const targets = getTargetsFromSelection(entry, warrenStore.selection).map(
|
||||
(entry) => joinPaths(warrenStore.current!.path, entry.name)
|
||||
);
|
||||
|
||||
await warrenRm(warrenStore.current.warrenId, targets, force);
|
||||
}
|
||||
|
||||
function onBack() {
|
||||
@@ -135,15 +190,19 @@ function onBack() {
|
||||
<div ref="dropZoneRef" class="grow">
|
||||
<DirectoryListContextMenu class="w-full grow">
|
||||
<DirectoryList
|
||||
v-if="dirData != null"
|
||||
v-if="
|
||||
warrenStore.current != null &&
|
||||
warrenStore.current.dir != null
|
||||
"
|
||||
:is-over-drop-zone="
|
||||
dropZone.isOverDropZone.value &&
|
||||
dropZone.files.value != null
|
||||
"
|
||||
:entries="dirData.files"
|
||||
:parent="dirData.parent"
|
||||
:entries="warrenStore.current.dir.entries"
|
||||
:parent="warrenStore.current.dir.parent"
|
||||
@entry-click="onEntryClicked"
|
||||
@entry-download="onEntryDownload"
|
||||
@entry-delete="onEntryDelete"
|
||||
@back="onBack"
|
||||
/>
|
||||
</DirectoryListContextMenu>
|
||||
|
||||
@@ -9,6 +9,7 @@ export type DirectoryEntry = {
|
||||
name: string;
|
||||
fileType: FileType;
|
||||
mimeType: string | null;
|
||||
size: number;
|
||||
/// Timestamp in seconds
|
||||
createdAt: number | null;
|
||||
isParent: boolean;
|
||||
|
||||
1
frontend/shared/types/selection.ts
Normal file
1
frontend/shared/types/selection.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type SelectionMode = 'set' | 'add' | 'subtract';
|
||||
@@ -6,10 +6,16 @@ import { getParentPath } from '~/utils/files';
|
||||
export const useWarrenStore = defineStore('warrens', {
|
||||
state: () => ({
|
||||
warrens: {} as Record<string, WarrenData>,
|
||||
imageViewer: {
|
||||
src: null as string | null,
|
||||
},
|
||||
current: null as { warrenId: string; path: string } | null,
|
||||
current: null as {
|
||||
warrenId: string;
|
||||
path: string;
|
||||
dir: {
|
||||
parent: DirectoryEntry | null;
|
||||
entries: DirectoryEntry[];
|
||||
} | null;
|
||||
} | null,
|
||||
selection: new Map() as Map<string, DirectoryEntry>,
|
||||
selectionRangeAnchor: null as DirectoryEntry | null,
|
||||
loading: false,
|
||||
}),
|
||||
actions: {
|
||||
@@ -17,6 +23,23 @@ export const useWarrenStore = defineStore('warrens', {
|
||||
this.current = {
|
||||
warrenId,
|
||||
path,
|
||||
dir: null,
|
||||
};
|
||||
this.clearSelection();
|
||||
},
|
||||
setCurrentWarrenEntries(
|
||||
entries: DirectoryEntry[],
|
||||
parent: DirectoryEntry | null
|
||||
) {
|
||||
if (this.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.current.dir = {
|
||||
entries: entries.toSorted((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
),
|
||||
parent,
|
||||
};
|
||||
},
|
||||
addToCurrentWarrenPath(path: string) {
|
||||
@@ -28,14 +51,14 @@ export const useWarrenStore = defineStore('warrens', {
|
||||
path = '/' + path;
|
||||
}
|
||||
|
||||
this.current.path += path;
|
||||
this.setCurrentWarrenPath(this.current.path + path);
|
||||
},
|
||||
backCurrentPath(): boolean {
|
||||
if (this.current == null || this.current.path === '/') {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.current.path = getParentPath(this.current.path);
|
||||
this.setCurrentWarrenPath(getParentPath(this.current.path));
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -44,19 +67,154 @@ export const useWarrenStore = defineStore('warrens', {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = this.current.path;
|
||||
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
|
||||
this.current.path = path;
|
||||
|
||||
if (previous !== path) {
|
||||
this.clearSelection();
|
||||
this.current.dir = null;
|
||||
}
|
||||
},
|
||||
clearCurrentWarren() {
|
||||
this.current = null;
|
||||
},
|
||||
addToSelection(entry: DirectoryEntry) {
|
||||
this.selection.set(entry.name, entry);
|
||||
},
|
||||
setSelection(entries: DirectoryEntry[]) {
|
||||
this.selection = entries.reduce(
|
||||
(acc, entry) => acc.set(entry.name, entry),
|
||||
new Map()
|
||||
);
|
||||
if (
|
||||
this.selectionRangeAnchor != null &&
|
||||
!this.selection.has(this.selectionRangeAnchor.name)
|
||||
) {
|
||||
this.selectionRangeAnchor = null;
|
||||
}
|
||||
},
|
||||
addMultipleToSelection(entries: DirectoryEntry[]) {
|
||||
for (const entry of entries) {
|
||||
this.selection.set(entry.name, entry);
|
||||
}
|
||||
},
|
||||
removeMultipleFromSelection(entries: DirectoryEntry[]) {
|
||||
for (const entry of entries) {
|
||||
this.selection.delete(entry.name);
|
||||
}
|
||||
},
|
||||
setSelectedRangeAnchor(entry: DirectoryEntry) {
|
||||
this.selectionRangeAnchor = entry;
|
||||
},
|
||||
removeFromSelection(entry: DirectoryEntry): boolean {
|
||||
return this.selection.delete(entry.name);
|
||||
},
|
||||
toggleSelection(entry: DirectoryEntry): boolean {
|
||||
if (this.selection.has(entry.name)) {
|
||||
this.removeFromSelection(entry);
|
||||
return false;
|
||||
} else {
|
||||
this.addToSelection(entry);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
isSelected(entry: DirectoryEntry): boolean {
|
||||
if (this.current == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.selection.has(entry.name);
|
||||
},
|
||||
handleSelectionClick(
|
||||
selectedEntry: DirectoryEntry,
|
||||
event: MouseEvent
|
||||
): boolean {
|
||||
if (!event.ctrlKey && !event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.current == null || this.current.dir == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
event.ctrlKey ||
|
||||
(event.shiftKey && this.selection.size === 0)
|
||||
) {
|
||||
if (
|
||||
this.toggleSelection(selectedEntry) ||
|
||||
this.selectionRangeAnchor == null
|
||||
) {
|
||||
this.setSelectedRangeAnchor(selectedEntry);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchor = this.selectionRangeAnchor;
|
||||
|
||||
if (anchor == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entries = this.current.dir.entries;
|
||||
|
||||
const anchorIndex = entries.findIndex(
|
||||
(entry) => entry.name === anchor.name
|
||||
);
|
||||
|
||||
const clickedIndex = entries.findIndex(
|
||||
(e) => e.name === selectedEntry.name
|
||||
);
|
||||
|
||||
const targetSelection = [];
|
||||
|
||||
if (clickedIndex > anchorIndex) {
|
||||
for (let i = anchorIndex; i <= clickedIndex; i++) {
|
||||
targetSelection.push(entries[i]);
|
||||
}
|
||||
} else {
|
||||
for (let i = clickedIndex; i <= anchorIndex; i++) {
|
||||
targetSelection.push(entries[i]);
|
||||
}
|
||||
}
|
||||
|
||||
this.setSelection(targetSelection);
|
||||
|
||||
return true;
|
||||
},
|
||||
clearSelection() {
|
||||
this.selection.clear();
|
||||
this.selectionRangeAnchor = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useCreateDirectoryDialog = defineStore('create_directory_dialog', {
|
||||
export const useCreateDirectoryDialog = defineStore('create-directory-dialog', {
|
||||
state: () => ({
|
||||
open: false,
|
||||
value: '',
|
||||
}),
|
||||
actions: {
|
||||
openDialog() {
|
||||
this.open = true;
|
||||
},
|
||||
reset() {
|
||||
this.open = false;
|
||||
this.value = '';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const useCreateFileDialog = defineStore('create-file-dialog', {
|
||||
state: () => ({
|
||||
open: false,
|
||||
value: '',
|
||||
|
||||
55
frontend/stores/selectionRect.ts
Normal file
55
frontend/stores/selectionRect.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { SelectionMode } from '~/shared/types/selection';
|
||||
|
||||
export const useSelectionRect = defineStore('selection-rect', {
|
||||
state: () => ({
|
||||
enabled: false as boolean,
|
||||
a: {
|
||||
x: 0 as number,
|
||||
y: 0 as number,
|
||||
},
|
||||
b: {
|
||||
x: 0 as number,
|
||||
y: 0 as number,
|
||||
},
|
||||
mode: 'set' as SelectionMode,
|
||||
}),
|
||||
actions: {
|
||||
set(
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
mode: SelectionMode
|
||||
) {
|
||||
this.a.x = a.x;
|
||||
this.a.y = a.y;
|
||||
|
||||
this.b.x = b.x;
|
||||
this.b.y = b.y;
|
||||
|
||||
this.enabled = true;
|
||||
this.mode = mode;
|
||||
},
|
||||
updateB(x: number, y: number) {
|
||||
this.b.x = x;
|
||||
this.b.y = y;
|
||||
},
|
||||
disable() {
|
||||
this.enabled = false;
|
||||
this.a = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
this.b = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
},
|
||||
isMinSize(minPixels: number = 10) {
|
||||
return (
|
||||
Math.max(
|
||||
Math.abs(this.a.x - this.b.x),
|
||||
Math.abs(this.a.y - this.b.y)
|
||||
) >= minPixels
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
13
frontend/stores/viewers/image.ts
Normal file
13
frontend/stores/viewers/image.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default defineStore('image-viewer', {
|
||||
state: () => ({
|
||||
src: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
open(src: string) {
|
||||
this.src = src;
|
||||
},
|
||||
close() {
|
||||
this.src = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
4
frontend/stores/viewers/index.ts
Normal file
4
frontend/stores/viewers/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import useImageViewer from './image';
|
||||
import useTextEditor from './text';
|
||||
|
||||
export { useImageViewer, useTextEditor };
|
||||
71
frontend/stores/viewers/text.ts
Normal file
71
frontend/stores/viewers/text.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { uploadToWarren } from '~/lib/api/warrens';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
export default defineStore('text-editor', {
|
||||
state: () => ({
|
||||
data: null as {
|
||||
warrenId: string;
|
||||
parentPath: string;
|
||||
entry: DirectoryEntry;
|
||||
content: string;
|
||||
editedContent: string;
|
||||
} | null,
|
||||
saving: false as boolean,
|
||||
}),
|
||||
actions: {
|
||||
async open(
|
||||
warrenId: string,
|
||||
parentPath: string,
|
||||
entry: DirectoryEntry,
|
||||
content: Blob
|
||||
) {
|
||||
const contentString = await content.text();
|
||||
|
||||
this.data = {
|
||||
warrenId,
|
||||
parentPath,
|
||||
entry,
|
||||
content: contentString,
|
||||
editedContent: contentString,
|
||||
};
|
||||
},
|
||||
async save(): Promise<boolean> {
|
||||
if (this.saving || this.data == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.saving = true;
|
||||
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(
|
||||
new File([this.data.editedContent], this.data.entry.name)
|
||||
);
|
||||
|
||||
const result = await uploadToWarren(
|
||||
this.data.warrenId,
|
||||
this.data.parentPath,
|
||||
dt.files,
|
||||
undefined
|
||||
);
|
||||
|
||||
this.$patch({
|
||||
data: {
|
||||
content: this.data.editedContent,
|
||||
},
|
||||
saving: false,
|
||||
});
|
||||
|
||||
return result.success;
|
||||
},
|
||||
discardEdits() {
|
||||
if (this.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.data.editedContent = this.data.content;
|
||||
},
|
||||
close() {
|
||||
this.data = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { copyFile, moveFile } from '~/lib/api/warrens';
|
||||
import { copyFile, moveFiles } from '~/lib/api/warrens';
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
export function joinPaths(path: string, ...other: string[]): string {
|
||||
@@ -25,7 +25,11 @@ export function onDirectoryEntryDrop(
|
||||
return async (e: DragEvent) => {
|
||||
const warrenStore = useWarrenStore();
|
||||
|
||||
if (e.dataTransfer == null || warrenStore.current == null) {
|
||||
if (
|
||||
e.dataTransfer == null ||
|
||||
warrenStore.current == null ||
|
||||
warrenStore.current.dir == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,23 +43,29 @@ export function onDirectoryEntryDrop(
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = joinPaths(warrenStore.current.path, fileName);
|
||||
const draggedEntry = warrenStore.current.dir.entries.find(
|
||||
(e) => e.name === fileName
|
||||
);
|
||||
if (draggedEntry == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPaths = getTargetsFromSelection(
|
||||
draggedEntry,
|
||||
warrenStore.selection
|
||||
).map((currentEntry) =>
|
||||
joinPaths(warrenStore.current!.path, currentEntry.name)
|
||||
);
|
||||
|
||||
let targetPath: string;
|
||||
|
||||
if (isParent) {
|
||||
targetPath = joinPaths(
|
||||
getParentPath(warrenStore.current.path),
|
||||
fileName
|
||||
);
|
||||
targetPath = getParentPath(warrenStore.current.path);
|
||||
} else {
|
||||
targetPath = joinPaths(
|
||||
warrenStore.current.path,
|
||||
entry.name,
|
||||
fileName
|
||||
);
|
||||
targetPath = joinPaths(warrenStore.current.path, entry.name);
|
||||
}
|
||||
|
||||
await moveFile(warrenStore.current.warrenId, currentPath, targetPath);
|
||||
await moveFiles(warrenStore.current.warrenId, targetPaths, targetPath);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
11
frontend/utils/rect.ts
Normal file
11
frontend/utils/rect.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function intersectRect(
|
||||
a: { x: number; y: number; width: number; height: number },
|
||||
b: { x: number; y: number; width: number; height: number }
|
||||
): boolean {
|
||||
return !(
|
||||
b.x > a.x + a.width ||
|
||||
b.x + b.width < a.x ||
|
||||
b.y > a.y + a.height ||
|
||||
b.y + b.height < a.y
|
||||
);
|
||||
}
|
||||
17
frontend/utils/selection.ts
Normal file
17
frontend/utils/selection.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { DirectoryEntry } from '~/shared/types';
|
||||
|
||||
/** Converts a selection and the entry that triggered an action into the target entries
|
||||
* @param targetEntry - The entry that triggered an action
|
||||
* @param selection - The selected entries
|
||||
* @returns If there are no selected elements or the target was not included only the target is returned. Otherwise the selection is returned
|
||||
*/
|
||||
export function getTargetsFromSelection(
|
||||
targetEntry: DirectoryEntry,
|
||||
selection: Map<string, DirectoryEntry>
|
||||
): DirectoryEntry[] {
|
||||
if (selection.size === 0 || !selection.has(targetEntry.name)) {
|
||||
return [targetEntry];
|
||||
}
|
||||
|
||||
return Array.from(selection.values());
|
||||
}
|
||||
Reference in New Issue
Block a user