Compare commits

...

32 Commits

Author SHA1 Message Date
409
676f0ca01c fix share list including expired shares 2025-09-07 18:04:36 +02:00
409
92b6d6f1dd fix DirectoryEntry menu action clicks clearing selection 2025-09-07 17:54:43 +02:00
409
754dd8b053 fix TextEditor initial line number 2025-09-07 17:35:11 +02:00
409
6fa26b3ddb remove sqlite extensions to fix docker issue
UUIDs are now generated in the backend before insertion
2025-09-07 17:35:07 +02:00
409
a1c9832515 migrate to sqlite
NOTE: extension loading crashes docker (for some reason)
2025-09-07 15:09:14 +02:00
409
5c3057e998 fix file rename 2025-09-06 21:15:46 +02:00
409
a0c90f57d5 fix share password issues 2025-09-06 19:19:54 +02:00
409
5c09120c23 improve file streams 2025-09-06 18:00:58 +02:00
409
1c4aaa7040 improve viewer exports 2025-09-06 18:00:19 +02:00
409
2b29716fee fix fs move issue on lowest directory 2025-09-06 17:32:19 +02:00
409
e1e97ef79d recursively get file size for directories 2025-09-06 16:23:42 +02:00
409
3329860b02 remove old debug trace 2025-09-06 15:50:44 +02:00
409
8c0d80d7fb show file sizes (basic version) 2025-09-06 15:49:57 +02:00
409
cef77e86b7 create new file dialog in nav and context menu 2025-09-06 01:29:19 +02:00
409
a4c2c039d2 basic text editor 2025-09-06 01:21:13 +02:00
409
73d3b2a27f fix selection rect not disappearing when clicking empty space 2025-09-05 17:16:24 +02:00
409
8c4f56a7ab fix selection rect not working at all 2025-09-05 17:13:29 +02:00
409
49c59cbaea move multiple files at once 2025-09-05 01:22:40 +02:00
409
13e91fdfbf fix more rect selection bugs 2025-09-04 21:03:19 +02:00
409
afcbadee8b fix selection rect bug when dragging DirectoryEntry 2025-09-04 19:29:47 +02:00
409
91c65e0861 refactor rect selection to make it work in shares 2025-09-04 19:02:14 +02:00
409
735e825c7d rect file selection 2025-09-04 18:31:02 +02:00
409
cdd4151462 select range with shift 2025-09-04 16:59:31 +02:00
409
76aedbaf96 remove unused imports 2025-09-04 16:27:45 +02:00
409
8b2ed0e700 delete multiple files with selection 2025-09-04 16:26:23 +02:00
409
49b4162448 fix file system cat prefix determination 2025-09-04 01:33:30 +02:00
409
e2085c1baa basic selection + download multiple files with selection 2025-09-02 18:08:13 +02:00
409
be46f92ddf improve zip memory usage 2025-09-02 13:44:12 +02:00
409
702f16f199 improve zip downloads 2025-08-30 02:42:38 +02:00
409
9b3f4a5fe6 stream zip archive creation 2025-08-29 23:34:21 +02:00
409
3498a2926c directory downloads (zipped) 2025-08-29 21:20:44 +02:00
409
76713db985 basic README 2025-08-29 16:27:06 +02:00
96 changed files with 3555 additions and 917 deletions

View File

@@ -12,3 +12,4 @@ frontend/node_modules
backend/target
backend/.gitignore
backend/data

7
README.md Normal file
View 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
View File

@@ -1,3 +1,4 @@
target
serve
.env
data

251
backend/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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);

View File

@@ -1 +0,0 @@
ALTER TABLE warrens ADD COLUMN name VARCHAR NOT NULL;

View File

@@ -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
);

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);

View File

@@ -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
);

View File

@@ -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)
);

View File

@@ -1 +0,0 @@
ALTER TABLE user_warrens DROP COLUMN can_create_children, DROP COLUMN can_delete_warren;

View File

@@ -1,2 +0,0 @@
ALTER TABLE users ALTER COLUMN hash DROP NOT NULL;
ALTER TABLE users ADD COLUMN oidc_sub VARCHAR UNIQUE;

View File

@@ -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
);

View File

@@ -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;

View File

@@ -1 +0,0 @@
ALTER TABLE shares ALTER COLUMN password_hash DROP NOT NULL;

View File

@@ -1 +0,0 @@
CREATE INDEX idx_shares_path ON shares(path);

View File

@@ -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
);

View 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
);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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),

View File

@@ -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,
}

View File

@@ -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),
}

View File

@@ -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),
}

View File

@@ -1,5 +1,6 @@
pub mod auth_session;
pub mod file;
pub mod option;
pub mod share;
pub mod user;
pub mod user_warren;

View 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()
}
}

View File

@@ -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),
}

View 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),
}

View File

@@ -0,0 +1,6 @@
mod delete;
mod get;
mod set;
pub use delete::*;
pub use get::*;
pub use set::*;

View 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),
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>(

View File

@@ -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> {

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod file_system;
pub mod option;
pub mod warren;

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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()),
}
}
}

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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())
}
},
}

View File

@@ -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),
))
}
}

View File

@@ -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(&current_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(&current_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();
&current_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)
}

View File

@@ -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");
}
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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)
}

View File

@@ -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> {

View 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)
}

View 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))
}
}

View File

@@ -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(
"

View File

@@ -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
*

View File

@@ -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'

View File

@@ -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.

View File

@@ -20,6 +20,7 @@ body,
#__layout {
width: 100%;
height: 100%;
overflow: hidden;
}
* {

View File

@@ -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>

View File

@@ -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"

View File

@@ -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)}`
)
"
>

View File

@@ -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>

View File

@@ -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

View 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>

View 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>

View File

@@ -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,
});

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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 };
}

View File

@@ -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/')"

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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,
}),
});

View File

@@ -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({

View File

@@ -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"

View File

@@ -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>

View File

@@ -9,6 +9,7 @@ export type DirectoryEntry = {
name: string;
fileType: FileType;
mimeType: string | null;
size: number;
/// Timestamp in seconds
createdAt: number | null;
isParent: boolean;

View File

@@ -0,0 +1 @@
export type SelectionMode = 'set' | 'add' | 'subtract';

View File

@@ -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: '',

View 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
);
},
},
});

View 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;
},
},
});

View File

@@ -0,0 +1,4 @@
import useImageViewer from './image';
import useTextEditor from './text';
export { useImageViewer, useTextEditor };

View 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;
},
},
});

View File

@@ -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
View 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
);
}

View 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());
}