Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15m15s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 15m14s

This commit is contained in:
2025-10-17 18:07:39 +02:00
parent 768f8cb21c
commit 2713dcb047
123 changed files with 25116 additions and 1755 deletions
+533 -34
View File
@@ -593,6 +593,51 @@ dependencies = [
"tower-service",
]
[[package]]
name = "backend"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"axum",
"axum-extra",
"base64 0.21.7",
"bytes",
"chrono",
"diesel",
"diesel_migrations",
"dotenv",
"futures-util",
"hex",
"http-body-util",
"hyper 1.7.0",
"image",
"jsonwebtoken",
"mime_guess",
"once_cell",
"pdfium-render",
"percent-encoding",
"quick-xml",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"url",
"uuid",
]
[[package]]
name = "backtrace"
version = "0.3.76"
@@ -692,12 +737,24 @@ version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.10.1"
@@ -741,6 +798,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.42"
@@ -775,6 +838,26 @@ dependencies = [
"cc",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "const-oid"
version = "0.9.6"
@@ -1097,12 +1180,31 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.12.1"
@@ -1119,6 +1221,16 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
[[package]]
name = "flate2"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -1161,6 +1273,23 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
@@ -1180,9 +1309,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
@@ -1215,9 +1349,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasi 0.14.7+wasi-0.2.4",
"wasm-bindgen",
]
[[package]]
@@ -1489,6 +1625,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -1652,6 +1789,21 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "indexmap"
version = "2.11.4"
@@ -1679,6 +1831,16 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1751,6 +1913,12 @@ dependencies = [
"windows-targets 0.53.5",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.0"
@@ -1781,6 +1949,12 @@ dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -1796,6 +1970,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "maybe-owned"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
[[package]]
name = "md-5"
version = "0.10.6"
@@ -1839,6 +2019,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -1852,6 +2042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
@@ -1865,6 +2056,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "moxcms"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cc7d85f3d741164e8972ad355e26ac6e51b20fcae5f911c7da8f2d8bbbb3f33"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1973,37 +2174,6 @@ dependencies = [
"sha2",
]
[[package]]
name = "paperless-backend"
version = "0.1.0"
dependencies = [
"anyhow",
"argon2",
"aws-config",
"aws-credential-types",
"aws-sdk-s3",
"axum",
"axum-extra",
"bytes",
"chrono",
"diesel",
"diesel_migrations",
"dotenv",
"hex",
"jsonwebtoken",
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
"thiserror 1.0.69",
"tokio",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2038,6 +2208,32 @@ dependencies = [
"subtle",
]
[[package]]
name = "pdfium-render"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8fa6e8ceaad45c9fed63db09045c45f2cccd4a8b92a826bd3f8e901a98b14c6"
dependencies = [
"bitflags",
"bytemuck",
"bytes",
"chrono",
"console_error_panic_hook",
"console_log",
"image",
"itertools",
"js-sys",
"libloading",
"log",
"maybe-owned",
"once_cell",
"utf16string",
"vecmath",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "pem"
version = "3.0.5"
@@ -2054,6 +2250,26 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@@ -2066,6 +2282,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "piston-float"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590"
[[package]]
name = "pkcs8"
version = "0.9.0"
@@ -2082,6 +2304,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "png"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "potential_utf"
version = "0.1.3"
@@ -2136,6 +2371,79 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls 0.23.32",
"socket2 0.6.0",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.3",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls 0.23.32",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.0",
"tracing",
"windows-sys 0.59.0",
]
[[package]]
name = "quote"
version = "1.0.41"
@@ -2265,6 +2573,47 @@ version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "reqwest"
version = "0.12.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.7.0",
"hyper-rustls 0.27.7",
"hyper-util",
"js-sys",
"log",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.32",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
"tokio-util",
"tower 0.5.2",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "rfc6979"
version = "0.3.1"
@@ -2311,6 +2660,19 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.21.12"
@@ -2331,6 +2693,7 @@ checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki 0.103.7",
"subtle",
@@ -2376,6 +2739,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
dependencies = [
"web-time",
"zeroize",
]
@@ -2634,6 +2998,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "simple_asn1"
version = "0.6.3"
@@ -2728,6 +3098,9 @@ name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
dependencies = [
"futures-core",
]
[[package]]
name = "synstructure"
@@ -2740,6 +3113,19 @@ dependencies = [
"syn",
]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -2830,6 +3216,21 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.47.1"
@@ -2931,7 +3332,9 @@ version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"pin-project",
"pin-project-lite",
"tokio",
"tower-layer",
@@ -2957,16 +3360,18 @@ dependencies = [
[[package]]
name = "tower-http"
version = "0.5.2"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags",
"bytes",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"iri-string",
"pin-project-lite",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
@@ -3058,6 +3463,12 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539"
[[package]]
name = "unicode-ident"
version = "1.0.19"
@@ -3088,6 +3499,15 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf16string"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216"
dependencies = [
"byteorder",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3118,6 +3538,15 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vecmath"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a"
dependencies = [
"piston-float",
]
[[package]]
name = "version_check"
version = "0.9.5"
@@ -3190,6 +3619,19 @@ dependencies = [
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c"
dependencies = [
"cfg-if",
"js-sys",
"once_cell",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.104"
@@ -3222,6 +3664,48 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-streams"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.81"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32b130c0d2d49f8b6889abc456e795e82525204f27c42cf767cf0d7734e089b8"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -3564,3 +4048,18 @@ dependencies = [
"quote",
"syn",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core",
]
+19 -3
View File
@@ -1,5 +1,5 @@
[package]
name = "paperless-backend"
name = "backend"
version = "0.1.0"
edition = "2021"
@@ -7,8 +7,8 @@ edition = "2021"
# Web framework
axum = { version = "0.7", features = ["multipart"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["make"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tower = { version = "0.4", features = ["make", "util"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
# Database
@@ -33,6 +33,17 @@ dotenv = "0.15"
sha2 = "0.10"
hex = "0.4"
bytes = "1.5"
async-trait = "0.1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
pdfium-render = "0.8"
mime_guess = "2.0"
tempfile = "3.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
percent-encoding = "2.3"
base64 = "0.21"
quick-xml = "0.32"
futures-util = "0.3"
url = "2.5"
# Error handling
thiserror = "1.0"
@@ -44,3 +55,8 @@ jsonwebtoken = "9"
# Misc
rand = "0.8"
[dev-dependencies]
once_cell = "1.19"
hyper = "1.2"
http-body-util = "0.1"
+64
View File
@@ -0,0 +1,64 @@
# syntax=docker/dockerfile:1
FROM rust:1-slim AS builder
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libssl-dev \
libpq-dev \
libjpeg-dev \
libpng-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
COPY tests ./tests
COPY diesel.toml ./
RUN cargo build --release --bin backend --bin worker --bin webdav
RUN cargo install diesel_cli --no-default-features --features postgres
FROM debian:trixie-slim AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \
libpq5 \
libjpeg62-turbo \
libpng16-16 \
ocrmypdf \
tesseract-ocr \
ghostscript \
qpdf \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /usr/local/lib \
&& curl -fsSL https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-arm64.tgz -o /tmp/pdfium.tgz \
&& mkdir -p /tmp/pdfium \
&& tar -xzf /tmp/pdfium.tgz -C /tmp/pdfium --strip-components=1 \
&& pdfium_so="$(find /tmp/pdfium -name libpdfium.so -type f | head -n1)" \
&& [ -n "${pdfium_so}" ] \
&& mv "${pdfium_so}" /usr/local/lib/libpdfium.so \
&& ldconfig \
&& rm -rf /tmp/pdfium.tgz /tmp/pdfium \
&& useradd --system --create-home --uid 10001 appuser
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel
COPY migrations ./migrations
COPY diesel.toml ./
ENV RUST_LOG=info
USER appuser
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/papercrate-backend"]
@@ -0,0 +1,5 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at;
DROP INDEX IF EXISTS idx_jobs_job_type;
DROP INDEX IF EXISTS idx_jobs_status_run_after;
DROP TABLE IF EXISTS jobs;
@@ -0,0 +1,28 @@
CREATE TABLE jobs (
id UUID PRIMARY KEY,
job_type TEXT NOT NULL,
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempts INTEGER NOT NULL DEFAULT 0,
run_after TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed'))
);
CREATE INDEX idx_jobs_status_run_after ON jobs (status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_jobs_updated_at
BEFORE UPDATE ON jobs
FOR EACH ROW
EXECUTE FUNCTION touch_jobs_updated_at();
@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_document_assets_type;
DROP INDEX IF EXISTS idx_document_assets_version;
DROP TABLE IF EXISTS document_assets;
@@ -0,0 +1,15 @@
CREATE TABLE document_assets (
id UUID PRIMARY KEY,
document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE,
asset_type TEXT NOT NULL,
s3_key TEXT NOT NULL,
mime_type TEXT NOT NULL,
width INTEGER,
height INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type)
);
CREATE INDEX idx_document_assets_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS folders_parent_name_unique_idx;
ALTER TABLE folders
ADD CONSTRAINT folders_parent_name_unique UNIQUE (parent_id, name);
@@ -0,0 +1,5 @@
ALTER TABLE folders
DROP CONSTRAINT IF EXISTS folders_parent_name_unique;
CREATE UNIQUE INDEX folders_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name);
@@ -0,0 +1,2 @@
ALTER TABLE documents
DROP COLUMN issued_at;
@@ -0,0 +1,2 @@
ALTER TABLE documents
ADD COLUMN issued_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE documents
DROP COLUMN name;
@@ -0,0 +1,11 @@
ALTER TABLE documents
ADD COLUMN name VARCHAR(255);
UPDATE documents
SET name = CASE
WHEN filename ~ '\\.[^./]+$' THEN regexp_replace(filename, '\\.[^./]+$', '')
ELSE filename
END;
ALTER TABLE documents
ALTER COLUMN name SET NOT NULL;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN title TO name;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN name TO title;
@@ -0,0 +1 @@
DROP TABLE refresh_tokens;
@@ -0,0 +1,13 @@
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);
@@ -0,0 +1,13 @@
-- Restore width/height columns and repopulate from metadata where available.
ALTER TABLE document_assets
ADD COLUMN width INTEGER,
ADD COLUMN height INTEGER;
UPDATE document_assets
SET width = (metadata->>'width')::INTEGER
WHERE metadata ? 'width';
UPDATE document_assets
SET height = (metadata->>'height')::INTEGER
WHERE metadata ? 'height';
@@ -0,0 +1,15 @@
-- Backfill existing width/height values into metadata then drop the columns.
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('width', width)
WHERE width IS NOT NULL
AND NOT (metadata ? 'width');
UPDATE document_assets
SET metadata = metadata || jsonb_build_object('height', height)
WHERE height IS NOT NULL
AND NOT (metadata ? 'height');
ALTER TABLE document_assets
DROP COLUMN width,
DROP COLUMN height;
@@ -0,0 +1,17 @@
ALTER TABLE documents ADD COLUMN current_version INT4;
UPDATE documents AS d
SET current_version = dv.version_number
FROM document_versions AS dv
WHERE dv.id = d.current_version_id;
ALTER TABLE documents
ALTER COLUMN current_version SET NOT NULL;
DROP INDEX IF EXISTS idx_documents_current_version_id;
ALTER TABLE documents
DROP CONSTRAINT IF EXISTS documents_current_version_fk;
ALTER TABLE documents
DROP COLUMN current_version_id;
@@ -0,0 +1,21 @@
ALTER TABLE documents ADD COLUMN current_version_id UUID;
UPDATE documents AS d
SET current_version_id = dv.id
FROM document_versions AS dv
WHERE dv.document_id = d.id
AND dv.version_number = d.current_version;
ALTER TABLE documents
ALTER COLUMN current_version_id SET NOT NULL;
ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk
FOREIGN KEY (current_version_id)
REFERENCES document_versions(id)
DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX idx_documents_current_version_id
ON documents(current_version_id);
ALTER TABLE documents DROP COLUMN current_version;
@@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_document_correspondents_role;
DROP INDEX IF EXISTS idx_document_correspondents_correspondent;
DROP INDEX IF EXISTS idx_document_correspondents_document;
DROP TABLE IF EXISTS document_correspondents;
DROP TABLE IF EXISTS correspondents;
@@ -0,0 +1,27 @@
CREATE TABLE correspondents (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT correspondents_name_unique UNIQUE (name)
);
CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
assigned_by UUID REFERENCES users(id),
PRIMARY KEY (document_id, correspondent_id, role),
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
);
CREATE INDEX idx_document_correspondents_document
ON document_correspondents(document_id);
CREATE INDEX idx_document_correspondents_correspondent
ON document_correspondents(correspondent_id);
CREATE INDEX idx_document_correspondents_role
ON document_correspondents(role);
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
@@ -0,0 +1,13 @@
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,16 @@
DROP INDEX IF EXISTS documents_unique_folder_filename;
DROP INDEX IF EXISTS idx_documents_folder_title;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE INDEX idx_documents_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,18 @@
DROP INDEX IF EXISTS idx_documents_folder_title;
DROP INDEX IF EXISTS idx_documents_folder_filename;
DROP INDEX IF EXISTS documents_unique_folder_title;
DROP INDEX IF EXISTS documents_unique_folder_filename;
CREATE INDEX idx_documents_folder_title
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
title
)
WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename
ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename
)
WHERE deleted_at IS NULL;
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
DROP COLUMN metadata;
@@ -0,0 +1,2 @@
ALTER TABLE document_versions
ADD COLUMN metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
ALTER TABLE folders
ADD COLUMN path_cache VARCHAR(1000);
@@ -0,0 +1,2 @@
ALTER TABLE folders
DROP COLUMN path_cache;
+37
View File
@@ -13,6 +13,8 @@ pub struct JwtService {
issuer: String,
audience: String,
expiry: Duration,
download_audience: String,
download_expiry: Duration,
}
impl JwtService {
@@ -23,6 +25,8 @@ impl JwtService {
issuer: config.jwt_issuer.clone(),
audience: config.jwt_audience.clone(),
expiry: Duration::minutes(config.jwt_expiry_minutes),
download_audience: config.download_token_audience.clone(),
download_expiry: Duration::minutes(config.download_token_expiry_minutes),
})
}
@@ -49,6 +53,29 @@ impl JwtService {
let data = decode::<Claims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
pub fn generate_download_token(&self, document_id: Uuid, user_id: Uuid) -> Result<String> {
let now = Utc::now();
let exp = now + self.download_expiry;
let claims = DownloadClaims {
doc_id: document_id,
user_id,
iss: self.issuer.clone(),
aud: self.download_audience.clone(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
};
Ok(encode(&Header::default(), &claims, &self.encoding)?)
}
pub fn verify_download_token(&self, token: &str) -> Result<DownloadClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.download_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<DownloadClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -61,3 +88,13 @@ pub struct Claims {
pub iat: usize,
pub exp: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadClaims {
pub doc_id: Uuid,
pub user_id: Uuid,
pub iss: String,
pub aud: String,
pub iat: usize,
pub exp: usize,
}
+75
View File
@@ -0,0 +1,75 @@
use std::env;
use anyhow::{Context, Result};
use diesel::prelude::*;
use backend::{
config::AppConfig,
db,
models::DocumentAsset,
s3,
schema::document_assets,
storage::{ObjectStorage, S3Storage},
};
#[tokio::main]
async fn main() -> Result<()> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("delete-assets") => delete_all_assets().await?,
Some(cmd) => {
eprintln!("Unknown command: {cmd}\nUsage: maintenance delete-assets");
std::process::exit(1);
}
None => {
eprintln!("Usage: maintenance delete-assets");
std::process::exit(1);
}
}
Ok(())
}
async fn delete_all_assets() -> Result<()> {
let config = AppConfig::from_env()?;
tracing::info!(
component = "maintenance",
database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size,
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = s3::build_client(&config).await?;
let storage = S3Storage::new(s3_client, config.s3_bucket.clone());
let mut conn = pool.get().context("failed to get database connection")?;
let assets: Vec<DocumentAsset> = document_assets::table
.load(&mut conn)
.context("failed to load document assets")?;
if assets.is_empty() {
println!("No assets found.");
return Ok(());
}
println!("Deleting {} assets…", assets.len());
for asset in &assets {
if let Err(err) = storage.delete_object(&asset.s3_key).await {
eprintln!(
"Failed to delete object {} from storage: {err}",
asset.s3_key
);
}
}
diesel::delete(document_assets::table)
.execute(&mut conn)
.context("failed to remove asset records")?;
println!("Asset records deleted.");
Ok(())
}
+60
View File
@@ -0,0 +1,60 @@
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db;
use backend::routes::webdav;
use backend::s3::build_client;
use backend::state::AppState;
use backend::storage::S3Storage;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
init_tracing();
let config = AppConfig::from_env()?;
tracing::info!(
component = "webdav",
database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size,
server_host = %config.server_host,
server_port = config.server_port,
webdav_host = %config.webdav_host,
webdav_port = config.webdav_port,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool, config, storage, jwt);
let listen_addr: SocketAddr = {
let config = state.config.clone();
format!("{}:{}", config.webdav_host, config.webdav_port).parse()?
};
let router = webdav::create_router().with_state(state);
let listener = TcpListener::bind(listen_addr).await?;
tracing::info!("listening for WebDAV on {}", listen_addr);
axum::serve(listener, Shared::new(router)).await?;
Ok(())
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+50
View File
@@ -0,0 +1,50 @@
use std::{sync::Arc, time::Duration};
use tokio::signal;
use tracing_subscriber::EnvFilter;
use backend::{
auth::jwt::JwtService, config::AppConfig, db, default_handlers, s3::build_client,
state::AppState, storage::S3Storage, Worker,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
init_tracing();
let config = AppConfig::from_env()?;
tracing::info!(
component = "worker",
database_url = %config.redacted_database_url(),
pool_size = 1,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, 1)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = Arc::new(AppState::new(pool, config, storage, jwt));
let worker = Worker::new(state, default_handlers(), Duration::from_secs(2));
tokio::select! {
_ = worker.run() => {}
_ = signal::ctrl_c() => {
tracing::info!("worker received shutdown signal");
}
}
Ok(())
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.compact()
.init();
}
+91 -2
View File
@@ -1,58 +1,147 @@
use std::env;
use anyhow::{Context, Result};
use url::Url;
use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug)]
pub struct AppConfig {
pub database_url: String,
pub database_max_pool_size: u32,
pub server_host: String,
pub server_port: u16,
pub webdav_host: String,
pub webdav_port: u16,
pub jwt_secret: String,
pub jwt_issuer: String,
pub jwt_audience: String,
pub jwt_expiry_minutes: i64,
pub download_token_audience: String,
pub download_token_expiry_minutes: i64,
pub refresh_token_expiry_days: i64,
pub refresh_cookie_secure: bool,
pub refresh_cookie_domain: Option<String>,
pub cors_allowed_origin: Option<String>,
pub aws_endpoint_url: Option<String>,
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
pub aws_region: String,
pub s3_bucket: String,
pub quickwit_endpoint: Option<String>,
pub quickwit_index: Option<String>,
}
impl AppConfig {
pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE")
.ok()
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "paperless-neo".to_string());
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "paperless-neo-clients".to_string());
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "papercrate-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
quickwit_endpoint,
quickwit_index,
})
}
pub fn redacted_database_url(&self) -> String {
redact_database_url(&self.database_url)
}
}
fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) {
Ok(mut parsed) => {
let _ = parsed.set_password(Some("*****"));
parsed.to_string()
}
Err(_) => "***".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::redact_database_url;
#[test]
fn redacts_password_in_database_url() {
let redacted = redact_database_url("postgres://user:secret@localhost/db");
assert!(redacted.contains("postgres://user:*****@"));
assert!(!redacted.contains("secret"));
}
#[test]
fn handles_url_without_password() {
let redacted = redact_database_url("postgres://localhost/db");
assert_eq!(redacted, "postgres://localhost/db");
}
#[test]
fn falls_back_when_parse_fails() {
let redacted = redact_database_url("not a url");
assert_eq!(redacted, "***");
}
}
+8 -1
View File
@@ -5,10 +5,17 @@ use diesel::r2d2::{ConnectionManager, Pool};
pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
}
pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result<PgPool> {
let manager = ConnectionManager::<PgConnection>::new(database_url);
let pool_size = max_size.max(1);
let pool = Pool::builder()
.max_size(16)
.max_size(pool_size)
.connection_timeout(Duration::from_secs(10))
.build(manager)?;
Ok(pool)
+129
View File
@@ -0,0 +1,129 @@
use std::time::Duration;
use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc};
use diesel::pg::PgConnection;
use diesel::prelude::*;
use serde_json::Value;
use thiserror::Error;
use uuid::Uuid;
use crate::models::{Job, NewJob};
use crate::schema::jobs;
pub const STATUS_QUEUED: &str = "queued";
pub const STATUS_PROCESSING: &str = "processing";
pub const STATUS_SUCCEEDED: &str = "succeeded";
pub const STATUS_FAILED: &str = "failed";
pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-text";
#[derive(Debug, Error)]
pub enum JobQueueError {
#[error("database error: {0}")]
Database(#[from] diesel::result::Error),
}
pub type JobQueueResult<T> = Result<T, JobQueueError>;
pub fn enqueue_job(
conn: &mut PgConnection,
job_type: &str,
payload: Value,
run_after: Option<NaiveDateTime>,
) -> JobQueueResult<Job> {
let new_job = NewJob {
id: Uuid::new_v4(),
job_type: job_type.to_string(),
payload,
status: STATUS_QUEUED.to_string(),
run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()),
};
diesel::insert_into(jobs::table)
.values(&new_job)
.execute(conn)?;
let job = jobs::table.find(new_job.id).first(conn)?;
Ok(job)
}
pub fn reserve_job(conn: &mut PgConnection, job_types: &[&str]) -> JobQueueResult<Option<Job>> {
let now = Utc::now().naive_utc();
conn.transaction(|conn| {
let job_opt = jobs::table
.filter(jobs::status.eq(STATUS_QUEUED))
.filter(jobs::run_after.le(now))
.filter(jobs::job_type.eq_any(job_types))
.order(jobs::run_after.asc())
.for_update()
.skip_locked()
.first::<Job>(conn)
.optional()?;
if let Some(job) = job_opt {
diesel::update(jobs::table.find(job.id))
.set((
jobs::status.eq(STATUS_PROCESSING),
jobs::attempts.eq(job.attempts + 1),
jobs::updated_at.eq(now),
))
.execute(conn)?;
let refreshed = jobs::table.find(job.id).first(conn)?;
Ok::<Option<Job>, diesel::result::Error>(Some(refreshed))
} else {
Ok::<Option<Job>, diesel::result::Error>(None)
}
})
.map_err(JobQueueError::from)
}
pub fn mark_job_succeeded(conn: &mut PgConnection, job_id: Uuid) -> JobQueueResult<()> {
diesel::update(jobs::table.find(job_id))
.set((
jobs::status.eq(STATUS_SUCCEEDED),
jobs::last_error.eq::<Option<String>>(None),
jobs::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)?;
Ok(())
}
pub fn retry_job_after(
conn: &mut PgConnection,
job_id: Uuid,
delay: Duration,
error_message: &str,
) -> JobQueueResult<()> {
let next_run = Utc::now()
+ ChronoDuration::from_std(delay).unwrap_or_else(|_| ChronoDuration::seconds(30));
diesel::update(jobs::table.find(job_id))
.set((
jobs::status.eq(STATUS_QUEUED),
jobs::run_after.eq(next_run.naive_utc()),
jobs::last_error.eq(Some(error_message.to_string())),
jobs::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)?;
Ok(())
}
pub fn mark_job_failed(
conn: &mut PgConnection,
job_id: Uuid,
error_message: &str,
) -> JobQueueResult<()> {
diesel::update(jobs::table.find(job_id))
.set((
jobs::status.eq(STATUS_FAILED),
jobs::last_error.eq(Some(error_message.to_string())),
jobs::updated_at.eq(Utc::now().naive_utc()),
))
.execute(conn)?;
Ok(())
}
+14
View File
@@ -0,0 +1,14 @@
pub mod auth;
pub mod config;
pub mod db;
pub mod error;
pub mod jobs;
pub mod models;
pub mod routes;
pub mod s3;
pub mod schema;
pub mod state;
pub mod storage;
pub mod utils;
pub mod workers;
pub use workers::{default_handlers, Worker};
+21 -16
View File
@@ -1,23 +1,17 @@
mod auth;
mod config;
mod db;
mod error;
mod models;
mod routes;
mod s3;
mod schema;
mod state;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tower::make::Shared;
use tracing_subscriber::EnvFilter;
use crate::auth::jwt::JwtService;
use crate::config::AppConfig;
use crate::s3::build_client;
use crate::state::AppState;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db;
use backend::routes;
use backend::s3::build_client;
use backend::state::AppState;
use backend::storage::S3Storage;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -25,11 +19,22 @@ async fn main() -> anyhow::Result<()> {
init_tracing();
let config = AppConfig::from_env()?;
let pool = db::init_pool(&config.database_url)?;
tracing::info!(
component = "api",
database_url = %config.redacted_database_url(),
pool_size = config.database_max_pool_size,
server_host = %config.server_host,
server_port = config.server_port,
quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket,
"loaded backend configuration"
);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
let s3_client = build_client(&config).await?;
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool, config, s3_client, jwt);
let state = AppState::new(pool, config, storage, jwt);
let router = routes::create_router(state.clone());
+120 -4
View File
@@ -30,7 +30,6 @@ pub struct Folder {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub path_cache: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
@@ -41,7 +40,6 @@ pub struct NewFolder {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub path_cache: Option<String>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -53,11 +51,13 @@ pub struct Document {
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub uploaded_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
pub current_version_id: Uuid,
}
#[derive(Debug, Insertable)]
@@ -68,8 +68,10 @@ pub struct NewDocument {
pub original_name: String,
pub content_type: Option<String>,
pub folder_id: Option<Uuid>,
pub current_version: i32,
pub current_version_id: Uuid,
pub metadata: serde_json::Value,
pub issued_at: Option<NaiveDateTime>,
pub title: String,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -84,6 +86,7 @@ pub struct DocumentVersion {
pub checksum: String,
pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value,
}
#[derive(Debug, Insertable)]
@@ -96,6 +99,55 @@ pub struct NewDocumentVersion {
pub size_bytes: i64,
pub checksum: String,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_assets)]
#[diesel(belongs_to(DocumentVersion, foreign_key = document_version_id))]
pub struct DocumentAsset {
pub id: Uuid,
pub document_version_id: Uuid,
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub metadata: serde_json::Value,
pub created_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_assets)]
pub struct NewDocumentAsset {
pub id: Uuid,
pub document_version_id: Uuid,
pub asset_type: String,
pub s3_key: String,
pub mime_type: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = jobs)]
pub struct Job {
pub id: Uuid,
pub job_type: String,
pub payload: serde_json::Value,
pub status: String,
pub attempts: i32,
pub run_after: NaiveDateTime,
pub last_error: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = jobs)]
pub struct NewJob {
pub id: Uuid,
pub job_type: String,
pub payload: serde_json::Value,
pub status: String,
pub run_after: NaiveDateTime,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
@@ -135,3 +187,67 @@ pub struct NewDocumentTag {
pub tag_id: Uuid,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = correspondents)]
pub struct Correspondent {
pub id: Uuid,
pub name: String,
pub metadata: serde_json::Value,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = correspondents)]
pub struct NewCorrespondent {
pub id: Uuid,
pub name: String,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Queryable, Associations)]
#[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))]
pub struct DocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_correspondents)]
pub struct NewDocumentCorrespondent {
pub document_id: Uuid,
pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = refresh_tokens)]
#[diesel(belongs_to(User))]
pub struct RefreshToken {
pub id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
pub revoked_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = refresh_tokens)]
pub struct NewRefreshToken {
pub id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub issued_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
}
+218 -12
View File
@@ -1,15 +1,28 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use axum::{
extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode},
Json,
};
use axum_extra::{headers::Cookie, typed_header::TypedHeader};
use chrono::{Duration as ChronoDuration, Utc};
use diesel::prelude::*;
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{
auth::{password, AuthenticatedUser},
error::{AppError, AppResult},
models::User,
schema::users::dsl,
models::{NewRefreshToken, RefreshToken, User},
schema::{refresh_tokens, users::dsl},
state::AppState,
};
use crate::schema::refresh_tokens::dsl as refresh_dsl;
const REFRESH_COOKIE_NAME: &str = "refresh_token";
#[derive(Deserialize)]
pub struct LoginRequest {
pub username: String,
@@ -26,7 +39,7 @@ pub struct LoginResponse {
pub async fn login(
State(state): State<AppState>,
Json(payload): Json<LoginRequest>,
) -> AppResult<Json<LoginResponse>> {
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
let mut conn = state.db()?;
let user: User = dsl::users
@@ -40,22 +53,215 @@ pub async fn login(
return Err(AppError::unauthorized());
}
let token = state
let access_token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?;
Ok(Json(LoginResponse {
access_token: token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}))
let now = Utc::now();
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &refresh_value, refresh_expires_at),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
}
pub async fn logout(_user: AuthenticatedUser) -> impl IntoResponse {
StatusCode::NO_CONTENT
pub async fn refresh(
State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, Json<LoginResponse>)> {
let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies
.get(REFRESH_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value);
let mut conn = state.db()?;
let now = Utc::now();
let now_naive = now.naive_utc();
let token = match refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(&hashed))
.filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now_naive))
.first::<RefreshToken>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
.set((
refresh_dsl::revoked_at.eq(now_naive),
refresh_dsl::updated_at.eq(now_naive),
))
.execute(&mut conn)?;
let user: User = dsl::users
.find(token.user_id)
.first(&mut conn)
.map_err(AppError::from)?;
let access_token = state
.jwt
.generate_token(user.id, &user.username, &user.role)
.map_err(AppError::from)?;
let new_refresh_value = generate_refresh_token();
let new_refresh_hash = hash_refresh_token(&new_refresh_value);
let new_refresh_expires = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: new_refresh_hash,
issued_at: now_naive,
expires_at: new_refresh_expires.naive_utc(),
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(&mut conn)?;
let mut headers = HeaderMap::new();
headers.insert(
SET_COOKIE,
build_refresh_cookie(&state, &new_refresh_value, new_refresh_expires),
);
Ok((
headers,
Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
}),
))
}
pub async fn logout(
State(state): State<AppState>,
user: AuthenticatedUser,
jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db()?;
let now = Utc::now().naive_utc();
let mut rows_affected = 0;
if let Some(cookies) = jar {
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) {
let hashed = hash_refresh_token(value);
rows_affected = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(hashed))
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn)
.unwrap_or(0);
}
}
if rows_affected == 0 {
let _ = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn);
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
Ok((headers, StatusCode::NO_CONTENT))
}
pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user)
}
fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn build_refresh_cookie(
state: &AppState,
token: &str,
expires_at: chrono::DateTime<Utc>,
) -> HeaderValue {
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push(format!("Max-Age={}", max_age));
parts.push(format!("Expires={}", expires_at.to_rfc2822()));
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
}
fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push("Max-Age=0".into());
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
if state.config.refresh_cookie_secure {
parts.push("Secure".into());
}
if let Some(domain) = &state.config.refresh_cookie_domain {
parts.push(format!("Domain={}", domain));
}
HeaderValue::from_str(&parts.join("; ")).expect("valid refresh cookie")
}
+259
View File
@@ -0,0 +1,259 @@
use std::collections::{BTreeMap, HashMap};
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{
error::{AppError, AppResult},
models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents},
state::AppState,
};
use super::documents::to_iso;
#[derive(Serialize)]
pub struct CorrespondentUsage {
pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
}
#[derive(Serialize)]
pub struct CorrespondentSummary {
pub id: Uuid,
pub name: String,
pub metadata: Value,
pub created_at: String,
pub updated_at: String,
pub usage: CorrespondentUsage,
}
#[derive(Deserialize)]
pub struct CreateCorrespondentRequest {
pub name: String,
#[serde(default)]
pub metadata: Option<Value>,
}
#[derive(Deserialize)]
pub struct UpdateCorrespondentRequest {
pub name: Option<String>,
pub metadata: Option<Value>,
}
#[derive(AsChangeset, Default)]
#[diesel(table_name = correspondents)]
struct CorrespondentChangeset<'a> {
name: Option<&'a str>,
metadata: Option<&'a Value>,
}
pub async fn list_correspondents(
State(state): State<AppState>,
) -> AppResult<Json<Vec<CorrespondentSummary>>> {
let mut conn = state.db()?;
let correspondents_list: Vec<Correspondent> = correspondents::table
.order(correspondents::name.asc())
.load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table
.group_by((
document_correspondents::correspondent_id,
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new();
for (correspondent_id, role, count) in usage_rows {
usage_map
.entry(correspondent_id)
.or_default()
.insert(role, count);
}
let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default();
response.push(build_summary(correspondent, role_counts));
}
Ok(Json(response))
}
pub async fn create_correspondent(
State(state): State<AppState>,
Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let name = payload.name.trim();
if name.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
let metadata_value = normalize_metadata(payload.metadata);
let new_id = Uuid::new_v4();
let new_correspondent = NewCorrespondent {
id: new_id,
name: name.to_string(),
metadata: metadata_value,
};
let mut conn = state.db()?;
match diesel::insert_into(correspondents::table)
.values(&new_correspondent)
.execute(&mut conn)
{
Ok(_) => {}
Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => {
return Err(AppError::bad_request("correspondent name already exists"));
}
Err(err) => return Err(AppError::from(err)),
}
let correspondent: Correspondent = correspondents::table.find(new_id).first(&mut conn)?;
Ok(Json(build_summary(correspondent, BTreeMap::new())))
}
pub async fn update_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>,
Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> {
let mut conn = state.db()?;
let existing: Correspondent = correspondents::table
.find(correspondent_id)
.first(&mut conn)?;
let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name {
let trimmed = candidate.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != existing.name {
let duplicate = correspondents::table
.filter(correspondents::name.eq(trimmed))
.filter(correspondents::id.ne(correspondent_id))
.first::<Correspondent>(&mut conn)
.optional()?;
if duplicate.is_some() {
return Err(AppError::bad_request("correspondent name already exists"));
}
new_name = Some(trimmed.to_string());
}
}
let mut new_metadata: Option<Value> = None;
if let Some(metadata) = payload.metadata.clone() {
let candidate = normalize_metadata(Some(metadata));
if candidate != existing.metadata {
new_metadata = Some(candidate);
}
}
if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
return Ok(Json(build_summary(existing.clone(), usage)));
}
let mut changeset = CorrespondentChangeset::default();
if let Some(ref name) = new_name {
changeset.name = Some(name.as_str());
}
if let Some(ref metadata) = new_metadata {
changeset.metadata = Some(metadata);
}
let now = Utc::now().naive_utc();
diesel::update(correspondents::table.find(correspondent_id))
.set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?;
let updated: Correspondent = correspondents::table
.find(correspondent_id)
.first(&mut conn)?;
let usage = load_usage_for_correspondent(&mut conn, correspondent_id)?;
Ok(Json(build_summary(updated, usage)))
}
pub async fn delete_correspondent(
State(state): State<AppState>,
Path(correspondent_id): Path<Uuid>,
) -> AppResult<impl IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.select(count_star())
.first(&mut conn)?;
if usage > 0 {
return Err(AppError::bad_request(
"cannot delete correspondent that is still assigned to documents",
));
}
let deleted =
diesel::delete(correspondents::table.find(correspondent_id)).execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
Ok(StatusCode::NO_CONTENT)
}
fn build_summary(
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary {
id: correspondent.id,
name: correspondent.name,
metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage {
total,
by_role: role_counts,
},
}
}
fn normalize_metadata(input: Option<Value>) -> Value {
match input {
None | Some(Value::Null) => Value::Object(Default::default()),
Some(value) => value,
}
}
fn load_usage_for_correspondent(
conn: &mut PgConnection,
correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> {
let rows: Vec<(String, i64)> = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id))
.group_by(document_correspondents::role)
.select((document_correspondents::role, count_star()))
.load(conn)?;
let mut map = BTreeMap::new();
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
}
File diff suppressed because it is too large Load Diff
+219 -145
View File
@@ -4,15 +4,20 @@ use axum::{
};
use diesel::{dsl::exists, prelude::*, PgConnection};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{document_tags, documents, folders};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{
auth::AuthenticatedUser,
error::{AppError, AppResult},
};
use super::documents::{load_tags_for_documents, to_document_response, to_iso, DocumentResponse};
use super::documents::{
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, to_iso, DocumentResponse,
};
#[derive(Deserialize)]
pub struct CreateFolderRequest {
@@ -20,6 +25,19 @@ pub struct CreateFolderRequest {
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct EnsureFolderPathRequest {
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Deserialize)]
pub struct UpdateFolderRequest {
#[serde(default)]
pub parent_id: Option<Option<Uuid>>,
pub name: Option<String>,
}
#[derive(Serialize)]
pub struct FolderResponse {
pub folder: FolderInfo,
@@ -33,9 +51,13 @@ pub struct FolderContentsResponse {
}
#[derive(Deserialize)]
pub struct DocumentSearchQuery {
pub query: Option<String>,
pub tags: Option<String>,
pub struct FolderContentsQuery {
#[serde(default = "default_include_documents")]
pub include_documents: bool,
}
const fn default_include_documents() -> bool {
true
}
#[derive(Serialize)]
@@ -43,11 +65,72 @@ pub struct FolderInfo {
pub id: Uuid,
pub name: String,
pub parent_id: Option<Uuid>,
pub path_cache: Option<String>,
pub created_at: String,
pub updated_at: String,
}
pub async fn ensure_folder_path(
State(state): State<AppState>,
Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let mut conn = state.db()?;
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = raw_name.trim();
if name.is_empty() {
return Err(AppError::bad_request("folder names must not be empty"));
}
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(conn)?;
folders::table.find(new_folder.id).first(conn)?
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
})?;
Ok(Json(FolderResponse {
folder: folder_to_info(target_folder),
}))
}
pub async fn create_folder(
State(state): State<AppState>,
Json(payload): Json<CreateFolderRequest>,
@@ -57,13 +140,11 @@ pub async fn create_folder(
}
let mut conn = state.db()?;
let path_cache = build_path_cache(&mut conn, payload.parent_id, &payload.name)?;
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: payload.name.trim().to_string(),
parent_id: payload.parent_id,
path_cache,
};
diesel::insert_into(folders::table)
@@ -79,6 +160,8 @@ pub async fn create_folder(
pub async fn list_folder_contents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
Query(query): Query<FolderContentsQuery>,
user: AuthenticatedUser,
) -> AppResult<Json<FolderContentsResponse>> {
let mut conn = state.db()?;
@@ -111,30 +194,47 @@ pub async fn list_folder_contents(
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.order(documents::uploaded_at.desc());
let documents = if query.include_documents {
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load(&mut conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load(&mut conn)?
};
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load(&mut conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load(&mut conn)?
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let documents = docs
.into_iter()
.map(|doc| {
let primary_versions = load_primary_assets(&state, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
})
.collect();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response(
&state,
user.user_id,
doc,
tags,
correspondents,
current_version,
)?);
}
documents
} else {
Vec::new()
};
Ok(Json(FolderContentsResponse {
folder,
@@ -143,102 +243,6 @@ pub async fn list_folder_contents(
}))
}
pub async fn search_documents(
State(state): State<AppState>,
Path(folder_identifier): Path<String>,
Query(params): Query<DocumentSearchQuery>,
) -> AppResult<Json<Vec<DocumentResponse>>> {
let mut conn = state.db()?;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None
} else {
Some(
Uuid::parse_str(&folder_identifier)
.map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?,
)
};
let mut docs_query = documents::table
.filter(documents::deleted_at.is_null())
.into_boxed();
if let Some(folder_id) = folder_id {
let descendant_ids = gather_descendant_folder_ids(&mut conn, folder_id)?;
docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids));
}
if let Some(query) = params
.query
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let pattern = format!("%{}%", query);
docs_query = docs_query.filter(documents::original_name.ilike(pattern));
}
if let Some(tags_param) = params
.tags
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let tag_ids: Result<Vec<Uuid>, _> = tags_param
.split(',')
.map(|s| Uuid::parse_str(s.trim()))
.collect();
if let Ok(ids) = tag_ids {
if !ids.is_empty() {
let mut doc_id_set: Option<HashSet<Uuid>> = None;
for tag_id in &ids {
let docs_for_tag: Vec<Uuid> = document_tags::table
.filter(document_tags::tag_id.eq(*tag_id))
.select(document_tags::document_id)
.load(&mut conn)?;
let docs_set: HashSet<Uuid> = docs_for_tag.into_iter().collect();
doc_id_set = Some(match doc_id_set {
Some(existing) => existing.intersection(&docs_set).cloned().collect(),
None => docs_set,
});
if let Some(ref set) = doc_id_set {
if set.is_empty() {
break;
}
}
}
let matching_doc_ids: Vec<Uuid> =
doc_id_set.unwrap_or_default().into_iter().collect();
if matching_doc_ids.is_empty() {
return Ok(Json(vec![]));
}
docs_query = docs_query.filter(documents::id.eq_any(matching_doc_ids));
}
}
}
let docs: Vec<Document> = docs_query
.order(documents::uploaded_at.desc())
.load(&mut conn)?;
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let response = docs
.into_iter()
.map(|doc| {
let tags = tags_map.get(&doc.id).cloned();
to_document_response(doc, tags)
})
.collect();
Ok(Json(response))
}
pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
@@ -280,23 +284,91 @@ pub async fn delete_folder(
Ok(StatusCode::NO_CONTENT)
}
fn build_path_cache(
conn: &mut PgConnection,
parent_id: Option<Uuid>,
name: &str,
) -> AppResult<Option<String>> {
let path = if let Some(parent_id) = parent_id {
let parent: Folder = folders::table.find(parent_id).first(conn)?;
let base = parent
.path_cache
.unwrap_or_else(|| "/".to_string())
.trim_end_matches('/')
.to_string();
format!("{}/{}", base, name)
} else {
format!("/{}", name)
};
Ok(Some(path))
pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> {
let mut conn = state.db()?;
conn.transaction::<(), AppError, _>(|conn| {
let folder: Folder = folders::table.find(folder_id).first(conn)?;
let mut next_parent = folder.parent_id;
let mut parent_changed = false;
if let Some(parent_request) = payload.parent_id {
if parent_request == Some(folder_id) {
return Err(AppError::bad_request("folder cannot be its own parent"));
}
if let Some(parent_id) = parent_request {
let _parent: Folder = folders::table.find(parent_id).first(conn)?;
let descendant_ids = gather_descendant_folder_ids(conn, folder_id)?;
if descendant_ids.contains(&parent_id) {
return Err(AppError::bad_request(
"cannot move folder into itself or a descendant",
));
}
}
parent_changed = parent_request != folder.parent_id;
next_parent = parent_request;
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
if let Some(name) = payload.name {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
name_changed = true;
}
}
if !parent_changed && !name_changed {
return Ok(());
}
let conflict = if let Some(parent_id) = next_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.first::<Folder>(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(&new_name))
.filter(folders::id.ne(folder_id))
.first::<Folder>(conn)
.optional()?
};
if conflict.is_some() {
return Err(AppError::bad_request(
"a folder with the same name already exists in the target",
));
}
diesel::update(folders::table.find(folder_id))
.set((
folders::parent_id.eq(next_parent),
folders::name.eq(&new_name),
))
.execute(conn)?;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
}
fn folder_to_info(folder: Folder) -> FolderInfo {
@@ -304,13 +376,15 @@ fn folder_to_info(folder: Folder) -> FolderInfo {
id: folder.id,
name: folder.name,
parent_id: folder.parent_id,
path_cache: folder.path_cache,
created_at: to_iso(folder.created_at),
updated_at: to_iso(folder.updated_at),
}
}
fn gather_descendant_folder_ids(conn: &mut PgConnection, folder_id: Uuid) -> AppResult<Vec<Uuid>> {
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
+87 -13
View File
@@ -1,27 +1,54 @@
use axum::http::HeaderValue;
use axum::{
extract::DefaultBodyLimit,
middleware,
routing::{delete, get, patch, post},
Router,
};
use tower_http::cors::{Any, CorsLayer};
use tower_http::cors::{AllowOrigin, CorsLayer};
use crate::{auth::AuthenticatedUser, state::AppState};
pub mod auth;
pub mod correspondents;
pub mod documents;
pub mod folders;
pub mod health;
pub mod tags;
pub mod webdav;
pub fn create_router(state: AppState) -> Router<()> {
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() {
let headers: Vec<HeaderValue> = origins
.split(',')
.filter_map(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| {
trimmed
.parse::<HeaderValue>()
.expect("invalid CORS allowed origin")
})
})
.collect();
let allow_origin = AllowOrigin::list(headers);
CorsLayer::new()
.allow_origin(allow_origin)
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
.allow_credentials(true)
} else {
CorsLayer::new()
.allow_origin(AllowOrigin::mirror_request())
.allow_methods(tower_http::cors::AllowMethods::mirror_request())
.allow_headers(tower_http::cors::AllowHeaders::mirror_request())
.allow_credentials(true)
};
let auth_routes = Router::new()
.route("/login", post(auth::login))
.route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout))
.route("/me", get(auth::me));
@@ -30,34 +57,81 @@ pub fn create_router(state: AppState) -> Router<()> {
"/",
get(documents::list_documents).post(documents::upload_document),
)
.route("/reanalyze", post(documents::reanalyze_all_documents))
.route("/bulk/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route(
"/bulk/correspondents",
post(documents::bulk_assign_correspondents),
)
.route(
"/bulk/reanalyze",
post(documents::reanalyze_selected_documents),
)
.route(
"/:id",
get(documents::get_document).delete(documents::delete_document),
get(documents::get_document)
.delete(documents::delete_document)
.patch(documents::update_document),
)
.route("/:id/download", get(documents::download_document))
.route("/:id/assets/:asset_id", get(documents::get_document_asset))
.route(
"/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets),
)
.route("/:id/folder", patch(documents::move_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag));
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route(
"/:id/correspondents",
post(documents::assign_correspondents),
)
.route(
"/:id/correspondents/:correspondent_id",
delete(documents::remove_correspondent),
);
let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token));
let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/:id", delete(folders::delete_folder))
.route("/:id/contents", get(folders::list_folder_contents))
.route("/:id/documents", get(folders::search_documents));
.route("/path", post(folders::ensure_folder_path))
.route(
"/:id",
delete(folders::delete_folder).patch(folders::update_folder),
)
.route("/:id/contents", get(folders::list_folder_contents));
let tags_routes = Router::new().route("/", get(tags::list_tags).post(tags::create_tag));
let tags_routes = Router::new()
.route("/", get(tags::list_tags).post(tags::create_tag))
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
let correspondents_routes = Router::new()
.route(
"/",
get(correspondents::list_correspondents).post(correspondents::create_correspondent),
)
.route(
"/:id",
patch(correspondents::update_correspondent)
.delete(correspondents::delete_correspondent),
);
let protected_state = state.clone();
let protected_routes = Router::new()
.nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes)
.route("/api/health", get(health::health_check))
.nest("/api/correspondents", correspondents_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state));
Router::new()
.nest("/api/auth", auth_routes)
.merge(download_routes)
.merge(protected_routes)
.nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check))
.with_state(state)
.layer(cors)
.layer(DefaultBodyLimit::max(1024 * 1024 * 512))
+188 -10
View File
@@ -1,32 +1,70 @@
use axum::{extract::State, Json};
use diesel::prelude::*;
use serde::Deserialize;
use crate::utils::json::{classify_nullable, NullableValue};
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use uuid::Uuid;
use crate::error::{AppError, AppResult};
use crate::models::{NewTag, Tag};
use crate::schema::tags;
use crate::schema::{document_tags, tags};
use crate::state::AppState;
use super::documents::TagResponse;
#[derive(Deserialize)]
pub struct CreateTagRequest {
pub label: String,
pub color: Option<String>,
}
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagResponse>>> {
#[derive(AsChangeset, Default)]
#[diesel(table_name = tags)]
struct UpdateTagChangeset<'a> {
label: Option<&'a str>,
color: Option<Option<&'a str>>,
}
#[derive(Serialize)]
pub struct TagCatalogEntry {
pub id: Uuid,
pub label: String,
pub color: Option<String>,
pub usage_count: i64,
}
pub async fn list_tags(State(state): State<AppState>) -> AppResult<Json<Vec<TagCatalogEntry>>> {
let mut conn = state.db()?;
let tag_list: Vec<Tag> = tags::table.order(tags::label.asc()).load(&mut conn)?;
let response = tag_list.into_iter().map(TagResponse::from).collect();
let usage_rows: Vec<(Uuid, i64)> = document_tags::table
.group_by(document_tags::tag_id)
.select((document_tags::tag_id, count_star()))
.load(&mut conn)?;
let usage_map: HashMap<Uuid, i64> = usage_rows.into_iter().collect();
let response = tag_list
.into_iter()
.map(|tag| TagCatalogEntry {
id: tag.id,
label: tag.label,
color: tag.color,
usage_count: *usage_map.get(&tag.id).unwrap_or(&0),
})
.collect();
Ok(Json(response))
}
pub async fn create_tag(
State(state): State<AppState>,
Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagResponse>> {
) -> AppResult<Json<TagCatalogEntry>> {
if payload.label.trim().is_empty() {
return Err(AppError::bad_request("label must not be empty"));
}
@@ -53,5 +91,145 @@ pub async fn create_tag(
}
let tag: Tag = tags::table.find(new_tag.id).first(&mut conn)?;
Ok(Json(TagResponse::from(tag)))
Ok(Json(TagCatalogEntry {
id: tag.id,
label: tag.label,
color: tag.color,
usage_count: 0,
}))
}
pub async fn update_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>,
Json(body): Json<Value>,
) -> AppResult<Json<TagCatalogEntry>> {
let mut conn = state.db()?;
let existing: Tag = tags::table.find(tag_id).first(&mut conn)?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?;
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
if matches!(label_class, NullableValue::Omitted)
&& matches!(color_class, NullableValue::Omitted)
{
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
return Ok(Json(TagCatalogEntry {
id: existing.id,
label: existing.label.clone(),
color: existing.color.clone(),
usage_count,
}));
}
let mut new_label: Option<String> = None;
let mut label_changed = false;
match label_class {
NullableValue::Omitted => {}
NullableValue::Null => {
return Err(AppError::bad_request("label cannot be null"));
}
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("label must not be empty"));
}
if trimmed != existing.label {
let duplicate = tags::table
.filter(tags::label.eq(trimmed))
.filter(tags::id.ne(tag_id))
.first::<Tag>(&mut conn)
.optional()?;
if duplicate.is_some() {
return Err(AppError::bad_request("tag label already exists"));
}
new_label = Some(trimmed.to_string());
label_changed = true;
}
}
}
let mut color_change: Option<Option<String>> = None;
let mut color_changed = false;
match color_class {
NullableValue::Omitted => {}
NullableValue::Null => {
color_change = Some(None);
color_changed = true;
}
NullableValue::String(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("color must not be empty"));
}
if existing.color.as_deref() != Some(trimmed) {
color_change = Some(Some(trimmed.to_string()));
color_changed = true;
}
}
}
if !label_changed && !color_changed {
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
return Ok(Json(TagCatalogEntry {
id: existing.id,
label: existing.label.clone(),
color: existing.color.clone(),
usage_count,
}));
}
let changeset = UpdateTagChangeset {
label: new_label.as_deref(),
color: color_change
.as_ref()
.map(|opt| opt.as_ref().map(|value| value.as_str())),
};
diesel::update(tags::table.find(tag_id))
.set(&changeset)
.execute(&mut conn)?;
let updated: Tag = tags::table.find(tag_id).first(&mut conn)?;
let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
Ok(Json(TagCatalogEntry {
id: updated.id,
label: updated.label,
color: updated.color,
usage_count,
}))
}
pub async fn delete_tag(
State(state): State<AppState>,
Path(tag_id): Path<Uuid>,
) -> AppResult<impl axum::response::IntoResponse> {
let mut conn = state.db()?;
let usage: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id))
.select(count_star())
.first(&mut conn)?;
if usage > 0 {
return Err(AppError::bad_request(
"cannot delete tag that is still assigned to documents",
));
}
let deleted = diesel::delete(tags::table.find(tag_id)).execute(&mut conn)?;
if deleted == 0 {
return Err(AppError::not_found());
}
Ok(StatusCode::NO_CONTENT)
}
+808
View File
@@ -0,0 +1,808 @@
use std::time::Duration;
use axum::body::Body;
use axum::extract::State;
use axum::http::{header, HeaderMap, Method, StatusCode};
use axum::response::Response;
use axum::Router;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use diesel::prelude::*;
use diesel::PgConnection;
use futures_util::StreamExt;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer;
use uuid::Uuid;
use crate::auth::password;
use crate::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User};
use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, users::dsl as users_dsl,
};
use crate::state::AppState;
const REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
struct WebDavUser {
_user_id: Uuid,
_username: String,
}
pub fn create_router() -> Router<AppState> {
Router::new().fallback(webdav_entrypoint)
}
async fn webdav_entrypoint(
State(state): State<AppState>,
req: axum::http::Request<axum::body::Body>,
) -> Result<Response, AppError> {
let method = req.method().clone();
let headers = req.headers().clone();
let path = req.uri().path().trim_start_matches('/').to_string();
tracing::debug!(method = %method, %path, "webdav entrypoint" );
match method {
ref m if m == Method::OPTIONS => Ok(handle_options()),
ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await,
ref m if m == Method::HEAD => {
handle_get_or_head(&state, &path, headers, Method::HEAD).await
}
_ => {
if method.as_str() == "PROPFIND" {
handle_propfind(&state, &path, headers).await
} else {
Ok(method_not_allowed())
}
}
}
}
async fn handle_propfind(
state: &AppState,
path: &str,
headers: HeaderMap,
) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? {
Some(user) => user,
None => return Ok(unauthorized_response()),
};
let depth = match parse_depth(&headers) {
Ok(value) => value,
Err(response) => return Ok(response),
};
let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
let resources = match resolution {
ResolvedPath::Root => {
let contents = fetch_folder_contents(state, None)?;
build_resources_for_folder(None, &[], &contents, depth)
}
ResolvedPath::Folder { folder, chain } => {
let contents = fetch_folder_contents(state, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth)
}
ResolvedPath::Document {
document,
version,
chain,
} => build_resources_for_document(&chain, &document, &version),
};
let body = render_multistatus(&resources)
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?;
let response = Response::builder()
.status(multi_status())
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(body))
.expect("valid response");
Ok(response)
}
async fn handle_get_or_head(
state: &AppState,
path: &str,
headers: HeaderMap,
method: Method,
) -> Result<Response, AppError> {
let _user = match authenticate(state, &headers)? {
Some(user) => user,
None => return Ok(unauthorized_response()),
};
let segments = parse_segments(path)?;
let resolution = match resolve_path(state, &segments)? {
Some(resolved) => resolved,
None => return Ok(not_found_response()),
};
let (document, version, chain) = match resolution {
ResolvedPath::Document {
document,
version,
chain,
} => (document, version, chain),
_ => return Ok(method_not_allowed()),
};
stream_document(state, &document, &version, &chain, headers, method).await
}
fn handle_options() -> Response {
Response::builder()
.status(StatusCode::OK)
.header("DAV", "1,2")
.header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD")
.header("Accept-Ranges", "bytes")
.body(Body::empty())
.expect("valid OPTIONS response")
}
fn method_not_allowed() -> Response {
Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.expect("valid response")
}
fn not_found_response() -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.expect("valid response")
}
fn unauthorized_response() -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(
header::WWW_AUTHENTICATE,
format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""),
)
.body(Body::empty())
.expect("valid response")
}
fn multi_status() -> StatusCode {
StatusCode::from_u16(207).expect("valid multi-status")
}
fn parse_depth(headers: &HeaderMap) -> Result<u8, Response> {
match headers.get("Depth") {
None => Ok(1),
Some(value) => match value.to_str() {
Ok("0") => Ok(0),
Ok("1") => Ok(1),
Ok("infinity") => Err(Response::builder()
.status(StatusCode::FORBIDDEN)
.body(Body::empty())
.expect("valid response")),
_ => Err(Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::empty())
.expect("valid response")),
},
}
}
fn parse_segments(path: &str) -> AppResult<Vec<String>> {
if path.trim_matches('/').is_empty() {
return Ok(vec![]);
}
let segments = path
.split('/')
.filter(|segment| !segment.is_empty())
.map(|segment| {
percent_decode_str(segment)
.decode_utf8()
.map(|cow| cow.into_owned())
.map_err(|_| AppError::bad_request("invalid UTF-8 in path"))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(segments)
}
fn fetch_folder_contents(
state: &AppState,
folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> {
let mut conn = state.db()?;
let folder = match folder_id {
Some(id) => Some(folders_dsl::folders.find(id).first::<Folder>(&mut conn)?),
None => None,
};
let subfolders: Vec<Folder> = match folder_id {
Some(id) => folders_dsl::folders
.filter(folders_dsl::parent_id.eq(Some(id)))
.order(folders_dsl::name.asc())
.load(&mut conn)?,
None => folders_dsl::folders
.filter(folders_dsl::parent_id.is_null())
.order(folders_dsl::name.asc())
.load(&mut conn)?,
};
let mut docs_query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null())
.into_boxed();
docs_query = match folder_id {
Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))),
None => docs_query.filter(documents_dsl::folder_id.is_null()),
};
let documents: Vec<Document> = docs_query
.order(documents_dsl::uploaded_at.desc())
.load(&mut conn)?;
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
Vec::new()
} else {
document_versions_dsl::document_versions
.filter(document_versions_dsl::id.eq_any(&version_ids))
.load(&mut conn)?
};
let mut version_map = versions
.into_iter()
.map(|version| (version.id, version))
.collect::<std::collections::HashMap<_, _>>();
let mut entries = Vec::with_capacity(documents.len());
for document in documents {
if let Some(version) = version_map.remove(&document.current_version_id) {
entries.push(DocumentEntry { document, version });
}
}
Ok(WebDavFolderContents {
_folder: folder,
subfolders,
documents: entries,
})
}
async fn stream_document(
state: &AppState,
document: &Document,
version: &DocumentVersion,
_chain: &[String],
headers: HeaderMap,
method: Method,
) -> Result<Response, AppError> {
let range_header = headers.get(header::RANGE).cloned();
let url = state
.storage
.presign_get_object(
&version.s3_key,
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
)
.await
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?;
let client = reqwest::Client::new();
let mut request = client.request(method.clone(), url.clone());
if let Some(range) = range_header.clone() {
request = request.header(header::RANGE, range.clone());
}
let upstream = request
.send()
.await
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?;
let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
return Err(AppError::internal(format!(
"upstream download returned status {status}"
)));
}
let mut builder = Response::builder().status(status);
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
builder = builder.header(header::CONTENT_TYPE, content_type);
} else if let Some(ref typ) = document.content_type {
builder = builder.header(header::CONTENT_TYPE, typ);
}
if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) {
builder = builder.header(header::CONTENT_LENGTH, content_length);
}
if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) {
builder = builder.header(header::CONTENT_RANGE, range);
}
builder = builder.header("Accept-Ranges", "bytes");
if let Some(disposition) = content_disposition(&document.filename) {
builder = builder.header(header::CONTENT_DISPOSITION, disposition);
}
builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
if method == Method::HEAD {
return builder
.body(Body::empty())
.map_err(|err| AppError::internal(format!("failed to build response: {err}")));
}
let stream = upstream
.bytes_stream()
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let body = Body::from_stream(stream);
builder
.body(body)
.map_err(|err| AppError::internal(format!("failed to build response: {err}")))
}
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavUser>, AppError> {
tracing::debug!("webdav authenticate invoked");
let authorization = match headers.get(header::AUTHORIZATION) {
Some(value) => match value.to_str() {
Ok(header) if header.starts_with("Basic ") => {
tracing::debug!("authorization header present");
&header[6..]
}
Ok(other) => {
tracing::warn!(header = %other, "non-basic authorization header");
return Ok(None);
}
Err(err) => {
tracing::warn!(error = %err, "invalid authorization header");
return Ok(None);
}
},
None => {
tracing::debug!("no authorization header");
return Ok(None);
}
};
let decoded = match BASE64.decode(authorization) {
Ok(bytes) => bytes,
Err(err) => {
tracing::warn!(error = %err, "failed to decode basic credentials");
return Ok(None);
}
};
let credential_str = match String::from_utf8(decoded) {
Ok(value) => value,
Err(err) => {
tracing::warn!(error = %err, "invalid utf-8 basic credentials");
return Ok(None);
}
};
let (username, password) = match credential_str.split_once(':') {
Some((username, password)) if !username.is_empty() => (username, password),
_ => return Ok(None),
};
tracing::debug!(%username, "attempting webdav login");
let mut conn = state.db()?;
let user: User = match users_dsl::users
.filter(users_dsl::username.eq(username))
.first(&mut conn)
{
Ok(user) => user,
Err(diesel::result::Error::NotFound) => {
tracing::warn!(%username, "webdav user not found");
return Ok(None);
}
Err(err) => return Err(AppError::from(err)),
};
let valid = password::verify_password(password, &user.password_hash)
.map_err(|_| AppError::internal("failed to verify password"))?;
if !valid {
tracing::warn!(%username, "webdav password invalid");
return Ok(None);
}
tracing::debug!(%username, "webdav login success");
Ok(Some(WebDavUser {
_user_id: user.id,
_username: user.username,
}))
}
fn build_resources_for_folder(
folder: Option<&Folder>,
chain: &[String],
contents: &WebDavFolderContents,
depth: u8,
) -> Vec<DavResource> {
let mut resources = Vec::new();
let display_name = folder
.map(|folder| folder.name.clone())
.unwrap_or_else(|| "/".to_string());
let href = build_href(chain, true);
let last_modified = folder.map(|folder| format_http_date(folder.updated_at));
resources.push(DavResource {
href,
display_name,
is_collection: true,
content_length: None,
content_type: None,
last_modified,
});
if depth == 0 {
return resources;
}
for subfolder in &contents.subfolders {
let mut child_chain = chain.to_vec();
child_chain.push(subfolder.name.clone());
resources.push(DavResource {
href: build_href(&child_chain, true),
display_name: subfolder.name.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: Some(format_http_date(subfolder.updated_at)),
});
}
for entry in &contents.documents {
let mut child_chain = chain.to_vec();
child_chain.push(entry.document.filename.clone());
resources.push(document_to_resource(
&child_chain,
&entry.document,
&entry.version,
));
}
resources
}
fn build_resources_for_document(
chain: &[String],
document: &Document,
version: &DocumentVersion,
) -> Vec<DavResource> {
vec![document_to_resource(chain, document, version)]
}
fn document_to_resource(
chain: &[String],
document: &Document,
version: &DocumentVersion,
) -> DavResource {
let href = build_href(chain, false);
DavResource {
href,
display_name: document.title.clone(),
is_collection: false,
content_length: Some(version.size_bytes),
content_type: document.content_type.clone(),
last_modified: Some(format_http_date(document.updated_at)),
}
}
fn build_href(names: &[String], is_collection: bool) -> String {
if names.is_empty() {
return "/".to_string();
}
let encoded = names
.iter()
.map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string())
.collect::<Vec<_>>();
let mut path = format!("/{}", encoded.join("/"));
if is_collection && !path.ends_with('/') {
path.push('/');
}
path
}
fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::Error> {
let mut writer = Writer::new(Vec::new());
writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?;
let mut multistatus = BytesStart::new("D:multistatus");
multistatus.push_attribute(("xmlns:D", "DAV:"));
writer.write_event(Event::Start(multistatus))?;
for resource in resources {
writer.write_event(Event::Start(BytesStart::new("D:response")))?;
writer.write_event(Event::Start(BytesStart::new("D:href")))?;
writer.write_event(Event::Text(BytesText::new(&resource.href)))?;
writer.write_event(Event::End(BytesEnd::new("D:href")))?;
writer.write_event(Event::Start(BytesStart::new("D:propstat")))?;
writer.write_event(Event::Start(BytesStart::new("D:prop")))?;
writer.write_event(Event::Start(BytesStart::new("D:displayname")))?;
writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?;
writer.write_event(Event::End(BytesEnd::new("D:displayname")))?;
writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?;
if resource.is_collection {
writer.write_event(Event::Empty(BytesStart::new("D:collection")))?;
}
writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?;
if let Some(length) = resource.content_length {
writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?;
writer.write_event(Event::Text(BytesText::new(&length.to_string())))?;
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
}
if let Some(content_type) = &resource.content_type {
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
writer.write_event(Event::Text(BytesText::new(content_type)))?;
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
}
if let Some(last_modified) = &resource.last_modified {
writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?;
writer.write_event(Event::Text(BytesText::new(last_modified)))?;
writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?;
}
writer.write_event(Event::End(BytesEnd::new("D:prop")))?;
writer.write_event(Event::Start(BytesStart::new("D:status")))?;
writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?;
writer.write_event(Event::End(BytesEnd::new("D:status")))?;
writer.write_event(Event::End(BytesEnd::new("D:propstat")))?;
writer.write_event(Event::End(BytesEnd::new("D:response")))?;
}
writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?;
Ok(writer.into_inner())
}
fn format_http_date(value: chrono::NaiveDateTime) -> String {
let datetime = chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(value, chrono::Utc);
datetime.format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
fn content_disposition(filename: &str) -> Option<String> {
if filename.is_empty() {
return None;
}
let sanitized: String = filename
.chars()
.map(|ch| match ch {
'"' | '\\' => '_',
_ => ch,
})
.collect();
let encoded =
percent_encoding::utf8_percent_encode(&sanitized, percent_encoding::NON_ALPHANUMERIC);
Some(format!(
"inline; filename=\"{}\"; filename*=UTF-8''{}",
sanitized, encoded
))
}
struct WebDavFolderContents {
_folder: Option<Folder>,
subfolders: Vec<Folder>,
documents: Vec<DocumentEntry>,
}
struct DocumentEntry {
document: Document,
version: DocumentVersion,
}
struct DavResource {
href: String,
display_name: String,
is_collection: bool,
content_length: Option<i64>,
content_type: Option<String>,
last_modified: Option<String>,
}
enum ResolvedPath {
Root,
Folder {
folder: Folder,
chain: Vec<String>,
},
Document {
document: Document,
version: DocumentVersion,
chain: Vec<String>,
},
}
fn resolve_path(state: &AppState, segments: &[String]) -> AppResult<Option<ResolvedPath>> {
if segments.is_empty() {
return Ok(Some(ResolvedPath::Root));
}
let mut conn = state.db()?;
let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None;
for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1;
match find_folder_by_name(&mut conn, parent_id, segment)? {
Some(folder) => {
if is_last {
chain.push(folder.name.clone());
return Ok(Some(ResolvedPath::Folder { folder, chain }));
}
parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder);
continue;
}
None => {}
}
if is_last {
if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)?
{
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = folders_dsl::folders
.find(uuid)
.first::<Folder>(&mut conn)
.optional()?
{
if folder.parent_id != parent_id {
return Ok(None);
}
if !is_last {
parent_id = Some(folder.id);
chain.push(folder.name.clone());
current_folder = Some(folder);
continue;
} else {
chain.push(folder.name.clone());
return Ok(Some(ResolvedPath::Folder { folder, chain }));
}
}
if let Some((document, version)) = find_document_by_id(&mut conn, uuid)? {
if document.folder_id != parent_id {
return Ok(None);
}
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
if is_last {
if let Some((document, version)) =
find_document_by_filename(&mut conn, parent_id, segment)?
{
chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document {
document,
version,
chain,
}));
}
}
return Ok(None);
}
Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain }))
}
fn find_folder_by_name(
conn: &mut PgConnection,
parent_id: Option<Uuid>,
name: &str,
) -> AppResult<Option<Folder>> {
let result = match parent_id {
Some(parent) => folders_dsl::folders
.filter(folders_dsl::parent_id.eq(Some(parent)))
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn)
.optional()?,
None => folders_dsl::folders
.filter(folders_dsl::parent_id.is_null())
.filter(folders_dsl::name.eq(name))
.first::<Folder>(conn)
.optional()?,
};
Ok(result)
}
fn find_document_by_filename(
conn: &mut PgConnection,
parent_id: Option<Uuid>,
filename: &str,
) -> AppResult<Option<(Document, DocumentVersion)>> {
let mut query = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null())
.filter(documents_dsl::filename.eq(filename))
.into_boxed();
query = match parent_id {
Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))),
None => query.filter(documents_dsl::folder_id.is_null()),
};
if let Some(document) = query.first::<Document>(conn).optional()? {
let version = document_versions_dsl::document_versions
.find(document.current_version_id)
.first::<DocumentVersion>(conn)?;
return Ok(Some((document, version)));
}
Ok(None)
}
fn find_document_by_id(
conn: &mut PgConnection,
document_id: Uuid,
) -> AppResult<Option<(Document, DocumentVersion)>> {
if let Some(document) = documents_dsl::documents
.filter(documents_dsl::deleted_at.is_null())
.find(document_id)
.first::<Document>(conn)
.optional()?
{
let version = document_versions_dsl::document_versions
.find(document.current_version_id)
.first::<DocumentVersion>(conn)?;
return Ok(Some((document, version)));
}
Ok(None)
}
+76 -6
View File
@@ -1,5 +1,39 @@
// @generated automatically by Diesel CLI.
diesel::table! {
correspondents (id) {
id -> Uuid,
#[max_length = 255]
name -> Varchar,
metadata -> Jsonb,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
document_assets (id) {
id -> Uuid,
document_version_id -> Uuid,
asset_type -> Text,
s3_key -> Text,
mime_type -> Text,
metadata -> Jsonb,
created_at -> Timestamptz,
}
}
diesel::table! {
document_correspondents (document_id, correspondent_id, role) {
document_id -> Uuid,
correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>,
}
}
diesel::table! {
document_tags (document_id, tag_id) {
document_id -> Uuid,
@@ -16,13 +50,12 @@ diesel::table! {
version_number -> Int4,
#[max_length = 500]
s3_key -> Varchar,
#[max_length = 100]
s3_bucket -> Varchar,
size_bytes -> Int8,
#[max_length = 64]
checksum -> Varchar,
created_at -> Timestamptz,
operations_summary -> Jsonb,
metadata -> Jsonb,
}
}
@@ -36,11 +69,14 @@ diesel::table! {
#[max_length = 100]
content_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>,
current_version -> Int4,
uploaded_at -> Timestamptz,
updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb,
issued_at -> Nullable<Timestamptz>,
#[max_length = 255]
title -> Varchar,
current_version_id -> Uuid,
}
}
@@ -50,8 +86,33 @@ diesel::table! {
#[max_length = 255]
name -> Varchar,
parent_id -> Nullable<Uuid>,
#[max_length = 1000]
path_cache -> Nullable<Varchar>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
jobs (id) {
id -> Uuid,
job_type -> Text,
payload -> Jsonb,
status -> Text,
attempts -> Int4,
run_after -> Timestamptz,
last_error -> Nullable<Text>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
}
diesel::table! {
refresh_tokens (id) {
id -> Uuid,
user_id -> Uuid,
token_hash -> Text,
issued_at -> Timestamptz,
expires_at -> Timestamptz,
revoked_at -> Nullable<Timestamptz>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
}
@@ -82,17 +143,26 @@ diesel::table! {
}
}
diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
diesel::joinable!(document_correspondents -> documents (document_id));
diesel::joinable!(document_correspondents -> users (assigned_by));
diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_versions -> documents (document_id));
diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(refresh_tokens -> users (user_id));
diesel::allow_tables_to_appear_in_same_query!(
correspondents,
document_assets,
document_correspondents,
document_tags,
document_versions,
documents,
folders,
jobs,
refresh_tokens,
tags,
users,
);
+9 -4
View File
@@ -1,6 +1,5 @@
use std::sync::Arc;
use aws_sdk_s3::Client as S3Client;
use diesel::{
pg::PgConnection,
r2d2::{ConnectionManager, PooledConnection},
@@ -11,6 +10,7 @@ use crate::{
config::AppConfig,
db::PgPool,
error::{AppError, AppResult},
storage::ObjectStorage,
};
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
@@ -19,16 +19,21 @@ type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
pub struct AppState {
pub pool: PgPool,
pub config: Arc<AppConfig>,
pub s3: S3Client,
pub storage: Arc<dyn ObjectStorage>,
pub jwt: JwtService,
}
impl AppState {
pub fn new(pool: PgPool, config: AppConfig, s3: S3Client, jwt: JwtService) -> Self {
pub fn new(
pool: PgPool,
config: AppConfig,
storage: Arc<dyn ObjectStorage>,
jwt: JwtService,
) -> Self {
Self {
pool,
config: Arc::new(config),
s3,
storage,
jwt,
}
}
+121
View File
@@ -0,0 +1,121 @@
use std::time::Duration;
use anyhow::{Context, Result};
use async_trait::async_trait;
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client;
#[async_trait]
pub trait ObjectStorage: Send + Sync + 'static {
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()>;
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>;
async fn get_object(&self, key: &str) -> Result<Vec<u8>>;
async fn delete_object(&self, key: &str) -> Result<()>;
}
pub struct S3Storage {
client: S3Client,
bucket: String,
}
impl S3Storage {
pub fn new(client: S3Client, bucket: impl Into<String>) -> Self {
Self {
client,
bucket: bucket.into(),
}
}
}
#[async_trait]
impl ObjectStorage for S3Storage {
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let mut request = self
.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(bytes));
if let Some(content_type) = content_type {
request = request.content_type(content_type);
}
if let Some(content_disposition) = content_disposition {
request = request.content_disposition(content_disposition);
}
request
.send()
.await
.context("failed to upload object to S3")?;
Ok(())
}
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
let presign_config = PresigningConfig::builder()
.expires_in(expires_in)
.build()
.context("failed to build S3 presigning config")?;
let presigned = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.presigned(presign_config)
.await
.context("failed to generate presigned download URL")?;
Ok(presigned.uri().to_string())
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let response = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.context("failed to download object from S3")?;
let bytes = response
.body
.collect()
.await
.context("failed to read object stream")?
.into_bytes()
.to_vec();
Ok(bytes)
}
async fn delete_object(&self, key: &str) -> Result<()> {
self.client
.delete_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.context("failed to delete object from S3")?;
Ok(())
}
}
+16
View File
@@ -0,0 +1,16 @@
use serde_json::Value;
pub enum NullableValue {
Omitted,
Null,
String(String),
}
pub fn classify_nullable(optional_value: Option<&Value>) -> Result<NullableValue, String> {
match optional_value {
None => Ok(NullableValue::Omitted),
Some(Value::Null) => Ok(NullableValue::Null),
Some(Value::String(s)) => Ok(NullableValue::String(s.to_owned())),
Some(other) => Err(format!("expected string or null, got {other}")),
}
}
+1
View File
@@ -0,0 +1 @@
pub mod json;
+203
View File
@@ -0,0 +1,203 @@
use std::{collections::HashSet, sync::Arc, time::Duration};
use async_trait::async_trait;
use diesel::prelude::*;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use tokio::task;
use tracing::{error, warn};
use uuid::Uuid;
use super::ocr::{document_is_pdf, OCR_TEXT_ASSET_TYPE};
use crate::{
jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_GENERATE_OCR_TEXT, JOB_GENERATE_THUMBNAILS},
models::{Document, DocumentAsset, DocumentVersion},
schema::{document_assets, document_versions, documents},
state::AppState,
};
use super::{JobExecution, JobHandler};
#[derive(Debug, Deserialize)]
struct AnalyzePayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
pub struct AnalyzeDocumentJob;
impl AnalyzeDocumentJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for AnalyzeDocumentJob {
fn job_type(&self) -> &'static str {
JOB_ANALYZE_DOCUMENT
}
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid analyze payload: {err}"),
}
}
};
let state_clone = state.clone();
match task::spawn_blocking(move || analyze_document(state_clone, payload)).await {
Ok(Ok(execution)) => execution,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "analyze job will retry");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
}
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "analyze task panicked");
JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
}
}
}
}
}
fn analyze_document(state: Arc<AppState>, payload: AnalyzePayload) -> Result<JobExecution, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let (supported, reason) = determine_thumbnail_support(&document);
let ocr_supported = document_is_pdf(&document);
let existing_ocr: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let skip_ocr = existing_ocr.is_some() && !payload.force;
let mut summary_map = match version.operations_summary {
Value::Object(map) => map,
_ => Map::new(),
};
summary_map.insert("thumbnail_supported".to_string(), Value::Bool(supported));
if let Some(reason) = reason {
summary_map.insert("thumbnail_reason".to_string(), Value::String(reason));
} else {
summary_map.remove("thumbnail_reason");
}
summary_map.insert("ocr_supported".to_string(), Value::Bool(ocr_supported));
if ocr_supported {
summary_map.remove("ocr_reason");
} else {
summary_map.insert(
"ocr_reason".to_string(),
Value::String("document is not a PDF".into()),
);
}
diesel::update(document_versions::table.find(version.id))
.set(document_versions::operations_summary.eq(Value::Object(summary_map)))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if supported {
let enqueue_result = enqueue_job(
&mut conn,
JOB_GENERATE_THUMBNAILS,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
"force": payload.force,
}),
None,
);
if let Err(err) = enqueue_result {
return Err(err.to_string());
}
}
if ocr_supported && !skip_ocr {
let enqueue_result = enqueue_job(
&mut conn,
JOB_GENERATE_OCR_TEXT,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
"force": payload.force,
}),
None,
);
if let Err(err) = enqueue_result {
return Err(err.to_string());
}
}
Ok(JobExecution::Success)
}
pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option<String>) {
let supported_mimes: HashSet<&'static str> = [
"image/jpeg",
"image/png",
"image/gif",
"image/tiff",
"image/bmp",
"image/webp",
"application/pdf",
]
.into_iter()
.collect();
if let Some(ref content_type) = document.content_type {
if supported_mimes.contains(content_type.as_str()) {
return (true, None);
}
}
if let Some(ext) = document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.to_ascii_lowercase())
{
let supported_exts = [
"jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf",
];
if supported_exts.contains(&ext.as_str()) {
return (true, None);
}
}
(
false,
Some("content type not supported for thumbnails".into()),
)
}
+204
View File
@@ -0,0 +1,204 @@
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use diesel::prelude::*;
use reqwest::Client;
use serde::Deserialize;
use serde_json::json;
use tokio::task;
use tracing::{error, warn};
use uuid::Uuid;
use crate::{
jobs::JOB_INDEX_DOCUMENT_TEXT,
models::{Document, DocumentAsset, DocumentVersion},
schema::{document_assets, document_versions, documents},
state::AppState,
};
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler};
#[derive(Debug, Deserialize)]
struct IndexPayload {
document_id: Uuid,
document_version_id: Uuid,
}
pub struct IndexDocumentTextJob;
impl IndexDocumentTextJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for IndexDocumentTextJob {
fn job_type(&self) -> &'static str {
JOB_INDEX_DOCUMENT_TEXT
}
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
let payload: IndexPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid index payload: {err}"),
}
}
};
let quickwit_endpoint = match &state.config.quickwit_endpoint {
Some(endpoint) => endpoint.clone(),
None => {
warn!("quickwit endpoint missing; skipping indexing");
return JobExecution::Success;
}
};
let quickwit_index = match &state.config.quickwit_index {
Some(index) => index.clone(),
None => {
warn!("quickwit index missing; skipping indexing");
return JobExecution::Success;
}
};
let client = Client::new();
let state_clone = state.clone();
let context = match task::spawn_blocking(move || load_context(state_clone, &payload)).await
{
Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "index job will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "index task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
if context.text_asset.is_none() {
warn!(job_id = %job.id, "missing OCR text asset; failing indexing job");
return JobExecution::Failed {
error: "missing OCR text asset".into(),
};
}
let asset = context.text_asset.unwrap();
let text = match state.storage.get_object(&asset.s3_key).await {
Ok(bytes) => match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => {
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
return JobExecution::Failed {
error: "ocr text not valid UTF-8".into(),
};
}
},
Err(err) => {
warn!(job_id = %job.id, error = %err, "failed to download ocr text");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
};
if text.trim().is_empty() {
warn!(job_id = %job.id, "ocr text empty; skipping");
return JobExecution::Failed {
error: "ocr text empty".into(),
};
}
let client = client;
let url = format!(
"{}/api/v1/{}/ingest?commit=auto",
quickwit_endpoint, quickwit_index
);
let payload = json!({
"document_id": context.document.id,
"version_id": context.version.id,
"title": context.document.title.to_lowercase(),
"text": text.to_lowercase()
});
let body = serde_json::to_string(&payload).unwrap();
match client
.post(&url)
.header("content-type", "application/x-ndjson")
.body(format!("{}\n", body))
.send()
.await
{
Ok(response) => {
if response.status().is_success() {
JobExecution::Success
} else {
let status = response.status();
let body = response.text().await.unwrap_or_default();
warn!(job_id = %job.id, %status, %body, "quickwit ingest failed");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("quickwit ingest failed with status {status}"),
}
}
}
Err(err) => {
warn!(job_id = %job.id, error = %err, "quickwit request failed");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
}
}
}
}
}
struct IndexContext {
document: Document,
version: DocumentVersion,
text_asset: Option<DocumentAsset>,
}
fn load_context(state: Arc<AppState>, payload: &IndexPayload) -> Result<IndexContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let text_asset: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
Ok(IndexContext {
document,
version,
text_asset,
})
}
+136
View File
@@ -0,0 +1,136 @@
use std::{collections::HashMap, sync::Arc, time::Duration};
use async_trait::async_trait;
use tokio::time::sleep;
use tracing::{error, info, warn};
use crate::{
jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError},
models::Job,
state::AppState,
};
pub mod analyze;
pub mod index;
pub mod ocr;
pub mod thumbnails;
#[derive(Debug)]
pub enum JobExecution {
Success,
Retry { delay: Duration, error: String },
Failed { error: String },
}
#[async_trait]
pub trait JobHandler: Send + Sync {
fn job_type(&self) -> &'static str;
async fn handle(&self, state: Arc<AppState>, job: Job) -> JobExecution;
}
pub struct Worker {
state: Arc<AppState>,
handlers: HashMap<&'static str, Arc<dyn JobHandler>>,
poll_interval: Duration,
}
impl Worker {
pub fn new(
state: Arc<AppState>,
handlers: Vec<Arc<dyn JobHandler>>,
poll_interval: Duration,
) -> Self {
let map = handlers
.into_iter()
.map(|handler| (handler.job_type(), handler))
.collect();
Self {
state,
handlers: map,
poll_interval,
}
}
pub async fn run(&self) {
info!("worker started");
loop {
match self.tick().await {
Ok(true) => {}
Ok(false) => sleep(self.poll_interval).await,
Err(err) => {
error!(error = %err, "worker tick failed");
sleep(self.poll_interval).await;
}
}
}
}
async fn tick(&self) -> Result<bool, JobQueueError> {
let job_types: Vec<&str> = self.handlers.keys().copied().collect();
if job_types.is_empty() {
return Ok(false);
}
let mut conn = match self.state.db() {
Ok(conn) => conn,
Err(err) => {
error!(?err, "failed to obtain database connection in worker");
return Ok(false);
}
};
let job_opt = reserve_job(&mut conn, &job_types)?;
drop(conn);
if let Some(job) = job_opt {
if let Some(handler) = self.handlers.get(job.job_type.as_str()) {
let result = handler.handle(self.state.clone(), job.clone()).await;
match result {
JobExecution::Success => {
if let Ok(mut conn) = self.state.db() {
mark_job_succeeded(&mut conn, job.id)?;
info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully");
} else {
error!("failed to mark job succeeded due to pool error");
}
}
JobExecution::Retry { delay, error } => {
warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry");
if let Ok(mut conn) = self.state.db() {
retry_job_after(&mut conn, job.id, delay, &error)?;
} else {
error!("failed to requeue job for retry due to pool error");
}
}
JobExecution::Failed { error } => {
error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed");
if let Ok(mut conn) = self.state.db() {
mark_job_failed(&mut conn, job.id, &error)?;
} else {
error!("failed to mark job failed due to pool error");
}
}
}
} else {
error!(job_type = %job.job_type, "no handler registered for job type");
if let Ok(mut conn) = self.state.db() {
mark_job_failed(&mut conn, job.id, "no handler registered")?;
} else {
error!("failed to mark job failed for missing handler due to pool error");
}
}
Ok(true)
} else {
Ok(false)
}
}
}
pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
vec![
Arc::new(analyze::AnalyzeDocumentJob::new()),
Arc::new(thumbnails::GenerateThumbnailsJob::new()),
Arc::new(ocr::GenerateOcrTextJob::new()),
Arc::new(index::IndexDocumentTextJob::new()),
]
}
+438
View File
@@ -0,0 +1,438 @@
use std::{
fmt, fs,
io::{ErrorKind, Write},
process::Command,
sync::Arc,
time::Duration,
};
use async_trait::async_trait;
use chrono::Utc;
use diesel::{pg::upsert::excluded, prelude::*};
use pdfium_render::prelude::*;
use serde::Deserialize;
use serde_json::json;
use tempfile::NamedTempFile;
use tokio::task;
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::{
jobs::{enqueue_job, JOB_GENERATE_OCR_TEXT, JOB_INDEX_DOCUMENT_TEXT},
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
schema::{document_assets, document_versions, documents},
state::AppState,
};
use super::{JobExecution, JobHandler};
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
const MIN_TEXT_LENGTH: usize = 50;
#[derive(Clone, Debug, Deserialize)]
struct OcrPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
pub struct GenerateOcrTextJob;
impl GenerateOcrTextJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for GenerateOcrTextJob {
fn job_type(&self) -> &'static str {
JOB_GENERATE_OCR_TEXT
}
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
let payload: OcrPayload = match serde_json::from_value(job.payload.clone()) {
Ok(payload) => payload,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid OCR payload: {err}"),
}
}
};
let state_clone = state.clone();
let payload_clone = payload.clone();
let context =
match task::spawn_blocking(move || load_ocr_context(state_clone, &payload_clone)).await
{
Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "ocr job will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "ocr task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
if context.skip {
info!(job_id = %job.id, "ocr already present; skipping");
return JobExecution::Success;
}
let bytes = match state.storage.get_object(&context.version.s3_key).await {
Ok(bytes) => bytes,
Err(err) => {
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
};
let doc_meta = PdfDocumentMeta {
content_type: context.document.content_type.clone(),
original_name: context.document.original_name.clone(),
};
let generation =
match task::spawn_blocking(move || generate_ocr_text(&doc_meta, &bytes)).await {
Ok(result) => result,
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "ocr text task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
let Some(generation) = generation else {
warn!(job_id = %job.id, "no text extracted from document; failing job");
return JobExecution::Failed {
error: "no text extracted and OCR unavailable".into(),
};
};
let asset_id = context
.existing_asset
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
let s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
context.document.id, context.version.version_number, OCR_TEXT_ASSET_TYPE, asset_id
);
if let Err(err) = state
.storage
.put_object(
&s3_key,
generation.text.into_bytes(),
Some("text/plain".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, "failed to upload ocr text");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
let state_clone = state.clone();
match task::spawn_blocking(move || {
persist_ocr_metadata(state_clone, &context, asset_id, &s3_key, generation.source)
})
.await
{
Ok(Ok(())) => {
if state.config.quickwit_endpoint.is_some() && state.config.quickwit_index.is_some()
{
if let Err(err) = enqueue_index_job(&state, &payload) {
warn!(job_id = %job.id, error = %err, "failed to enqueue index job");
}
}
JobExecution::Success
}
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "failed to persist ocr metadata");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
}
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "ocr metadata task panicked");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("metadata update panic: {join_err}"),
}
}
}
}
}
struct PdfDocumentMeta {
content_type: Option<String>,
original_name: String,
}
struct OcrContext {
document: Document,
version: DocumentVersion,
existing_asset: Option<DocumentAsset>,
skip: bool,
}
struct OcrGeneration {
text: String,
source: &'static str,
}
fn load_ocr_context(state: Arc<AppState>, payload: &OcrPayload) -> Result<OcrContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing: Option<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq(OCR_TEXT_ASSET_TYPE))
.first(&mut conn)
.optional()
.map_err(|err| format!("{err:?}"))?;
let is_pdf = document_is_pdf(&document);
if !is_pdf {
return Ok(OcrContext {
document,
version,
existing_asset: existing,
skip: true,
});
}
let skip = existing.is_some() && !payload.force;
Ok(OcrContext {
document,
version,
existing_asset: existing,
skip,
})
}
fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option<OcrGeneration> {
if !document_meta_is_pdf(meta) {
return None;
}
if let Ok(text) = extract_pdf_text(bytes) {
if text.trim().chars().count() >= MIN_TEXT_LENGTH {
return Some(OcrGeneration {
text,
source: "pdf-text",
});
}
}
match run_ocr(bytes) {
Ok(Some(text)) => Some(OcrGeneration {
text,
source: "ocr",
}),
Ok(None) => None,
Err(OcrError::BinaryMissing) => {
warn!("ocrmypdf not installed; cannot perform OCR");
None
}
Err(err) => {
warn!(error = ?err, "ocr command failed");
None
}
}
}
fn extract_pdf_text(bytes: &[u8]) -> Result<String, String> {
let pdfium = Pdfium::default();
let document = pdfium
.load_pdf_from_byte_slice(bytes, None)
.map_err(|err| format!("load pdf: {err}"))?;
let mut combined = String::new();
let pages = document.pages();
for page_index in 0..pages.len() {
let page = pages
.get(page_index)
.map_err(|err| format!("load page {page_index}: {err}"))?;
if let Ok(page_text) = page.text() {
for segment in page_text.segments().iter() {
combined.push_str(&segment.text());
combined.push('\n');
}
};
}
Ok(combined)
}
#[derive(Debug)]
enum OcrError {
BinaryMissing,
Failed(String),
}
impl fmt::Display for OcrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OcrError::BinaryMissing => write!(f, "ocrmypdf binary not found"),
OcrError::Failed(msg) => write!(f, "ocr failed: {msg}"),
}
}
}
fn run_ocr(bytes: &[u8]) -> Result<Option<String>, OcrError> {
let mut input = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
input
.write_all(bytes)
.map_err(|err| OcrError::Failed(err.to_string()))?;
input
.flush()
.map_err(|err| OcrError::Failed(err.to_string()))?;
let output_pdf = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
let sidecar = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?;
let status = Command::new("ocrmypdf")
.arg("--sidecar")
.arg(sidecar.path())
.arg("--skip-text")
.arg(input.path())
.arg(output_pdf.path())
.output();
match status {
Ok(output) => {
if !output.status.success() {
return Err(OcrError::Failed(format!(
"ocrmypdf failed: exit={} stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
)));
}
let text = fs::read_to_string(sidecar.path())
.map_err(|err| OcrError::Failed(err.to_string()))?;
if text.trim().chars().count() >= MIN_TEXT_LENGTH {
Ok(Some(text))
} else {
Ok(None)
}
}
Err(err) => {
if err.kind() == ErrorKind::NotFound {
Err(OcrError::BinaryMissing)
} else {
Err(OcrError::Failed(err.to_string()))
}
}
}
}
fn persist_ocr_metadata(
state: Arc<AppState>,
context: &OcrContext,
asset_id: Uuid,
s3_key: &str,
source: &'static str,
) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let new_asset = NewDocumentAsset {
id: asset_id,
document_version_id: context.version.id,
asset_type: OCR_TEXT_ASSET_TYPE.to_string(),
s3_key: s3_key.to_string(),
mime_type: "text/plain".to_string(),
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"source": source,
}),
};
diesel::insert_into(document_assets::table)
.values(&new_asset)
.on_conflict((
document_assets::document_version_id,
document_assets::asset_type,
))
.do_update()
.set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
fn enqueue_index_job(state: &AppState, payload: &OcrPayload) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
enqueue_job(
&mut conn,
JOB_INDEX_DOCUMENT_TEXT,
json!({
"document_id": payload.document_id,
"document_version_id": payload.document_version_id,
}),
None,
)
.map(|_| ())
.map_err(|err| err.to_string())
}
pub fn document_is_pdf(document: &Document) -> bool {
document_meta_is_pdf(&PdfDocumentMeta {
content_type: document.content_type.clone(),
original_name: document.original_name.clone(),
})
}
fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool {
if let Some(content_type) = &meta.content_type {
if content_type.eq_ignore_ascii_case("application/pdf") {
return true;
}
}
meta.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
}
+535
View File
@@ -0,0 +1,535 @@
use std::{convert::TryInto, io::Cursor, panic, sync::Arc, time::Duration};
use async_trait::async_trait;
use chrono::Utc;
use diesel::{pg::upsert::excluded, prelude::*};
use image::{GenericImageView, ImageFormat, ImageReader};
use pdfium_render::prelude::*;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use tokio::task;
use tracing::{error, info, warn};
use uuid::Uuid;
use crate::{
jobs::JOB_GENERATE_THUMBNAILS,
models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset},
schema::{document_assets, document_versions, documents},
state::AppState,
};
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler};
const THUMBNAIL_WIDTH: u32 = 512;
const THUMBNAIL_HEIGHT: u32 = 512;
const PREVIEW_WIDTH: u32 = THUMBNAIL_WIDTH * 4;
const PREVIEW_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4;
const THUMBNAIL_ASSET_TYPE: &str = "thumbnail";
const PREVIEW_ASSET_TYPE: &str = "preview";
#[derive(Debug, Deserialize)]
struct ThumbnailPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
pub struct GenerateThumbnailsJob;
impl GenerateThumbnailsJob {
pub fn new() -> Self {
Self
}
}
#[async_trait]
impl JobHandler for GenerateThumbnailsJob {
fn job_type(&self) -> &'static str {
JOB_GENERATE_THUMBNAILS
}
async fn handle(&self, state: Arc<AppState>, job: crate::models::Job) -> JobExecution {
let payload: ThumbnailPayload = match serde_json::from_value(job.payload.clone()) {
Ok(p) => p,
Err(err) => {
return JobExecution::Failed {
error: format!("invalid thumbnail payload: {err}"),
}
}
};
let state_clone = state.clone();
let initial =
match task::spawn_blocking(move || load_thumbnail_context(state_clone, &payload)).await
{
Ok(Ok(ctx)) => ctx,
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "thumbnail job will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "thumbnail task panicked");
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("worker panicked: {join_err}"),
};
}
};
if initial.skip {
info!(job_id = %job.id, "thumbnails already exist; skipping");
return JobExecution::Success;
}
let bytes = match state.storage.get_object(&initial.version.s3_key).await {
Ok(bytes) => bytes,
Err(err) => {
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
};
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
Ok(result) => result,
Err(err) => {
return JobExecution::Failed { error: err };
}
};
if let Some(page_count) = generation.page_count {
let state_clone = state.clone();
let document_id = initial.document.id;
let version_id = initial.version.id;
match task::spawn_blocking(move || {
persist_document_page_count(state_clone, document_id, version_id, page_count)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(
job_id = %job.id,
document_id = %document_id,
version_id = %version_id,
error = %err,
"failed to update document page count metadata; retrying"
);
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(
job_id = %job.id,
document_id = %document_id,
version_id = %version_id,
error = %join_err,
"page count metadata task panicked"
);
return JobExecution::Retry {
delay: Duration::from_secs(60),
error: format!("metadata panic: {join_err}"),
};
}
}
}
let thumbnail_asset_id = initial
.existing_thumbnail
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
let thumbnail_s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
initial.document.id,
initial.version.version_number,
THUMBNAIL_ASSET_TYPE,
thumbnail_asset_id
);
let preview_asset_id = initial
.existing_preview
.as_ref()
.map(|asset| asset.id)
.unwrap_or_else(Uuid::new_v4);
let preview_s3_key = format!(
"documents/{}/v{}/assets/{}/{}",
initial.document.id,
initial.version.version_number,
PREVIEW_ASSET_TYPE,
preview_asset_id
);
if let Err(err) = state
.storage
.put_object(
&preview_s3_key,
generation.preview.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, "failed to upload preview; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
if let Err(err) = state
.storage
.put_object(
&thumbnail_s3_key,
generation.thumbnail.image_bytes.clone(),
Some("image/png".into()),
None,
)
.await
{
warn!(job_id = %job.id, error = %err, "failed to upload thumbnail; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
let state_clone = state.clone();
match task::spawn_blocking(move || {
persist_assets_metadata(
state_clone,
&initial,
&[
AssetPersistence {
asset_type: PREVIEW_ASSET_TYPE,
asset_id: preview_asset_id,
s3_key: &preview_s3_key,
generated: &generation.preview,
},
AssetPersistence {
asset_type: THUMBNAIL_ASSET_TYPE,
asset_id: thumbnail_asset_id,
s3_key: &thumbnail_s3_key,
generated: &generation.thumbnail,
},
],
)
})
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!(job_id = %job.id, error = %err, "failed to persist thumbnail metadata; retrying");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err,
};
}
Err(join_err) => {
error!(job_id = %job.id, error = %join_err, "thumbnail metadata update panicked");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: format!("metadata update panic: {join_err}"),
};
}
}
JobExecution::Success
}
}
struct ThumbnailContext {
document: Document,
version: DocumentVersion,
existing_thumbnail: Option<DocumentAsset>,
existing_preview: Option<DocumentAsset>,
skip: bool,
}
struct GeneratedImage {
image_bytes: Vec<u8>,
width: Option<i32>,
height: Option<i32>,
}
struct GeneratedAssets {
thumbnail: GeneratedImage,
preview: GeneratedImage,
page_count: Option<u32>,
}
struct AssetPersistence<'a> {
asset_type: &'static str,
asset_id: Uuid,
s3_key: &'a str,
generated: &'a GeneratedImage,
}
fn load_thumbnail_context(
state: Arc<AppState>,
payload: &ThumbnailPayload,
) -> Result<ThumbnailContext, String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let version: DocumentVersion = document_versions::table
.find(payload.document_version_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
if version.document_id != payload.document_id {
return Err("document/version mismatch".into());
}
let document: Document = documents::table
.find(payload.document_id)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let existing_assets: Vec<DocumentAsset> = document_assets::table
.filter(document_assets::document_version_id.eq(payload.document_version_id))
.filter(document_assets::asset_type.eq_any(vec![
THUMBNAIL_ASSET_TYPE.to_string(),
PREVIEW_ASSET_TYPE.to_string(),
]))
.load(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let mut existing_thumbnail = None;
let mut existing_preview = None;
for asset in existing_assets {
match asset.asset_type.as_str() {
THUMBNAIL_ASSET_TYPE => existing_thumbnail = Some(asset),
PREVIEW_ASSET_TYPE => existing_preview = Some(asset),
_ => {}
}
}
let (supported, _) = determine_thumbnail_support(&document);
if !supported {
return Err("thumbnail generation not supported for this document".into());
}
let skip = existing_thumbnail.is_some() && existing_preview.is_some() && !payload.force;
Ok(ThumbnailContext {
document,
version,
existing_thumbnail,
existing_preview,
skip,
})
}
fn generate_preview_and_thumbnail(
document: &Document,
bytes: &[u8],
) -> Result<GeneratedAssets, String> {
let is_pdf = document
.content_type
.as_deref()
.map(|mime| mime == "application/pdf")
.unwrap_or_else(|| {
document
.original_name
.rsplit('.')
.next()
.map(|ext| ext.eq_ignore_ascii_case("pdf"))
.unwrap_or(false)
});
if is_pdf {
let pdf_assets = generate_pdf_assets(bytes)?;
Ok(GeneratedAssets {
preview: pdf_assets.preview,
thumbnail: pdf_assets.thumbnail,
page_count: Some(pdf_assets.page_count),
})
} else {
let (preview, thumbnail) = generate_image_assets(bytes)?;
Ok(GeneratedAssets {
preview,
thumbnail,
page_count: None,
})
}
}
fn generate_image_assets(bytes: &[u8]) -> Result<(GeneratedImage, GeneratedImage), String> {
let reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|err| err.to_string())?;
let image = reader.decode().map_err(|err| err.to_string())?;
let preview_image = if image.width() > PREVIEW_WIDTH || image.height() > PREVIEW_HEIGHT {
image.thumbnail(PREVIEW_WIDTH, PREVIEW_HEIGHT)
} else {
image.clone()
};
let thumbnail_image =
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
} else {
preview_image.clone()
};
let preview = encode_dynamic_image(preview_image)?;
let thumbnail = encode_dynamic_image(thumbnail_image)?;
Ok((preview, thumbnail))
}
struct PdfGeneratedAssets {
preview: GeneratedImage,
thumbnail: GeneratedImage,
page_count: u32,
}
fn generate_pdf_assets(bytes: &[u8]) -> Result<PdfGeneratedAssets, String> {
let pdfium = panic::catch_unwind(|| Pdfium::default())
.map_err(|_| "failed to initialize PDFium".to_string())?;
let document = pdfium
.load_pdf_from_byte_slice(bytes, None)
.map_err(|err| format!("load pdf: {err}"))?;
let pages = document.pages();
let total_pages = pages.len();
let page = pages
.get(0)
.map_err(|err| format!("load first page: {err}"))?;
let render_config = PdfRenderConfig::new()
.set_target_width(PREVIEW_WIDTH as i32)
.set_maximum_height(PREVIEW_HEIGHT as i32)
.render_form_data(true)
.rotate_if_landscape(PdfPageRenderRotation::None, true);
let bitmap = page
.render_with_config(&render_config)
.map_err(|err| format!("render pdf page: {err}"))?;
let preview_buffer = bitmap.as_image().to_rgb8();
let preview_image = image::DynamicImage::ImageRgb8(preview_buffer);
let thumbnail_image =
if preview_image.width() > THUMBNAIL_WIDTH || preview_image.height() > THUMBNAIL_HEIGHT {
preview_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT)
} else {
preview_image.clone()
};
let preview = encode_dynamic_image(preview_image)?;
let thumbnail = encode_dynamic_image(thumbnail_image)?;
let page_count: u32 = total_pages
.try_into()
.map_err(|_| "page count exceeds supported range".to_string())?;
Ok(PdfGeneratedAssets {
preview,
thumbnail,
page_count,
})
}
fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, String> {
let (width, height) = image.dimensions();
let mut cursor = Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|err| err.to_string())?;
Ok(GeneratedImage {
image_bytes: cursor.into_inner(),
width: Some(width as i32),
height: Some(height as i32),
})
}
fn persist_assets_metadata(
state: Arc<AppState>,
context: &ThumbnailContext,
assets: &[AssetPersistence<'_>],
) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
for asset in assets {
let new_asset = NewDocumentAsset {
id: asset.asset_id,
document_version_id: context.version.id,
asset_type: asset.asset_type.to_string(),
s3_key: asset.s3_key.to_string(),
mime_type: "image/png".to_string(),
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
"width": asset.generated.width,
"height": asset.generated.height,
}),
};
diesel::insert_into(document_assets::table)
.values(&new_asset)
.on_conflict((
document_assets::document_version_id,
document_assets::asset_type,
))
.do_update()
.set((
document_assets::s3_key.eq(excluded(document_assets::s3_key)),
document_assets::mime_type.eq(excluded(document_assets::mime_type)),
document_assets::metadata.eq(excluded(document_assets::metadata)),
))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
}
Ok(())
}
fn persist_document_page_count(
state: Arc<AppState>,
document_id: Uuid,
document_version_id: Uuid,
page_count: u32,
) -> Result<(), String> {
let mut conn = state.db().map_err(|err| format!("{err:?}"))?;
let existing_metadata: Value = document_versions::table
.filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id))
.select(document_versions::metadata)
.first(&mut conn)
.map_err(|err| format!("{err:?}"))?;
let updated = match existing_metadata {
Value::Object(mut map) => {
map.insert("page_count".to_string(), Value::from(page_count));
Value::Object(map)
}
_ => {
let mut map = Map::new();
map.insert("page_count".to_string(), Value::from(page_count));
Value::Object(map)
}
};
diesel::update(
document_versions::table
.filter(document_versions::id.eq(document_version_id))
.filter(document_versions::document_id.eq(document_id)),
)
.set(document_versions::metadata.eq(updated))
.execute(&mut conn)
.map_err(|err| format!("{err:?}"))?;
Ok(())
}
+34
View File
@@ -0,0 +1,34 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
#[derive(Deserialize)]
struct AuthenticatedUser {
username: String,
role: String,
}
#[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "s3cret";
app.insert_user("alice", password, "admin").await?;
let token = app.login_token("alice", password).await?;
let response = app.get("/api/auth/me", Some(&token)).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
assert_eq!(user.username, "alice");
assert_eq!(user.role, "admin");
app.cleanup().await?;
Ok(())
}
+444
View File
@@ -0,0 +1,444 @@
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use axum::Router;
use backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db::{self, PgPool};
use backend::models::{Job, NewUser};
use backend::routes;
use backend::state::AppState;
use backend::storage::ObjectStorage;
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use rand::rngs::OsRng;
use serde::Serialize;
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use uuid::Uuid;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
#[allow(dead_code)]
#[derive(Clone)]
pub struct StoredObject {
pub key: String,
pub bytes: Vec<u8>,
pub content_type: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Default)]
pub struct FakeStorage {
objects: Mutex<HashMap<String, StoredObject>>,
}
#[async_trait]
impl ObjectStorage for FakeStorage {
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let stored = StoredObject {
key: key.to_string(),
bytes,
content_type,
content_disposition,
};
let mut guard = self.objects.lock().await;
guard.insert(stored.key.clone(), stored);
Ok(())
}
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
let guard = self.objects.lock().await;
ensure!(guard.contains_key(key), "object {key} missing");
Ok(format!(
"https://fake-storage/{key}?expires_in={}",
expires_in.as_secs()
))
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))
}
async fn delete_object(&self, key: &str) -> Result<()> {
let mut guard = self.objects.lock().await;
guard.remove(key);
Ok(())
}
}
impl FakeStorage {
#[allow(dead_code)]
pub async fn get(&self, key: &str) -> Option<StoredObject> {
let guard = self.objects.lock().await;
guard.get(key).cloned()
}
#[allow(dead_code)]
pub async fn object_count(&self) -> usize {
let guard = self.objects.lock().await;
guard.len()
}
}
pub struct TestApp {
pub state: AppState,
router: Router,
storage: Arc<FakeStorage>,
}
impl TestApp {
pub async fn new() -> Result<Self> {
let database_url = env::var("TEST_DATABASE_URL")
.context("TEST_DATABASE_URL must be set for integration tests")?;
let config = AppConfig {
database_url: database_url.clone(),
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "test-secret".to_string(),
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "test-download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
cors_allowed_origin: None,
aws_endpoint_url: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_region: "us-east-1".to_string(),
s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None,
quickwit_index: None,
};
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
prepare_database(&pool).await?;
let storage = Arc::new(FakeStorage::default());
let storage_for_state: Arc<dyn ObjectStorage> = storage.clone();
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone());
Ok(Self {
state,
router,
storage,
})
}
pub async fn cleanup(&self) -> Result<()> {
let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("cleanup task panicked")?
}
#[allow(dead_code)]
pub fn storage(&self) -> Arc<FakeStorage> {
self.storage.clone()
}
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
let username = username.to_string();
let password = password.to_string();
let role = role.to_string();
self.with_conn(move |conn| {
let password_hash = hash_password(&password)?;
let user = NewUser {
id: Uuid::new_v4(),
username,
password_hash,
role,
};
diesel::insert_into(backend::schema::users::table)
.values(&user)
.execute(conn)
.context("failed to insert user")?;
Ok(user.id)
})
.await
}
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
#[derive(Serialize)]
struct LoginPayload<'a> {
username: &'a str,
password: &'a str,
}
let response = self
.post_json(
"/api/auth/login",
&LoginPayload { username, password },
None,
)
.await?;
ensure!(
response.status() == StatusCode::OK,
"login failed with status {}",
response.status()
);
let body = body_to_vec(response.into_body()).await?;
#[derive(serde::Deserialize)]
struct LoginResponse {
access_token: String,
}
let parsed: LoginResponse = serde_json::from_slice(&body)?;
Ok(parsed.access_token)
}
#[allow(dead_code)]
pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| {
use backend::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table)
.execute(conn)
.context("failed to clear jobs")?;
Ok(())
})
.await
}
#[allow(dead_code)]
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string();
self.with_conn(move |conn| {
use backend::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
let rows = jobs_table
.filter(job_type_col.eq(&ty))
.load::<Job>(conn)
.context("failed to load jobs")?;
Ok(rows)
})
.await
}
pub async fn post_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::POST)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
#[allow(dead_code)]
pub async fn patch_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::PATCH)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn get(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let mut builder = Request::builder().method(Method::GET).uri(path);
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
#[allow(dead_code)]
pub async fn delete(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let builder = Request::builder().method(Method::DELETE).uri(path);
let builder = if let Some(token) = token {
builder.header("authorization", format!("Bearer {token}"))
} else {
builder
};
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
#[allow(dead_code)]
pub async fn upload_document(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
token: &str,
) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new();
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
filename
)
.as_bytes(),
);
body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes());
body.extend(data);
body.extend(b"\r\n");
if let Some(folder) = folder_id {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n");
body.extend(folder.to_string().as_bytes());
body.extend(b"\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder()
.method(Method::POST)
.uri(path)
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.header("authorization", format!("Bearer {token}"));
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
async fn with_conn<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get database connection: {err}"))?;
f(&mut conn)
})
.await
.context("connection task panicked")?
}
}
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
DB_LOCK.lock().await
}
pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
let collected = body
.collect()
.await
.map_err(|err| anyhow!("failed to read response body: {err}"))?;
Ok(collected.to_bytes().to_vec())
}
async fn prepare_database(pool: &PgPool) -> Result<()> {
let pool = pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to acquire connection: {err}"))?;
conn.run_pending_migrations(MIGRATIONS)
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("migration task panicked")?
}
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute(
"TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;",
)
.context("failed to truncate tables")?;
Ok(())
}
fn hash_password(password: &str) -> Result<String> {
use argon2::password_hash::{PasswordHasher, SaltString};
use argon2::Argon2;
let salt = SaltString::generate(&mut OsRng);
Ok(Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!("failed to hash password: {err}"))?
.to_string())
}
+975
View File
@@ -0,0 +1,975 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
}
#[derive(Deserialize)]
struct DocumentInfo {
id: Uuid,
title: String,
original_name: String,
deleted_at: Option<String>,
issued_at: Option<String>,
tags: Vec<TagSummary>,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentInfo>,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
#[derive(Deserialize)]
struct DocumentVersion {
id: Uuid,
s3_key: String,
size_bytes: i64,
version_number: i32,
download_path: String,
#[serde(default)]
assets: Vec<DocumentAssetInfo>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct DocumentAssetInfo {
id: Uuid,
asset_type: String,
}
#[derive(Deserialize)]
struct DocumentListItem {
id: Uuid,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
#[derive(Deserialize)]
struct DocumentDownload {
url: String,
filename: String,
}
#[derive(Deserialize)]
struct BulkReanalyze {
queued: usize,
}
#[derive(Deserialize)]
struct BulkMoveResult {
updated: usize,
}
#[derive(Deserialize)]
struct BulkTagResult {
added: usize,
removed: usize,
}
#[derive(Deserialize)]
struct TagSummary {
label: String,
}
#[derive(Deserialize)]
struct DocumentCorrespondentInfo {
name: String,
role: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
#[derive(Deserialize)]
struct AnalyzeJobPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
#[derive(Deserialize)]
struct FolderResponse {
folder: FolderInfo,
}
#[derive(Deserialize)]
struct FolderInfo {
id: Uuid,
}
#[derive(Deserialize)]
struct FolderContents {
documents: Vec<DocumentListItem>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
}
#[derive(Serialize)]
struct BulkMoveRequest<'a> {
document_ids: &'a [Uuid],
folder_id: Option<Uuid>,
}
#[derive(Serialize)]
struct BulkTagRequest<'a> {
document_ids: &'a [Uuid],
tag_ids: &'a [Uuid],
action: &'a str,
}
#[derive(Serialize)]
struct CreateFolderRequest<'a> {
name: &'a str,
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
#[tokio::test]
async fn upload_and_list_document() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "passw0rd";
app.insert_user("dana", password, "admin").await?;
let token = app.login_token("dana", password).await?;
let file_bytes = b"example document body".to_vec();
let upload = app
.upload_document(
"/api/documents",
"doc.txt",
"text/plain",
&file_bytes,
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
assert_eq!(detail.document.original_name, "doc.txt");
assert_eq!(detail.document.title, "doc");
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.tags.is_empty());
let current_version = detail
.document
.current_version
.as_ref()
.expect("current version detail");
assert!(current_version.download_path.starts_with("/download/"));
assert_eq!(current_version.version_number, 1);
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
assert!(current_version.assets.is_empty());
let stored = app
.storage()
.get(&current_version.s3_key)
.await
.expect("object stored");
assert_eq!(stored.bytes, file_bytes);
assert_eq!(app.storage().object_count().await, 1);
let response = app.get("/api/documents", Some(&token)).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let mut list: Vec<DocumentListItem> = serde_json::from_slice(&body)?;
assert_eq!(list.len(), 1);
let item = list.pop().unwrap();
assert_eq!(item.id, detail.document.id);
assert_eq!(
item.current_version
.as_ref()
.map(|version| version.version_number),
Some(1)
);
assert!(item
.current_version
.as_ref()
.expect("list current version")
.download_path
.starts_with("/download/"));
let download = app
.get(
&format!("/api/documents/{}/download", detail.document.id),
Some(&token),
)
.await?;
assert_eq!(download.status(), StatusCode::OK);
let body = body_to_vec(download.into_body()).await?;
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
assert!(download_info.url.contains(&current_version.s3_key));
assert_eq!(download_info.filename, "doc.txt");
let redirect = app.get(&current_version.download_path, None).await?;
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
let location = redirect
.headers()
.get("location")
.expect("redirect location header");
let location = location.to_str().expect("location header utf8");
assert!(location.contains(&current_version.s3_key));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pass1234";
app.insert_user("sam", password, "admin").await?;
let token = app.login_token("sam", password).await?;
let payload = b"same bytes".to_vec();
let first = app
.upload_document(
"/api/documents",
"dup.bin",
"application/octet-stream",
&payload,
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"dup.bin",
"application/octet-stream",
&payload,
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::OK);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
assert_eq!(first_detail.document.id, second_detail.document.id);
assert_eq!(second_detail.document.deleted_at, None);
assert!(second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.assets
.is_empty());
assert_eq!(app.storage().object_count().await, 1);
let delete = app
.delete(
&format!("/api/documents/{}", first_detail.document.id),
Some(&token),
)
.await?;
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
let third = app
.upload_document(
"/api/documents",
"dup.bin",
"application/octet-stream",
&payload,
None,
&token,
)
.await?;
assert_eq!(third.status(), StatusCode::OK);
let third_body = body_to_vec(third.into_body()).await?;
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
assert_eq!(third_detail.document.id, first_detail.document.id);
assert_eq!(third_detail.document.deleted_at, None);
assert_eq!(app.storage().object_count().await, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkpass";
app.insert_user("alex", password, "admin").await?;
let token = app.login_token("alex", password).await?;
app.clear_jobs().await?;
let first_bytes = b"first doc";
let first = app
.upload_document(
"/api/documents",
"first.txt",
"text/plain",
first_bytes,
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second_bytes = b"second doc";
let second = app
.upload_document(
"/api/documents",
"second.txt",
"text/plain",
second_bytes,
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/reanalyze",
&serde_json::json!({}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
let mut expected = vec![
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
second_detail.document.id,
second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.id,
),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkmove";
app.insert_user("mover", password, "admin").await?;
let token = app.login_token("mover", password).await?;
let alpha = app
.upload_document(
"/api/documents",
"alpha.txt",
"text/plain",
b"alpha",
None,
&token,
)
.await?;
assert_eq!(alpha.status(), StatusCode::CREATED);
let alpha_body = body_to_vec(alpha.into_body()).await?;
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
let beta = app
.upload_document(
"/api/documents",
"beta.txt",
"text/plain",
b"beta",
None,
&token,
)
.await?;
assert_eq!(beta.status(), StatusCode::CREATED);
let beta_body = body_to_vec(beta.into_body()).await?;
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
let folder_resp = app
.post_json(
"/api/folders",
&CreateFolderRequest {
name: "Archives",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
let move_resp = app
.post_json(
"/api/documents/bulk/move",
&BulkMoveRequest {
document_ids: &[alpha_detail.document.id, beta_detail.document.id],
folder_id: Some(folder.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(move_resp.status(), StatusCode::OK);
let move_body = body_to_vec(move_resp.into_body()).await?;
let result: BulkMoveResult = serde_json::from_slice(&move_body)?;
assert_eq!(result.updated, 2);
let folder_contents = app
.get(
&format!("/api/folders/{}/contents", folder.folder.id),
Some(&token),
)
.await?;
assert_eq!(folder_contents.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_contents.into_body()).await?;
let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?;
let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect();
assert!(moved_ids.contains(&alpha_detail.document.id));
assert!(moved_ids.contains(&beta_detail.document.id));
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root_docs: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root_docs
.documents
.iter()
.all(|doc| doc.id != alpha_detail.document.id && doc.id != beta_detail.document.id));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_update_tags_for_selection() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulktags";
app.insert_user("tagger", password, "admin").await?;
let token = app.login_token("tagger", password).await?;
let first = app
.upload_document(
"/api/documents",
"notes.txt",
"text/plain",
b"notes",
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"report.txt",
"text/plain",
b"report",
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let urgent_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Urgent",
color: None,
},
Some(&token),
)
.await?;
assert_eq!(urgent_tag.status(), StatusCode::OK);
let urgent_body = body_to_vec(urgent_tag.into_body()).await?;
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
let review_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Review",
color: None,
},
Some(&token),
)
.await?;
assert_eq!(review_tag.status(), StatusCode::OK);
let review_body = body_to_vec(review_tag.into_body()).await?;
let review: TagResponse = serde_json::from_slice(&review_body)?;
let add_resp = app
.post_json(
"/api/documents/bulk/tags",
&BulkTagRequest {
document_ids: &[first_detail.document.id, second_detail.document.id],
tag_ids: &[urgent.id, review.id],
action: "add",
},
Some(&token),
)
.await?;
assert_eq!(add_resp.status(), StatusCode::OK);
let add_body = body_to_vec(add_resp.into_body()).await?;
let add_result: BulkTagResult = serde_json::from_slice(&add_body)?;
assert_eq!(add_result.added, 4);
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
.document
.tags
.iter()
.map(|tag| tag.label.as_str())
.collect();
assert!(labels.contains(&"Urgent"));
assert!(labels.contains(&"Review"));
}
let remove_resp = app
.post_json(
"/api/documents/bulk/tags",
&BulkTagRequest {
document_ids: &[first_detail.document.id, second_detail.document.id],
tag_ids: &[urgent.id],
action: "remove",
},
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.removed, 2);
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
.document
.tags
.iter()
.map(|tag| tag.label.as_str())
.collect();
assert!(!labels.contains(&"Urgent"));
assert!(labels.contains(&"Review"));
}
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_to_selection() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkcorresp";
app.insert_user("corra", password, "admin").await?;
let token = app.login_token("corra", password).await?;
let first = app
.upload_document(
"/api/documents",
"letter-one.txt",
"text/plain",
b"letter one",
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"letter-two.txt",
"text/plain",
b"letter two",
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let sender = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Acme Corp" }),
Some(&token),
)
.await?;
assert_eq!(sender.status(), StatusCode::OK);
let sender_body = body_to_vec(sender.into_body()).await?;
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
let receiver = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Bank Ltd" }),
Some(&token),
)
.await?;
assert_eq!(receiver.status(), StatusCode::OK);
let receiver_body = body_to_vec(receiver.into_body()).await?;
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
let assign_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": sender_summary.id,
"role": "sender"
},
{
"correspondent_id": receiver_summary.id,
"role": "receiver"
}
]
});
let assign_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&assign_payload,
Some(&token),
)
.await?;
assert_eq!(assign_resp.status(), StatusCode::OK);
let assign_body = body_to_vec(assign_resp.into_body()).await?;
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
assert_eq!(assign_result.assigned, 4);
assert_eq!(assign_result.removed, 0);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Acme Corp"));
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
}
let duplicate_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&assign_payload,
Some(&token),
)
.await?;
assert_eq!(duplicate_resp.status(), StatusCode::OK);
let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?;
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
assert_eq!(duplicate_result.assigned, 0);
assert_eq!(duplicate_result.removed, 0);
let replacement = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Charlie" }),
Some(&token),
)
.await?;
assert_eq!(replacement.status(), StatusCode::OK);
let replacement_body = body_to_vec(replacement.into_body()).await?;
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
let replace_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": replacement_summary.id,
"role": "sender"
}
]
});
let replace_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&replace_payload,
Some(&token),
)
.await?;
assert_eq!(replace_resp.status(), StatusCode::OK);
let replace_body = body_to_vec(replace_resp.into_body()).await?;
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
assert_eq!(replace_result.assigned, 2);
assert_eq!(replace_result.removed, 2);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
}
let remove_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": receiver_summary.id,
"role": "receiver"
}
],
"action": "remove"
});
let remove_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&remove_payload,
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.assigned, 0);
assert_eq!(remove_result.removed, 2);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 1);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
assert!(!detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver"));
}
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_selected_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "subsetrean";
app.insert_user("subset", password, "admin").await?;
let token = app.login_token("subset", password).await?;
app.clear_jobs().await?;
let first = app
.upload_document(
"/api/documents",
"doc-one.txt",
"text/plain",
b"one",
None,
&token,
)
.await?;
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
.upload_document(
"/api/documents",
"doc-two.txt",
"text/plain",
b"two",
None,
&token,
)
.await?;
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let third = app
.upload_document(
"/api/documents",
"doc-three.txt",
"text/plain",
b"three",
None,
&token,
)
.await?;
let third_body = body_to_vec(third.into_body()).await?;
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/bulk/reanalyze",
&serde_json::json!({
"document_ids": [
first_detail.document.id,
third_detail.document.id
],
"force": true
}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
assert!(payload_docs
.iter()
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
let mut expected = vec![
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
third_detail.document.id,
third_detail
.document
.current_version
.as_ref()
.expect("third current version")
.id,
),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
+318
View File
@@ -0,0 +1,318 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde::Serialize;
use uuid::Uuid;
#[derive(Deserialize)]
struct FolderResponse {
folder: FolderInfo,
}
#[derive(Deserialize)]
struct FolderInfo {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct FolderContents {
folder: Option<FolderInfo>,
subfolders: Vec<FolderInfo>,
documents: Vec<DocSummary>,
}
#[derive(Deserialize)]
struct DocSummary {
id: Uuid,
}
#[derive(Serialize)]
struct CreateFolder<'a> {
name: &'a str,
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct EnsureFolderPath<'a> {
parent_id: Option<Uuid>,
segments: &'a [&'a str],
}
#[derive(Serialize)]
struct UpdateFolderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<Option<Uuid>>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Serialize)]
struct MoveDocumentRequest {
folder_id: Option<Uuid>,
}
#[derive(Deserialize)]
struct DocumentDetail {
document: DocSummary,
}
#[tokio::test]
async fn folder_move_and_delete_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "folderpass";
app.insert_user("folder-admin", password, "admin").await?;
let token = app.login_token("folder-admin", password).await?;
let folder_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Projects",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
let upload = app
.upload_document(
"/api/documents",
"plan.pdf",
"application/pdf",
b"dummy",
None,
&token,
)
.await?;
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
let move_resp = app
.patch_json(
&format!("/api/documents/{}/folder", detail.document.id),
&MoveDocumentRequest {
folder_id: Some(folder.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(move_resp.status(), StatusCode::NO_CONTENT);
let contents = app
.get(
&format!("/api/folders/{}/contents", folder.folder.id),
Some(&token),
)
.await?;
assert_eq!(contents.status(), StatusCode::OK);
let contents_body = body_to_vec(contents.into_body()).await?;
let contents: FolderContents = serde_json::from_slice(&contents_body)?;
assert_eq!(contents.documents.len(), 1);
assert_eq!(contents.documents[0].id, detail.document.id);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root
.documents
.iter()
.all(|doc| doc.id != detail.document.id));
let delete_attempt = app
.delete(&format!("/api/folders/{}", folder.folder.id), Some(&token))
.await?;
assert_eq!(delete_attempt.status(), StatusCode::BAD_REQUEST);
let move_back = app
.patch_json(
&format!("/api/documents/{}/folder", detail.document.id),
&MoveDocumentRequest { folder_id: None },
Some(&token),
)
.await?;
assert_eq!(move_back.status(), StatusCode::NO_CONTENT);
let delete = app
.delete(&format!("/api/folders/{}", folder.folder.id), Some(&token))
.await?;
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_path_creates_nested_folders() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", password, "admin").await?;
let token = app.login_token("path-admin", password).await?;
let base_path = EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering", "Backend"],
};
let first_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(first_resp.status(), StatusCode::OK);
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(second_resp.status(), StatusCode::OK);
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_folder.folder.id, first_folder.folder.id);
let engineering_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering"],
},
Some(&token),
)
.await?;
assert_eq!(engineering_resp.status(), StatusCode::OK);
let engineering_body = body_to_vec(engineering_resp.into_body()).await?;
let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?;
assert_ne!(engineering_folder.folder.id, first_folder.folder.id);
let infra_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_resp.status(), StatusCode::OK);
let infra_body = body_to_vec(infra_resp.into_body()).await?;
let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?;
assert_ne!(infra_folder.folder.id, engineering_folder.folder.id);
let infra_dupe_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_dupe_resp.status(), StatusCode::OK);
let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?;
let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?;
assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "renamepass";
app.insert_user("rename-admin", password, "admin").await?;
let token = app.login_token("rename-admin", password).await?;
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Projects",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Q1",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
let rename_resp = app
.patch_json(
&format!("/api/folders/{}", parent.folder.id),
&UpdateFolderRequest {
parent_id: None,
name: Some("Archive".to_string()),
},
Some(&token),
)
.await?;
assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let renamed = root
.subfolders
.iter()
.find(|f| f.id == parent.folder.id)
.expect("renamed folder present");
assert_eq!(renamed.name, "Archive");
let folders_only = app
.get(
&format!(
"/api/folders/{}/contents?include_documents=false",
parent.folder.id
),
Some(&token),
)
.await?;
assert_eq!(folders_only.status(), StatusCode::OK);
let folders_only_body = body_to_vec(folders_only.into_body()).await?;
let folders_only_contents: FolderContents = serde_json::from_slice(&folders_only_body)?;
assert!(folders_only_contents.documents.is_empty());
let child_contents = app
.get(
&format!("/api/folders/{}/contents", child.folder.id),
Some(&token),
)
.await?;
assert_eq!(child_contents.status(), StatusCode::OK);
let child_contents_body = body_to_vec(child_contents.into_body()).await?;
let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?;
let child_folder = child_details.folder.expect("child folder info");
assert_eq!(child_folder.name, "Q1");
app.cleanup().await?;
Ok(())
}
+174
View File
@@ -0,0 +1,174 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde::Serialize;
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
}
#[derive(Deserialize)]
struct DocumentInfo {
id: Uuid,
tags: Vec<TagInfo>,
}
#[derive(Deserialize)]
struct TagInfo {
label: String,
#[allow(dead_code)]
color: Option<String>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
label: String,
color: Option<String>,
usage_count: i64,
}
#[derive(Serialize)]
struct AssignTagsRequest {
tag_ids: Vec<Uuid>,
}
#[tokio::test]
async fn tag_assignment_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "tagpass";
app.insert_user("tagger", password, "admin").await?;
let token = app.login_token("tagger", password).await?;
let upload = app
.upload_document(
"/api/documents",
"tagged.txt",
"text/plain",
b"tag me",
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
let create_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Important",
color: Some("#FF0000"),
},
Some(&token),
)
.await?;
assert_eq!(create_tag.status(), StatusCode::OK);
let body = body_to_vec(create_tag.into_body()).await?;
let tag: TagResponse = serde_json::from_slice(&body)?;
assert_eq!(tag.label, "Important");
assert_eq!(tag.color.as_deref(), Some("#FF0000"));
assert_eq!(tag.usage_count, 0);
let update = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"label": "Critical",
"color": "#00FF00"
}),
Some(&token),
)
.await?;
let updated_status = update.status();
let updated_body = body_to_vec(update.into_body()).await?;
if updated_status != StatusCode::OK {
panic!(
"update tag failed: {}",
String::from_utf8_lossy(&updated_body)
);
}
let updated: TagResponse = serde_json::from_slice(&updated_body)?;
assert_eq!(updated.label, "Critical");
assert_eq!(updated.color.as_deref(), Some("#00FF00"));
assert_eq!(updated.usage_count, 0);
let clear_color = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"color": null
}),
Some(&token),
)
.await?;
let cleared_status = clear_color.status();
let cleared_body = body_to_vec(clear_color.into_body()).await?;
if cleared_status != StatusCode::OK {
panic!(
"clear color failed: {}",
String::from_utf8_lossy(&cleared_body)
);
}
let cleared: TagResponse = serde_json::from_slice(&cleared_body)?;
assert_eq!(cleared.color, None);
assert_eq!(cleared.usage_count, 0);
let assign = app
.post_json(
&format!("/api/documents/{}/tags", detail.document.id),
&AssignTagsRequest {
tag_ids: vec![tag.id],
},
Some(&token),
)
.await?;
assert_eq!(assign.status(), StatusCode::NO_CONTENT);
let refreshed = app
.get(
&format!("/api/documents/{}", detail.document.id),
Some(&token),
)
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(refreshed_detail.document.tags.len(), 1);
assert_eq!(refreshed_detail.document.tags[0].label, "Critical");
let remove = app
.delete(
&format!("/api/documents/{}/tags/{}", detail.document.id, tag.id),
Some(&token),
)
.await?;
assert_eq!(remove.status(), StatusCode::NO_CONTENT);
let final_check = app
.get(
&format!("/api/documents/{}", detail.document.id),
Some(&token),
)
.await?;
let final_body = body_to_vec(final_check.into_body()).await?;
let final_detail: DocumentDetail = serde_json::from_slice(&final_body)?;
assert!(final_detail.document.tags.is_empty());
app.cleanup().await?;
Ok(())
}