diff --git a/Cargo.lock b/Cargo.lock index 80d3aa23..afe70d7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more 2.1.0", + "derive_more 2.1.1", "futures-util", "log", "once_cell", @@ -49,7 +49,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more 2.1.0", + "derive_more 2.1.1", "encoding_rs", "flate2", "foldhash", @@ -197,7 +197,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more 2.1.0", + "derive_more 2.1.1", "encoding_rs", "foldhash", "futures-core", @@ -311,6 +311,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -378,9 +384,12 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "51d03449bb8ca2cc2ef70869af31463d1ae5ccc8fa3e334b307203fbf815207e" +dependencies = [ + "rustversion", +] [[package]] name = "argon2rs" @@ -442,6 +451,464 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-config" +version = "1.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81b5b2898f6798ad58f484856768bca817e3cd9de0974c24ae0f1113fe88f1b" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-cloudfront" +version = "1.106.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab4cef77601b6d7da203b97433e945ba5005f9e570e50fa594a45d67f8a75a3" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.118.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e6b7079f85d9ea9a70643c9f89f50db70f5ada868fa9cfe08c1ffdf51abc13" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.91.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee6402a36f27b52fe67661c6732d684b2635152b676aa2babbfb5204f99115d" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.93.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45a7f750bbd170ee3677671ad782d90b894548f4e4ae168302c57ec9de5cb3e" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55542378e419558e6b1f398ca70adb0b2088077e79ad9f14eb09441f2f7b2164" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e523e1c4e8e7e8ff219d732988e22bfeae8a1cafdbe6d9eca1546fa080be7c" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ee19095c7c4dda59f1697d028ce704c24b2d33c6718790c7f1d5a3015b4107c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.63.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc12f8b310e38cad85cf3bef45ad236f470717393c613266ce0a89512286b650" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.62.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e62db736db19c488966c8d787f52e6270be565727236fd5579eaa301e7bc4a" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.12", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.35", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.61.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f616c3f2260612fe44cede278bafa18e73e6479c4e393e2c4518cf2a9a228a" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5d689cf437eae90460e944a58b5668530d433b4ff85789e69d2f2a556e057d" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fda37911905ea4d3141a01364bc5509a0f32ae3f3b22d6e330c0abfb62d247" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab0d43d899f9e508300e587bf582ba54c27a452dd0a9ea294690669138ae14a2" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "905cb13a9895626d49cf2ced759b062d913834c7482c38e49557eac4e6193f01" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.20.0" @@ -454,6 +921,22 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" + [[package]] name = "bitflags" version = "1.3.2" @@ -508,9 +991,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -524,6 +1007,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "bytestring" version = "1.5.0" @@ -535,9 +1028,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", "jobserver", @@ -611,6 +1104,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "codegen" version = "0.1.3" @@ -653,6 +1155,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.1.5" @@ -696,6 +1204,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -711,6 +1229,34 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc-fast" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" +dependencies = [ + "crc", + "digest", + "rand 0.9.2", + "regex", + "rustversion", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -720,6 +1266,28 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -850,6 +1418,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.5" @@ -881,11 +1459,11 @@ dependencies = [ [[package]] name = "derive_more" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "derive_more-impl 2.1.0", + "derive_more-impl 2.1.1", ] [[package]] @@ -902,9 +1480,9 @@ dependencies = [ [[package]] name = "derive_more-impl" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", @@ -940,9 +1518,9 @@ dependencies = [ [[package]] name = "diesel" -version = "2.3.4" +version = "2.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c415189028b232660655e4893e8bc25ca7aee8e96888db66d9edb400535456a" +checksum = "e130c806dccc85428c564f2dc5a96e05b6615a27c9a28776bd7761a9af4bb552" dependencies = [ "bitflags 2.10.0", "byteorder", @@ -981,9 +1559,9 @@ dependencies = [ [[package]] name = "diesel_derives" -version = "2.3.5" +version = "2.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8587cbca3c929fb198e7950d761d31ca72b80aa6e07c1b7bec5879d187720436" +checksum = "c30b2969f923fa1f73744b92bb7df60b858df8832742d9a3aceb79236c0be1d2" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -1075,6 +1653,24 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + [[package]] name = "ego-tree" version = "0.6.3" @@ -1087,6 +1683,26 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1147,6 +1763,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -1199,6 +1825,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1419,6 +2051,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -1463,6 +2106,17 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1487,6 +2141,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.12.4" @@ -1540,6 +2200,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -1559,7 +2230,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.0", - "http-body", + "http-body 1.0.1", "pin-project-lite", ] @@ -1575,6 +2246,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1587,7 +2282,7 @@ dependencies = [ "futures-core", "h2 0.4.12", "http 1.4.0", - "http-body", + "http-body 1.0.1", "httparse", "itoa", "pin-project-lite", @@ -1597,6 +2292,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.7" @@ -1604,12 +2314,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http 1.4.0", - "hyper", + "hyper 1.8.1", "hyper-util", - "rustls", + "rustls 0.23.35", + "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", ] @@ -1621,7 +2332,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -1641,8 +2352,8 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.0", - "http-body", - "hyper", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", @@ -1727,9 +2438,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -1741,9 +2452,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -1880,9 +2591,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "7ee5b5339afb4c41626dde77b7a611bd4f2c202b897852b4bcf5d03eddc61010" [[package]] name = "jiff" @@ -2038,6 +2749,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "mac" version = "0.1.1" @@ -2067,6 +2787,16 @@ dependencies = [ "tendril", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2144,7 +2874,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2278,6 +3008,23 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + [[package]] name = "paperclip" version = "0.9.5" @@ -2521,6 +3268,16 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2541,9 +3298,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" [[package]] name = "portable-atomic-util" @@ -2815,9 +3572,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", @@ -2825,10 +3582,10 @@ dependencies = [ "futures-core", "h2 0.4.12", "http 1.4.0", - "http-body", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", "js-sys", @@ -2879,7 +3636,7 @@ dependencies = [ "futures", "getrandom 0.2.16", "http 1.4.0", - "hyper", + "hyper 1.8.1", "parking_lot 0.11.2", "reqwest", "reqwest-middleware", @@ -2899,6 +3656,17 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "ring" version = "0.17.14" @@ -2933,9 +3701,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -2944,34 +3712,70 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2985,9 +3789,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "62049b2877bf12821e8f9ad256ee38fdc31db7387ec2d3b3f403024de2034aea" [[package]] name = "schannel" @@ -3035,6 +3839,30 @@ dependencies = [ "tendril", ] +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -3042,7 +3870,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3115,9 +3956,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.146" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "217ca874ae0207aac254aa02c957ded05585a90892cc8d87f9e5fa49669dadd8" dependencies = [ "itoa", "memchr", @@ -3128,9 +3969,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] @@ -3193,9 +4034,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -3212,11 +4053,21 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simple_asn1" @@ -3285,6 +4136,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3423,7 +4284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3522,6 +4383,10 @@ version = "0.13.15" dependencies = [ "actix-web", "argon2rs", + "aws-config", + "aws-sdk-cloudfront", + "aws-sdk-s3", + "base64 0.22.1", "chrono", "deadpool-redis", "diesel", @@ -3530,6 +4395,7 @@ dependencies = [ "diesel_migrations", "dotenv", "futures", + "hex", "isbn", "jsonwebtoken", "juniper", @@ -3708,13 +4574,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.35", "tokio", ] @@ -3733,9 +4609,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ "serde_core", "serde_spanned", @@ -3746,18 +4622,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] @@ -3779,15 +4655,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", "http 1.4.0", - "http-body", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3809,9 +4685,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3832,9 +4708,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -3915,6 +4791,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" @@ -3963,6 +4845,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "want" version = "0.3.1" diff --git a/lol-test.pdf b/lol-test.pdf new file mode 100644 index 00000000..86ccb42a Binary files /dev/null and b/lol-test.pdf differ diff --git a/test_cv.pdf b/test_cv.pdf new file mode 100644 index 00000000..2c61a148 Binary files /dev/null and b/test_cv.pdf differ diff --git a/thoth-api/Cargo.toml b/thoth-api/Cargo.toml index 637a0357..59c8bcd6 100644 --- a/thoth-api/Cargo.toml +++ b/thoth-api/Cargo.toml @@ -21,7 +21,12 @@ backend = [ "jsonwebtoken", "deadpool-redis", "rand", - "argon2rs" + "argon2rs", + "aws-sdk-s3", + "aws-sdk-cloudfront", + "aws-config", + "base64", + "hex" ] [dependencies] @@ -48,6 +53,11 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" strum = { version = "0.27.1", features = ["derive"] } uuid = { version = "1.16.0", features = ["serde", "v4"] } +aws-sdk-s3 = { version = "1", optional = true } +aws-sdk-cloudfront = { version = "1", optional = true } +aws-config = { version = "1", optional = true } +base64 = { version = "0.22", optional = true } +hex = { version = "0.4", optional = true } [dev-dependencies] tokio = { version = "1.44", features = ["macros"] } diff --git a/thoth-api/migrations/v0.15.0/down.sql b/thoth-api/migrations/v0.15.0/down.sql new file mode 100644 index 00000000..842853c3 --- /dev/null +++ b/thoth-api/migrations/v0.15.0/down.sql @@ -0,0 +1,17 @@ +-- Drop file_upload table +DROP TABLE IF EXISTS file_upload; + +-- Drop file table +DROP TABLE IF EXISTS file; + +-- Drop file_type enum +DROP TYPE IF EXISTS file_type; + +-- Remove storage configuration columns from imprint table +ALTER TABLE imprint + DROP CONSTRAINT IF EXISTS imprint_storage_cfg_all_or_none, + DROP COLUMN IF EXISTS s3_bucket, + DROP COLUMN IF EXISTS s3_region, + DROP COLUMN IF EXISTS cdn_domain, + DROP COLUMN IF EXISTS cloudfront_dist_id; + diff --git a/thoth-api/migrations/v0.15.0/up.sql b/thoth-api/migrations/v0.15.0/up.sql new file mode 100644 index 00000000..119d2fb3 --- /dev/null +++ b/thoth-api/migrations/v0.15.0/up.sql @@ -0,0 +1,111 @@ +-- Add storage configuration columns to imprint table +ALTER TABLE imprint + ADD COLUMN s3_bucket TEXT, + ADD COLUMN s3_region TEXT, + ADD COLUMN cdn_domain TEXT, + ADD COLUMN cloudfront_dist_id TEXT; + +-- All or nothing constraint: either all storage config fields are NULL or all are NOT NULL +ALTER TABLE imprint + ADD CONSTRAINT imprint_storage_cfg_all_or_none + CHECK ( + ( + s3_bucket IS NULL AND + s3_region IS NULL AND + cdn_domain IS NULL AND + cloudfront_dist_id IS NULL + ) + OR + ( + s3_bucket IS NOT NULL AND + s3_region IS NOT NULL AND + cdn_domain IS NOT NULL AND + cloudfront_dist_id IS NOT NULL + ) + ); + +-- Create file_type enum +CREATE TYPE file_type AS ENUM ('publication', 'frontcover'); + +-- Create file table (final stored files) +CREATE TABLE file ( + file_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + file_type file_type NOT NULL, -- 'publication' | 'frontcover' + + work_id UUID REFERENCES work (work_id), + publication_id UUID REFERENCES publication (publication_id), + + object_key TEXT NOT NULL, -- lowercase DOI-based canonical path + cdn_url TEXT NOT NULL, -- full public URL + + mime_type TEXT NOT NULL, + bytes BIGINT NOT NULL, + sha256 TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Enforce file type constraints +ALTER TABLE file + ADD CONSTRAINT file_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL) + ); + +-- One frontcover per work +CREATE UNIQUE INDEX file_frontcover_work_unique_idx + ON file (work_id) + WHERE file_type = 'frontcover'; + +-- One publication file per publication +CREATE UNIQUE INDEX file_publication_unique_idx + ON file (publication_id) + WHERE file_type = 'publication'; + +-- Never reuse the same object key +CREATE UNIQUE INDEX file_object_key_unique_idx + ON file (object_key); + +-- Enable automatic updated_at management +SELECT diesel_manage_updated_at('file'); + +-- Create file_upload table (temporary uploads) +CREATE TABLE file_upload ( + file_upload_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + file_type file_type NOT NULL, -- same enum as final file table + + work_id UUID REFERENCES work (work_id), + publication_id UUID REFERENCES publication (publication_id), + + declared_mime_type TEXT NOT NULL, + declared_extension TEXT NOT NULL, + declared_sha256 TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Enforce file_upload type constraints +ALTER TABLE file_upload + ADD CONSTRAINT file_upload_type_check + CHECK ( + (file_type = 'frontcover' AND work_id IS NOT NULL AND publication_id IS NULL) OR + (file_type = 'publication' AND publication_id IS NOT NULL AND work_id IS NULL) + ); + +-- Indexes for file_upload lookups +CREATE INDEX file_upload_work_idx + ON file_upload (work_id) + WHERE file_type = 'frontcover'; + +CREATE INDEX file_upload_publication_idx + ON file_upload (publication_id) + WHERE file_type = 'publication'; + +-- Enable automatic updated_at management +SELECT diesel_manage_updated_at('file_upload'); + diff --git a/thoth-api/src/graphql/model.rs b/thoth-api/src/graphql/model.rs index a86552ca..63ea3954 100644 --- a/thoth-api/src/graphql/model.rs +++ b/thoth-api/src/graphql/model.rs @@ -16,6 +16,11 @@ use crate::model::{ }, contributor::{Contributor, ContributorOrderBy, NewContributor, PatchContributor}, convert_from_jats, convert_to_jats, + file::{ + parse_doi, validate_file_extension, CompleteFileUpload, File, FileType, FileUpload, + FileUploadResponse, NewFile, NewFileUpload, NewFrontcoverFileUpload, + NewPublicationFileUpload, + }, funding::{Funding, FundingField, NewFunding, PatchFunding}, imprint::{Imprint, ImprintField, ImprintOrderBy, NewImprint, PatchImprint}, institution::{CountryCode, Institution, InstitutionOrderBy, NewInstitution, PatchInstitution}, @@ -43,6 +48,12 @@ use crate::model::{ ConversionLimit, Convert, Crud, Doi, Isbn, LengthUnit, MarkupFormat, Orcid, Reorder, Ror, Timestamp, WeightUnit, }; +#[cfg(feature = "backend")] +use crate::storage::{ + build_cdn_url, canonical_frontcover_key, canonical_publication_key, copy_temp_object_to_final, + create_cloudfront_client, create_s3_client, delete_temp_object, head_object, + invalidate_cloudfront, presign_put_for_upload, temp_key, StorageConfig, +}; use thoth_errors::{ThothError, ThothResult}; impl juniper::Context for Context {} @@ -606,6 +617,14 @@ impl QueryRoot { Publication::from_id(&context.db, &publication_id).map_err(Into::into) } + #[graphql(description = "Query a single file using its ID")] + fn file( + context: &Context, + #[graphql(description = "Thoth file ID to search on")] file_id: Uuid, + ) -> FieldResult { + File::from_id(&context.db, &file_id).map_err(|e| e.into()) + } + #[graphql(description = "Get the total number of publications")] fn publication_count( context: &Context, @@ -3398,6 +3417,350 @@ impl MutationRoot { contact.delete(&context.db).map_err(Into::into) } + + #[cfg(feature = "backend")] + #[graphql( + description = "Start uploading a publication file (e.g. PDF, EPUB, XML) for a given publication. Returns an upload session ID and a presigned S3 PUT URL." + )] + async fn init_publication_file_upload( + context: &Context, + #[graphql(description = "Input for starting a publication file upload")] + data: NewPublicationFileUpload, + ) -> FieldResult { + println!( + "DEBUG: Starting init_publication_file_upload for publication_id: {}", + data.publication_id + ); + context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; + + let publication = Publication::from_id(&context.db, &data.publication_id)?; + context + .account_access + .can_edit(publisher_id_from_publication_id( + &context.db, + data.publication_id, + )?)?; + + let work = Work::from_id(&context.db, &publication.work_id)?; + work.doi.ok_or_else(|| { + ThothError::InternalError("Work must have a DOI to upload files".to_string()) + })?; + + let imprint = Imprint::from_id(&context.db, &work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + eprintln!( + "GRAPHQL_DEBUG: Storage config obtained: bucket={}, region={}", + storage_config.s3_bucket, storage_config.s3_region + ); + + let new_upload = NewFileUpload { + file_type: FileType::Publication, + work_id: None, + publication_id: Some(data.publication_id), + declared_mime_type: data.declared_mime_type.clone(), + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256.clone(), + }; + + let file_upload = FileUpload::create(&context.db, &new_upload)?; + + eprintln!( + "GRAPHQL_DEBUG: About to create S3 client for region: {}", + storage_config.s3_region + ); + let s3_client = create_s3_client(&storage_config.s3_region).await; + eprintln!("GRAPHQL_DEBUG: S3 client created, about to presign URL"); + let temp_key = temp_key(&file_upload.file_upload_id); + let upload_url = presign_put_for_upload( + &s3_client, + &storage_config.s3_bucket, + &temp_key, + &data.declared_mime_type, + &data.declared_sha256, + 30, // 30 minutes expiration + ) + .await?; + + let expires_at = Timestamp::parse_from_rfc3339( + &chrono::Utc::now() + .checked_add_signed(chrono::Duration::minutes(30)) + .ok_or_else(|| { + ThothError::InternalError("Failed to calculate expiration time".to_string()) + })? + .to_rfc3339(), + )?; + + Ok(FileUploadResponse { + file_upload_id: file_upload.file_upload_id, + upload_url, + expires_at, + }) + } + + #[cfg(feature = "backend")] + #[graphql( + description = "Start uploading a front cover image for a given work. Returns an upload session ID and a presigned S3 PUT URL." + )] + async fn init_frontcover_file_upload( + context: &Context, + #[graphql(description = "Input for starting a front cover upload")] + data: NewFrontcoverFileUpload, + ) -> FieldResult { + context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; + + let work = Work::from_id(&context.db, &data.work_id)?; + context + .account_access + .can_edit(publisher_id_from_work_id(&context.db, data.work_id)?)?; + + work.doi.ok_or_else(|| { + ThothError::InternalError("Work must have a DOI to upload files".to_string()) + })?; + + let imprint = Imprint::from_id(&context.db, &work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + + let new_upload = NewFileUpload { + file_type: FileType::Frontcover, + work_id: Some(data.work_id), + publication_id: None, + declared_mime_type: data.declared_mime_type.clone(), + declared_extension: data.declared_extension.to_lowercase(), + declared_sha256: data.declared_sha256.clone(), + }; + + let file_upload = FileUpload::create(&context.db, &new_upload)?; + + let s3_client = create_s3_client(&storage_config.s3_region).await; + let temp_key = temp_key(&file_upload.file_upload_id); + let upload_url = presign_put_for_upload( + &s3_client, + &storage_config.s3_bucket, + &temp_key, + &data.declared_mime_type, + &data.declared_sha256, + 30, // 30 minutes expiration + ) + .await?; + + let expires_at = Timestamp::parse_from_rfc3339( + &chrono::Utc::now() + .checked_add_signed(chrono::Duration::minutes(30)) + .ok_or_else(|| { + ThothError::InternalError("Failed to calculate expiration time".to_string()) + })? + .to_rfc3339(), + )?; + + Ok(FileUploadResponse { + file_upload_id: file_upload.file_upload_id, + upload_url, + expires_at, + }) + } + + #[cfg(feature = "backend")] + #[graphql( + description = "Complete a file upload by validating the uploaded object, moving it to its canonical DOI-based key, updating/creating the file record." + )] + async fn complete_file_upload( + context: &Context, + #[graphql(description = "Input for completing a file upload")] data: CompleteFileUpload, + ) -> FieldResult { + context.token.jwt.as_ref().ok_or(ThothError::Unauthorised)?; + + let file_upload = FileUpload::from_id(&context.db, &data.file_upload_id) + .map_err(|_| ThothError::EntityNotFound)?; + + match file_upload.file_type { + FileType::Publication => { + let publication_id = file_upload.publication_id.ok_or_else(|| { + ThothError::InternalError( + "Publication file upload missing publication_id".to_string(), + ) + })?; + context + .account_access + .can_edit(publisher_id_from_publication_id( + &context.db, + publication_id, + )?)?; + } + FileType::Frontcover => { + let work_id = file_upload.work_id.ok_or_else(|| { + ThothError::InternalError("Frontcover file upload missing work_id".to_string()) + })?; + context + .account_access + .can_edit(publisher_id_from_work_id(&context.db, work_id)?)?; + } + } + + let (work, storage_config) = match file_upload.file_type { + FileType::Publication => { + let publication = + Publication::from_id(&context.db, &file_upload.publication_id.unwrap())?; + let work = Work::from_id(&context.db, &publication.work_id)?; + let imprint = Imprint::from_id(&context.db, &work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + (work, storage_config) + } + FileType::Frontcover => { + let work = Work::from_id(&context.db, &file_upload.work_id.unwrap())?; + let imprint = Imprint::from_id(&context.db, &work.imprint_id)?; + let storage_config = StorageConfig::from_imprint(&imprint)?; + (work, storage_config) + } + }; + + let doi = work + .doi + .ok_or_else(|| ThothError::InternalError("Work must have a DOI".to_string()))?; + + let (doi_prefix, doi_suffix) = parse_doi(&doi)?; + + let s3_client = create_s3_client(&storage_config.s3_region).await; + let temp_key = temp_key(&file_upload.file_upload_id); + let (bytes, mime_type) = + head_object(&s3_client, &storage_config.s3_bucket, &temp_key).await?; + + validate_file_extension( + &file_upload.declared_extension, + &file_upload.file_type, + if file_upload.file_type == FileType::Publication { + Some( + Publication::from_id(&context.db, &file_upload.publication_id.unwrap())? + .publication_type, + ) + } else { + None + }, + )?; + + // Compute canonical key + let canonical_key = match file_upload.file_type { + FileType::Publication => { + canonical_publication_key(&doi_prefix, &doi_suffix, &file_upload.declared_extension) + } + FileType::Frontcover => { + canonical_frontcover_key(&doi_prefix, &doi_suffix, &file_upload.declared_extension) + } + }; + + // Check if file already exists (for cache invalidation) + let existing_file = File::from_object_key(&context.db, &canonical_key).ok(); + let should_invalidate = existing_file.is_some(); + + // Copy object to final location + copy_temp_object_to_final( + &s3_client, + &storage_config.s3_bucket, + &temp_key, + &canonical_key, + ) + .await?; + + // Build CDN URL + let cdn_url = build_cdn_url(&storage_config.cdn_domain, &canonical_key); + + // Create or update file record + let new_file = NewFile { + file_type: file_upload.file_type, + work_id: file_upload.work_id, + publication_id: file_upload.publication_id, + object_key: canonical_key.clone(), + cdn_url: cdn_url.clone(), + mime_type: mime_type.clone(), + bytes, + sha256: file_upload.declared_sha256.clone(), + }; + + let file = if let Some(existing) = &existing_file { + // Update existing file + use crate::schema::file::dsl; + use diesel::prelude::*; + let mut connection = context.db.get()?; + diesel::update(dsl::file.find(&existing.file_id)) + .set(( + dsl::cdn_url.eq(&new_file.cdn_url), + dsl::mime_type.eq(&new_file.mime_type), + dsl::bytes.eq(new_file.bytes), + dsl::sha256.eq(&new_file.sha256), + )) + .get_result::(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e))? + } else { + File::create(&context.db, &new_file)? + }; + + // Update Work.cover_url for frontcovers + if file_upload.file_type == FileType::Frontcover { + let work_id = file_upload.work_id.unwrap(); + use crate::schema::work::dsl; + use diesel::prelude::*; + let mut connection = context.db.get()?; + diesel::update(dsl::work.find(&work_id)) + .set(dsl::cover_url.eq(Some(cdn_url.clone()))) + .execute(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e))?; + } + + // Create/update canonical Location for publication files + if file_upload.file_type == FileType::Publication { + let publication_id = file_upload.publication_id.unwrap(); + use crate::schema::location::dsl; + use diesel::prelude::*; + let mut connection = context.db.get()?; + + // Check if canonical location exists + let existing_location = dsl::location + .filter(dsl::publication_id.eq(publication_id)) + .filter(dsl::canonical.eq(true)) + .first::(&mut connection) + .optional() + .map_err(|e: diesel::result::Error| ThothError::from(e))?; + + if let Some(loc) = existing_location { + // Update existing canonical location + diesel::update(dsl::location.find(&loc.location_id)) + .set(dsl::full_text_url.eq(Some(cdn_url.clone()))) + .execute(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e))?; + } else { + // Create new canonical location + let new_location = NewLocation { + publication_id, + landing_page: Some(work.landing_page.clone().unwrap_or_default()), + full_text_url: Some(cdn_url.clone()), + location_platform: LocationPlatform::Thoth, + canonical: true, + }; + Location::create(&context.db, &new_location)?; + } + } + + // Invalidate CloudFront cache if replacing existing file + if should_invalidate { + let cloudfront_client = create_cloudfront_client().await; + invalidate_cloudfront( + &cloudfront_client, + &storage_config.cloudfront_dist_id, + &canonical_key, + ) + .await?; + } + + // Cleanup: delete file_upload record and temp object + use crate::schema::file_upload::dsl; + use diesel::prelude::*; + let mut connection = context.db.get()?; + diesel::delete(dsl::file_upload.find(&file_upload.file_upload_id)) + .execute(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e))?; + delete_temp_object(&s3_client, &storage_config.s3_bucket, &temp_key).await?; + + Ok(file) + } } #[juniper::graphql_object(Context = Context, description = "A written text that can be published")] @@ -4003,6 +4366,12 @@ impl Work { ) .map_err(Into::into) } + + #[graphql(description = "Get the front cover file for this work")] + pub fn frontcover(&self, context: &Context) -> FieldResult> { + File::from_work_id(&context.db, &self.work_id).map_err(|e| e.into()) + } + #[graphql(description = "Get references cited by this work")] pub fn references( &self, @@ -4243,6 +4612,11 @@ impl Publication { .map_err(Into::into) } + #[graphql(description = "Get the publication file for this publication")] + pub fn file(&self, context: &Context) -> FieldResult> { + File::from_publication_id(&context.db, &self.publication_id).map_err(|e| e.into()) + } + #[graphql(description = "Get the work to which this publication belongs")] pub fn work(&self, context: &Context) -> FieldResult { Work::from_id(&context.db, &self.work_id).map_err(Into::into) @@ -4620,7 +4994,7 @@ impl Contribution { filter, order.unwrap_or_default(), vec![], - None, + Some(self.contribution_id), None, locale_codes.unwrap_or_default(), vec![], @@ -5634,3 +6008,64 @@ fn publisher_id_from_publication_id(db: &PgPool, publication_id: Uuid) -> ThothR fn publisher_id_from_contribution_id(db: &PgPool, contribution_id: Uuid) -> ThothResult { Contribution::from_id(db, &contribution_id)?.publisher_id(db) } + +#[cfg(feature = "backend")] +#[juniper::graphql_object(Context = Context, description = "A file stored in the system (publication file or front cover).")] +impl File { + #[graphql(description = "Thoth ID of the file")] + pub fn file_id(&self) -> &Uuid { + &self.file_id + } + + #[graphql(description = "Type of file (publication or frontcover)")] + pub fn file_type(&self) -> &FileType { + &self.file_type + } + + #[graphql(description = "Thoth ID of the work (for frontcovers)")] + pub fn work_id(&self) -> Option<&Uuid> { + self.work_id.as_ref() + } + + #[graphql(description = "Thoth ID of the publication (for publication files)")] + pub fn publication_id(&self) -> Option<&Uuid> { + self.publication_id.as_ref() + } + + #[graphql(description = "S3 object key (canonical DOI-based path)")] + pub fn object_key(&self) -> &String { + &self.object_key + } + + #[graphql(description = "Public CDN URL")] + pub fn cdn_url(&self) -> &String { + &self.cdn_url + } + + #[graphql(description = "MIME type used when serving the file")] + pub fn mime_type(&self) -> &String { + &self.mime_type + } + + #[graphql(description = "Size of the file in bytes")] + pub fn bytes(&self) -> i32 { + // Note: GraphQL doesn't support i64, so we cast to i32 + // Files larger than 2GB will show incorrect size + self.bytes as i32 + } + + #[graphql(description = "SHA-256 checksum of the stored file")] + pub fn sha256(&self) -> &String { + &self.sha256 + } + + #[graphql(description = "Date and time at which the file record was created")] + pub fn created_at(&self) -> Timestamp { + self.created_at + } + + #[graphql(description = "Date and time at which the file record was last updated")] + pub fn updated_at(&self) -> Timestamp { + self.updated_at + } +} diff --git a/thoth-api/src/lib.rs b/thoth-api/src/lib.rs index 3965e644..ccdc0a0b 100644 --- a/thoth-api/src/lib.rs +++ b/thoth-api/src/lib.rs @@ -25,6 +25,8 @@ pub mod model; pub mod redis; #[cfg(feature = "backend")] mod schema; +#[cfg(feature = "backend")] +pub mod storage; macro_rules! apis { ($($name:ident => $content:expr,)*) => ( diff --git a/thoth-api/src/model/file/crud.rs b/thoth-api/src/model/file/crud.rs new file mode 100644 index 00000000..5c35f438 --- /dev/null +++ b/thoth-api/src/model/file/crud.rs @@ -0,0 +1,269 @@ +#[cfg(feature = "backend")] +use super::FileType; +#[cfg(feature = "backend")] +use super::{File, FileUpload, NewFile, NewFileUpload}; +#[cfg(feature = "backend")] +use crate::model::Crud; +#[cfg(feature = "backend")] +use crate::schema::{file, file_upload}; +#[cfg(feature = "backend")] +use diesel::prelude::*; +#[cfg(feature = "backend")] +use diesel::OptionalExtension; +#[cfg(feature = "backend")] +use thoth_errors::{ThothError, ThothResult}; +#[cfg(feature = "backend")] +use uuid::Uuid; + +#[cfg(feature = "backend")] +impl Crud for File { + type NewEntity = NewFile; + type PatchEntity = NewFile; + type OrderByEntity = (); // Not queryable via GraphQL + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.file_id + } + + fn all( + _db: &crate::db::PgPool, + _limit: i32, + _offset: i32, + _filter: Option, + _order: Self::OrderByEntity, + _publishers: Vec, + _parent_id_1: Option, + _parent_id_2: Option, + _filter_param_1: Vec, + _filter_param_2: Vec, + _filter_param_3: Option, + _filter_param_4: Option, + ) -> ThothResult> { + Err(ThothError::InternalError( + "File::all not implemented".to_string(), + )) + } + + fn count( + _db: &crate::db::PgPool, + _filter: Option, + _publishers: Vec, + _filter_param_1: Vec, + _filter_param_2: Vec, + _filter_param_3: Option, + _filter_param_4: Option, + ) -> ThothResult { + Err(ThothError::InternalError( + "File::count not implemented".to_string(), + )) + } + + fn from_id(db: &crate::db::PgPool, entity_id: &Uuid) -> ThothResult { + use diesel::QueryDsl; + use diesel::RunQueryDsl; + let mut connection = db.get()?; + file::table + .find(entity_id) + .get_result::(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + fn create(db: &crate::db::PgPool, data: &NewFile) -> ThothResult { + let mut connection = db.get()?; + diesel::insert_into(file::table) + .values(data) + .get_result::(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + fn update( + &self, + _db: &crate::db::PgPool, + _data: &NewFile, + _account_id: &Uuid, + ) -> ThothResult { + Err(ThothError::InternalError( + "File::update not implemented".to_string(), + )) + } + + fn delete(self, db: &crate::db::PgPool) -> ThothResult { + use diesel::QueryDsl; + use diesel::RunQueryDsl; + let mut connection = db.get()?; + diesel::delete(file::table.find(self.file_id)) + .execute(&mut connection) + .map(|_| self) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult { + match (self.work_id, self.publication_id) { + (Some(work_id), None) => { + crate::model::work::Work::from_id(db, &work_id)?.publisher_id(db) + } + (None, Some(publication_id)) => { + crate::model::publication::Publication::from_id(db, &publication_id)? + .publisher_id(db) + } + _ => Err(ThothError::InternalError( + "File must have either work_id or publication_id".to_string(), + )), + } + } +} + +#[cfg(feature = "backend")] +impl Crud for FileUpload { + type NewEntity = NewFileUpload; + type PatchEntity = NewFileUpload; + type OrderByEntity = (); // Not queryable via GraphQL + type FilterParameter1 = (); + type FilterParameter2 = (); + type FilterParameter3 = (); + type FilterParameter4 = (); + + fn pk(&self) -> Uuid { + self.file_upload_id + } + + fn all( + _db: &crate::db::PgPool, + _limit: i32, + _offset: i32, + _filter: Option, + _order: Self::OrderByEntity, + _publishers: Vec, + _parent_id_1: Option, + _parent_id_2: Option, + _filter_param_1: Vec, + _filter_param_2: Vec, + _filter_param_3: Option, + _filter_param_4: Option, + ) -> ThothResult> { + Err(ThothError::InternalError( + "FileUpload::all not implemented".to_string(), + )) + } + + fn count( + _db: &crate::db::PgPool, + _filter: Option, + _publishers: Vec, + _filter_param_1: Vec, + _filter_param_2: Vec, + _filter_param_3: Option, + _filter_param_4: Option, + ) -> ThothResult { + Err(ThothError::InternalError( + "FileUpload::count not implemented".to_string(), + )) + } + + fn from_id(db: &crate::db::PgPool, entity_id: &Uuid) -> ThothResult { + use diesel::QueryDsl; + use diesel::RunQueryDsl; + let mut connection = db.get()?; + file_upload::table + .find(entity_id) + .get_result::(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + fn create(db: &crate::db::PgPool, data: &NewFileUpload) -> ThothResult { + let mut connection = db.get()?; + diesel::insert_into(file_upload::table) + .values(data) + .get_result::(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + fn update( + &self, + _db: &crate::db::PgPool, + _data: &NewFileUpload, + _account_id: &Uuid, + ) -> ThothResult { + Err(ThothError::InternalError( + "FileUpload::update not implemented".to_string(), + )) + } + + fn delete(self, db: &crate::db::PgPool) -> ThothResult { + use diesel::QueryDsl; + use diesel::RunQueryDsl; + let mut connection = db.get()?; + diesel::delete(file_upload::table.find(self.file_upload_id)) + .execute(&mut connection) + .map(|_| self) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + fn publisher_id(&self, db: &crate::db::PgPool) -> ThothResult { + match (self.work_id, self.publication_id) { + (Some(work_id), None) => { + crate::model::work::Work::from_id(db, &work_id)?.publisher_id(db) + } + (None, Some(publication_id)) => { + crate::model::publication::Publication::from_id(db, &publication_id)? + .publisher_id(db) + } + _ => Err(ThothError::InternalError( + "FileUpload must have either work_id or publication_id".to_string(), + )), + } + } +} + +#[cfg(feature = "backend")] +impl File { + /// Find a file by its object_key + pub fn from_object_key(db: &crate::db::PgPool, object_key: &str) -> ThothResult { + use crate::schema::file::dsl; + use diesel::QueryDsl; + use diesel::RunQueryDsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::object_key.eq(object_key)) + .first::(&mut connection) + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + /// Find the front cover file for a work + pub fn from_work_id(db: &crate::db::PgPool, work_id: &Uuid) -> ThothResult> { + use crate::schema::file::dsl; + use diesel::QueryDsl; + use diesel::RunQueryDsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::work_id.eq(work_id)) + .filter(dsl::file_type.eq(FileType::Frontcover)) + .first::(&mut connection) + .optional() + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } + + /// Find the publication file for a publication + pub fn from_publication_id( + db: &crate::db::PgPool, + publication_id: &Uuid, + ) -> ThothResult> { + use crate::schema::file::dsl; + use diesel::QueryDsl; + use diesel::RunQueryDsl; + + let mut connection = db.get()?; + dsl::file + .filter(dsl::publication_id.eq(publication_id)) + .filter(dsl::file_type.eq(FileType::Publication)) + .first::(&mut connection) + .optional() + .map_err(|e: diesel::result::Error| ThothError::from(e)) + } +} diff --git a/thoth-api/src/model/file/mod.rs b/thoth-api/src/model/file/mod.rs new file mode 100644 index 00000000..928dfb92 --- /dev/null +++ b/thoth-api/src/model/file/mod.rs @@ -0,0 +1,240 @@ +use serde::Deserialize; +use serde::Serialize; +use strum::Display; +use strum::EnumString; +use uuid::Uuid; + +use crate::model::publication::PublicationType; +use crate::model::{Doi, Timestamp}; +#[cfg(feature = "backend")] +use crate::schema::file; +#[cfg(feature = "backend")] +use crate::schema::file_upload; +#[cfg(feature = "backend")] +use thoth_errors::{ThothError, ThothResult}; + +#[cfg_attr( + feature = "backend", + derive(DbEnum, juniper::GraphQLEnum), + graphql(description = "Type of file being uploaded"), + ExistingTypePath = "crate::schema::sql_types::FileType" +)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, EnumString, Display)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "lowercase")] +pub enum FileType { + #[cfg_attr( + feature = "backend", + db_rename = "publication", + graphql(description = "Publication file (PDF, EPUB, XML, etc.)") + )] + Publication, + #[cfg_attr( + feature = "backend", + db_rename = "frontcover", + graphql(description = "Front cover image") + )] + Frontcover, +} + +#[cfg_attr(feature = "backend", derive(Queryable))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct File { + pub file_id: Uuid, + pub file_type: FileType, + pub work_id: Option, + pub publication_id: Option, + pub object_key: String, + pub cdn_url: String, + pub mime_type: String, + pub bytes: i64, + pub sha256: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr(feature = "backend", derive(Queryable))] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct FileUpload { + pub file_upload_id: Uuid, + pub file_type: FileType, + pub work_id: Option, + pub publication_id: Option, + pub declared_mime_type: String, + pub declared_extension: String, + pub declared_sha256: String, + pub created_at: Timestamp, + pub updated_at: Timestamp, +} + +#[cfg_attr( + feature = "backend", + derive(juniper::GraphQLInputObject, Insertable), + graphql(description = "Input for starting a publication file upload"), + diesel(table_name = file_upload) +)] +pub struct NewFileUpload { + pub file_type: FileType, + pub work_id: Option, + pub publication_id: Option, + pub declared_mime_type: String, + pub declared_extension: String, + pub declared_sha256: String, +} + +#[cfg_attr( + feature = "backend", + derive(Insertable), + diesel(table_name = file) +)] +pub struct NewFile { + pub file_type: FileType, + pub work_id: Option, + pub publication_id: Option, + pub object_key: String, + pub cdn_url: String, + pub mime_type: String, + pub bytes: i64, + pub sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting a publication file upload (PDF, EPUB, XML, etc.).")] +pub struct NewPublicationFileUpload { + #[graphql(description = "Thoth ID of the publication linked to this file.")] + pub publication_id: Uuid, + #[graphql( + description = "MIME type declared by the client (used for validation and in the presigned URL)." + )] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'pdf', 'epub', 'xml'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql(description = "Input for starting a front cover upload for a work.")] +pub struct NewFrontcoverFileUpload { + #[graphql(description = "Thoth ID of the work this front cover belongs to.")] + pub work_id: Uuid, + #[graphql(description = "MIME type declared by the client (e.g. 'image/jpeg').")] + pub declared_mime_type: String, + #[graphql( + description = "File extension to use in the final canonical key, e.g. 'jpg', 'png', 'webp'." + )] + pub declared_extension: String, + #[graphql(description = "SHA-256 checksum of the file, hex-encoded.")] + pub declared_sha256: String, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLInputObject)] +#[graphql( + description = "Input for completing a file upload and promoting it to its final DOI-based location." +)] +pub struct CompleteFileUpload { + #[graphql(description = "ID of the upload session to complete.")] + pub file_upload_id: Uuid, +} + +#[cfg(feature = "backend")] +#[derive(juniper::GraphQLObject)] +#[graphql( + description = "Response from initiating a file upload, containing the upload URL and expiration time." +)] +pub struct FileUploadResponse { + #[graphql(description = "ID of the upload session.")] + pub file_upload_id: Uuid, + #[graphql(description = "Presigned S3 PUT URL for uploading the file.")] + pub upload_url: String, + #[graphql(description = "Time when the upload URL expires.")] + pub expires_at: Timestamp, +} + +#[cfg(feature = "backend")] +/// Parse a DOI into prefix and suffix +pub fn parse_doi(doi: &Doi) -> ThothResult<(String, String)> { + // DOI format: https://doi.org/10.XXXX/SUFFIX + // We need to extract 10.XXXX (prefix) and SUFFIX + let doi_str = doi.to_lowercase_string(); + // Remove the https://doi.org/ prefix if present + let doi_path = if doi_str.starts_with("https://doi.org/") { + doi_str.strip_prefix("https://doi.org/").unwrap() + } else if doi_str.starts_with("http://doi.org/") { + doi_str.strip_prefix("http://doi.org/").unwrap() + } else { + &doi_str + }; + let parts: Vec<&str> = doi_path.splitn(2, '/').collect(); + if parts.len() != 2 { + return Err(ThothError::InternalError(format!( + "Invalid DOI format: {}", + doi_str + ))); + } + let prefix = parts[0].to_string(); + let suffix = parts[1].to_string(); + Ok((prefix, suffix)) +} + +#[cfg(feature = "backend")] +pub fn validate_file_extension( + extension: &str, + file_type: &FileType, + publication_type: Option, +) -> ThothResult<()> { + match file_type { + FileType::Frontcover => { + let valid_extensions = ["jpg", "jpeg", "png", "webp"]; + if !valid_extensions.contains(&extension.to_lowercase().as_str()) { + return Err(ThothError::InternalError(format!( + "Invalid extension for frontcover: {}. Allowed: jpg, jpeg, png, webp", + extension + ))); + } + } + FileType::Publication => { + if let Some(pub_type) = publication_type { + let valid_extensions: Vec<&str> = match pub_type { + PublicationType::Pdf => vec!["pdf"], + PublicationType::Epub => vec!["epub"], + PublicationType::Html => vec!["html", "htm", "zip"], + PublicationType::Xml => vec!["xml", "zip"], + PublicationType::Docx => vec!["docx"], + PublicationType::Mobi => vec!["mobi"], + PublicationType::Azw3 => vec!["azw3"], + PublicationType::FictionBook => vec!["fb2", "fbz", "zip"], + PublicationType::Mp3 => vec!["mp3"], + PublicationType::Wav => vec!["wav"], + _ => { + return Err(ThothError::InternalError(format!( + "File uploads not supported for publication type: {:?}", + pub_type + ))) + } + }; + if !valid_extensions.contains(&extension.to_lowercase().as_str()) { + return Err(ThothError::InternalError(format!( + "Invalid extension for {:?}: {}. Allowed: {:?}", + pub_type, extension, valid_extensions + ))); + } + } else { + return Err(ThothError::InternalError( + "Publication type required for publication file validation".to_string(), + )); + } + } + } + Ok(()) +} + +#[cfg(feature = "backend")] +pub mod crud; diff --git a/thoth-api/src/model/imprint/mod.rs b/thoth-api/src/model/imprint/mod.rs index 1f6c2df4..617af6f5 100644 --- a/thoth-api/src/model/imprint/mod.rs +++ b/thoth-api/src/model/imprint/mod.rs @@ -42,6 +42,10 @@ pub struct Imprint { pub imprint_name: String, pub imprint_url: Option, pub crossmark_doi: Option, + pub s3_bucket: Option, + pub s3_region: Option, + pub cdn_domain: Option, + pub cloudfront_dist_id: Option, pub created_at: Timestamp, pub updated_at: Timestamp, } diff --git a/thoth-api/src/model/mod.rs b/thoth-api/src/model/mod.rs index 7bd79f39..5668b63e 100644 --- a/thoth-api/src/model/mod.rs +++ b/thoth-api/src/model/mod.rs @@ -1731,6 +1731,7 @@ pub mod biography; pub mod contact; pub mod contribution; pub mod contributor; +pub mod file; pub mod funding; pub mod imprint; pub mod institution; @@ -1747,3 +1748,29 @@ pub mod subject; pub mod title; pub mod work; pub mod work_relation; + +// Explicitly list the items to avoid ambiguous glob re-exports +pub use affiliation::{Affiliation, NewAffiliation}; +pub use contribution::{Contribution, NewContribution}; +pub use contributor::{Contributor, NewContributor}; +pub use file::{ + parse_doi, validate_file_extension, CompleteFileUpload, File, FileType, FileUpload, + FileUploadResponse, NewFile, NewFileUpload, NewFrontcoverFileUpload, NewPublicationFileUpload, +}; +pub use funding::{Funding, NewFunding}; +pub use imprint::{Imprint, NewImprint}; +pub use institution::{Institution, NewInstitution}; +pub use issue::{Issue, NewIssue}; +pub use language::{Language, NewLanguage}; +pub use locale::LocaleCode; +pub use location::{Location, NewLocation}; +pub use price::{NewPrice, Price}; +pub use publication::{NewPublication, Publication}; +pub use publisher::{NewPublisher, Publisher}; +pub use r#abstract::{Abstract, NewAbstract}; +pub use reference::{NewReference, Reference}; +pub use series::{NewSeries, Series}; +pub use subject::{NewSubject, Subject}; +pub use title::{NewTitle, Title, TitleOrderBy}; +pub use work::{NewWork, Work}; +pub use work_relation::{NewWorkRelation, WorkRelation}; diff --git a/thoth-api/src/schema.rs b/thoth-api/src/schema.rs index 71fc7e01..63200055 100644 --- a/thoth-api/src/schema.rs +++ b/thoth-api/src/schema.rs @@ -59,6 +59,10 @@ pub mod sql_types { #[diesel(postgres_type(name = "markup_format"))] pub struct MarkupFormat; + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] + #[diesel(postgres_type(name = "file_type"))] + pub struct FileType; + #[derive(diesel::sql_types::SqlType, diesel::query_builder::QueryId)] #[diesel(postgres_type(name = "contact_type"))] pub struct ContactType; @@ -267,6 +271,10 @@ table! { imprint_name -> Text, imprint_url -> Nullable, crossmark_doi -> Nullable, + s3_bucket -> Nullable, + s3_region -> Nullable, + cdn_domain -> Nullable, + cloudfront_dist_id -> Nullable, created_at -> Timestamptz, updated_at -> Timestamptz, } @@ -708,6 +716,42 @@ table! { } } +table! { + use diesel::sql_types::*; + use super::sql_types::FileType; + + file (file_id) { + file_id -> Uuid, + file_type -> FileType, + work_id -> Nullable, + publication_id -> Nullable, + object_key -> Text, + cdn_url -> Text, + mime_type -> Text, + bytes -> Int8, + sha256 -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +table! { + use diesel::sql_types::*; + use super::sql_types::FileType; + + file_upload (file_upload_id) { + file_upload_id -> Uuid, + file_type -> FileType, + work_id -> Nullable, + publication_id -> Nullable, + declared_mime_type -> Text, + declared_extension -> Text, + declared_sha256 -> Text, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + table! { use diesel::sql_types::*; @@ -797,6 +841,10 @@ joinable!(work_relation -> work (relator_work_id)); joinable!(work_relation_history -> account (account_id)); joinable!(work_relation_history -> work_relation (work_relation_id)); joinable!(work_title -> work (work_id)); +joinable!(file -> work (work_id)); +joinable!(file -> publication (publication_id)); +joinable!(file_upload -> work (work_id)); +joinable!(file_upload -> publication (publication_id)); allow_tables_to_appear_in_same_query!( abstract_history, @@ -843,4 +891,6 @@ allow_tables_to_appear_in_same_query!( work_relation, work_relation_history, work_title, + file, + file_upload, ); diff --git a/thoth-api/src/storage/mod.rs b/thoth-api/src/storage/mod.rs new file mode 100644 index 00000000..66ec9c2b --- /dev/null +++ b/thoth-api/src/storage/mod.rs @@ -0,0 +1,259 @@ +#[cfg(feature = "backend")] +use aws_config::Region; +#[cfg(feature = "backend")] +use aws_sdk_cloudfront::Client as CloudFrontClient; +#[cfg(feature = "backend")] +use aws_sdk_s3::{presigning::PresigningConfig, types::ChecksumAlgorithm, Client as S3Client}; +#[cfg(feature = "backend")] +use std::time::Duration as StdDuration; +#[cfg(feature = "backend")] +use thoth_errors::{ThothError, ThothResult}; +#[cfg(feature = "backend")] +use uuid::Uuid; + +#[cfg(feature = "backend")] +use crate::model::imprint::Imprint; + +/// Storage configuration extracted from an imprint +#[cfg(feature = "backend")] +pub struct StorageConfig { + pub s3_bucket: String, + pub s3_region: String, + pub cdn_domain: String, + pub cloudfront_dist_id: String, +} + +#[cfg(feature = "backend")] +impl StorageConfig { + /// Extract storage configuration from an imprint + pub fn from_imprint(imprint: &Imprint) -> ThothResult { + match ( + &imprint.s3_bucket, + &imprint.s3_region, + &imprint.cdn_domain, + &imprint.cloudfront_dist_id, + ) { + (Some(bucket), Some(region), Some(domain), Some(dist_id)) => Ok(StorageConfig { + s3_bucket: bucket.clone(), + s3_region: region.clone(), + cdn_domain: domain.clone(), + cloudfront_dist_id: dist_id.clone(), + }), + _ => Err(ThothError::InternalError( + "Imprint is not configured for file hosting".to_string(), + )), + } + } +} + +/// Create an S3 client configured for the given region +#[cfg(feature = "backend")] +pub async fn create_s3_client(region: &str) -> S3Client { + eprintln!("S3_DEBUG: Creating S3 client for region: {}", region); + + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region.to_string())) + .load() + .await; + + let s3_config = aws_sdk_s3::config::Builder::from(&config) + .force_path_style(true) + .build(); + + eprintln!("S3_DEBUG: S3 client created with path-style addressing"); + S3Client::from_conf(s3_config) +} + +/// Create a CloudFront client +#[cfg(feature = "backend")] +pub async fn create_cloudfront_client() -> CloudFrontClient { + let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + CloudFrontClient::new(&config) +} + +/// Generate a presigned PUT URL for uploading a file to S3 +/// required headers: +/// - Content-Type: from declared_mime_type +/// - x-amz-checksum-sha256: base64-encoded SHA-256 checksum +#[cfg(feature = "backend")] +pub async fn presign_put_for_upload( + s3_client: &S3Client, + bucket: &str, + temp_key: &str, + declared_mime_type: &str, + declared_sha256: &str, + expires_in_minutes: u64, +) -> ThothResult { + eprintln!( + "PRESIGN_DEBUG: Creating presigned URL for bucket: {}, key: {}", + bucket, temp_key + ); + use base64::{engine::general_purpose, Engine as _}; + + // hex SHA-256 to base64 + let sha256_bytes = hex::decode(declared_sha256) + .map_err(|e| ThothError::InternalError(format!("Invalid SHA-256 hex: {}", e)))?; + let sha256_base64 = general_purpose::STANDARD.encode(&sha256_bytes); + + let expires_in = StdDuration::from_secs(expires_in_minutes * 60); + + let presigning_config = PresigningConfig::expires_in(expires_in).map_err(|e| { + ThothError::InternalError(format!("Failed to create presigning config: {}", e)) + })?; + + let request = s3_client + .put_object() + .bucket(bucket) + .key(temp_key) + .content_type(declared_mime_type) + .checksum_sha256(sha256_base64) + .checksum_algorithm(ChecksumAlgorithm::Sha256); + + let presigned_request = request.presigned(presigning_config).await.map_err(|e| { + eprintln!("PRESIGN_DEBUG: Presigning failed with error: {:?}", e); + eprintln!("PRESIGN_DEBUG: Bucket: {}, Key: {}", bucket, temp_key); + ThothError::InternalError(format!("Failed to presign request: {}", e)) + })?; + + Ok(presigned_request.uri().to_string()) +} + +/// Copy an object from temporary upload location to final canonical location +#[cfg(feature = "backend")] +pub async fn copy_temp_object_to_final( + s3_client: &S3Client, + bucket: &str, + temp_key: &str, + final_key: &str, +) -> ThothResult<()> { + let copy_source = format!("{}/{}", bucket, temp_key); + + s3_client + .copy_object() + .bucket(bucket) + .copy_source(copy_source) + .key(final_key) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to copy object: {}", e)))?; + + Ok(()) +} + +/// Delete a temporary upload object from S3 +#[cfg(feature = "backend")] +pub async fn delete_temp_object( + s3_client: &S3Client, + bucket: &str, + temp_key: &str, +) -> ThothResult<()> { + s3_client + .delete_object() + .bucket(bucket) + .key(temp_key) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to delete object: {}", e)))?; + + Ok(()) +} + +/// Get object metadata (HeadObject) from S3 +#[cfg(feature = "backend")] +pub async fn head_object( + s3_client: &S3Client, + bucket: &str, + key: &str, +) -> ThothResult<(i64, String)> { + let response = s3_client + .head_object() + .bucket(bucket) + .key(key) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to head object: {}", e)))?; + + let bytes = response.content_length().unwrap_or(0) as i64; + let mime_type = response + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + Ok((bytes, mime_type)) +} + +/// Invalidate CloudFront cache for a given path +#[cfg(feature = "backend")] +pub async fn invalidate_cloudfront( + cloudfront_client: &CloudFrontClient, + distribution_id: &str, + path: &str, +) -> ThothResult { + use aws_sdk_cloudfront::types::Paths; + + let paths = Paths::builder() + .quantity(1) + .items(path) + .build() + .map_err(|e| ThothError::InternalError(format!("Failed to build paths: {}", e)))?; + + let response = cloudfront_client + .create_invalidation() + .distribution_id(distribution_id) + .invalidation_batch( + aws_sdk_cloudfront::types::InvalidationBatch::builder() + .paths(paths) + .caller_reference(format!("thoth-{}", Uuid::new_v4())) + .build() + .map_err(|e| { + ThothError::InternalError(format!("Failed to build invalidation batch: {}", e)) + })?, + ) + .send() + .await + .map_err(|e| ThothError::InternalError(format!("Failed to create invalidation: {}", e)))?; + + let invalidation_id = response + .invalidation() + .map(|inv| inv.id().to_string()) + .ok_or_else(|| ThothError::InternalError("No invalidation ID returned".to_string()))?; + + Ok(invalidation_id) +} + +/// Compute the temporary S3 key for an upload +#[cfg(feature = "backend")] +pub fn temp_key(file_upload_id: &Uuid) -> String { + format!("uploads/{}", file_upload_id) +} + +/// Compute the canonical object key for a publication file +#[cfg(feature = "backend")] +pub fn canonical_publication_key(doi_prefix: &str, doi_suffix: &str, extension: &str) -> String { + format!( + "{}/{}.{}", + doi_prefix.to_lowercase(), + doi_suffix.to_lowercase(), + extension.to_lowercase() + ) +} + +/// Compute the canonical object key for a frontcover file +#[cfg(feature = "backend")] +pub fn canonical_frontcover_key(doi_prefix: &str, doi_suffix: &str, extension: &str) -> String { + format!( + "{}/{}_frontcover.{}", + doi_prefix.to_lowercase(), + doi_suffix.to_lowercase(), + extension.to_lowercase() + ) +} + +/// Build the full CDN URL from domain and object key +#[cfg(feature = "backend")] +pub fn build_cdn_url(cdn_domain: &str, object_key: &str) -> String { + // Ensure cdn_domain doesn't end with / and object_key doesn't have a leading / + let domain = cdn_domain.trim_end_matches('/'); + let key = object_key.trim_start_matches('/'); + format!("https://{}/{}", domain, key) +} diff --git a/thoth-errors/src/database_errors.rs b/thoth-errors/src/database_errors.rs index 7c37935d..754c1064 100644 --- a/thoth-errors/src/database_errors.rs +++ b/thoth-errors/src/database_errors.rs @@ -52,6 +52,7 @@ static DATABASE_CONSTRAINT_ERRORS: Map<&'static str, &'static str> = phf_map! { "funding_project_shortname_check" => "Project shortname must not be an empty string.", "imprint_imprint_name_check" => "Imprint name must not be an empty string.", "imprint_imprint_url_check" => "Invalid URL.", + "imprint_storage_cfg_all_or_none" => "Storage configuration must be complete: all fields (S3 bucket, region, CDN domain, CloudFront distribution ID) must be provided together, or all must be empty.", "imprint_uniq_idx" => "An imprint with this name already exists.", "institution_doi_uniq_idx" => "An institution with this DOI already exists.", "institution_institution_doi_check" => "Invalid DOI.", @@ -66,6 +67,11 @@ static DATABASE_CONSTRAINT_ERRORS: Map<&'static str, &'static str> = phf_map! { "location_uniq_canonical_true_idx" => "A canonical location for this publication already exists.", "location_uniq_platform_idx" => "A location on the selected platform already exists.", "location_url_check" => "A location must have a landing page and/or a full text URL.", + "file_frontcover_work_unique_idx" => "A frontcover file for this work already exists.", + "file_object_key_unique_idx" => "A file with this object key already exists.", + "file_publication_unique_idx" => "A publication file for this publication already exists.", + "file_type_check" => "File type is invalid: frontcover must have work_id, publication must have publication_id.", + "file_upload_type_check" => "File upload type is invalid: frontcover must have work_id, publication must have publication_id.", "orcid_uniq_idx" => "A contributor with this ORCID ID already exists.", "price_publication_id_currency_code_uniq" => "A price in this currency already exists for this publication.", "price_unit_price_check" => "Price values must be greater than zero. To indicate an unpriced Publication, omit all Prices.",