From 9ee5048a434dc1a22237fca1feae873eeb3a24d4 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Sun, 9 Aug 2026 00:15:40 +0100 Subject: [PATCH] Added Cloudflare R2 media storage support --- Cargo.lock | 776 +++++++++++++++++++++++++++-- Cargo.toml | 3 +- Dockerfile | 3 +- src/admin.rs | 344 ++++++++++--- src/bin/migrate_uploads_to_r2.rs | 144 ++++++ src/i18n.rs | 30 ++ src/main.rs | 2 - src/public.rs | 246 ++++++--- src/uploads.rs | 497 ++++++++++++++++-- templates/admin/media.html | 8 +- templates/admin/schedule_edit.html | 8 +- templates/admin/settings.html | 39 ++ templates/admin/testimonials.html | 6 +- templates/client_portal.html | 8 +- templates/landing.html | 4 +- 15 files changed, 1883 insertions(+), 235 deletions(-) create mode 100644 src/bin/migrate_uploads_to_r2.rs diff --git a/Cargo.lock b/Cargo.lock index 58cc6c1..5a01a6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -268,6 +277,332 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" +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", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.141.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f9420d3a2467eed22ed3635ca653653162c386a0b0f65c78189f9bd3c1379e" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.5.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sigv4" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.5.0", + "percent-encoding", + "sha2 0.11.0", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.65.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.5.0", + "http-body 1.0.1", + "http-body-util", + "md-5 0.11.0", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.61.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.15", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls 0.24.2", + "pin-project-lite", + "rustls 0.21.12", + "rustls-native-certs", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.5.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.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -277,10 +612,10 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.9.0", "hyper-util", "itoa", "matchit", @@ -304,8 +639,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -341,6 +676,16 @@ 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.3" @@ -444,6 +789,16 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[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 = "cc" version = "1.2.61" @@ -626,6 +981,16 @@ dependencies = [ "version_check", ] +[[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" @@ -657,7 +1022,7 @@ dependencies = [ "futures-util", "grass", "hex", - "http", + "http 1.5.0", "http-body-util", "humantime", "indexmap", @@ -710,8 +1075,8 @@ dependencies = [ "form_urlencoded", "futures-core", "futures-util", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "http-body-util", "indexmap", "serde", @@ -779,6 +1144,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.1", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1177,6 +1552,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1220,7 +1601,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1235,6 +1616,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1447,6 +1834,44 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1465,7 +1890,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1564,6 +2000,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.5.0" @@ -1574,6 +2021,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" @@ -1581,7 +2039,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.5.0", ] [[package]] @@ -1592,8 +2050,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -1625,6 +2083,30 @@ dependencies = [ "typenum", ] +[[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.9.0" @@ -1635,8 +2117,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -1646,18 +2128,33 @@ 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.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http", - "hyper", + "http 1.5.0", + "hyper 1.9.0", "hyper-util", - "rustls", + "rustls 0.23.40", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower-service", "webpki-roots", ] @@ -1672,14 +2169,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.5.0", + "http-body 1.0.1", + "hyper 1.9.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1996,7 +2493,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2060,6 +2557,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2091,6 +2597,16 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2180,11 +2696,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.5.0", "httparse", "memchr", "mime", - "spin", + "spin 0.9.8", "version_check", ] @@ -2276,6 +2792,18 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "p256" version = "0.13.2" @@ -2433,6 +2961,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -2586,8 +3120,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", - "socket2", + "rustls 0.23.40", + "socket2 0.6.3", "thiserror", "tokio", "tracing", @@ -2606,7 +3140,7 @@ dependencies = [ "rand 0.9.4", "ring", "rustc-hash", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "slab", "thiserror", @@ -2624,7 +3158,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -2744,6 +3278,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -2759,25 +3299,25 @@ dependencies = [ "base64", "bytes", "futures-core", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", + "hyper 1.9.0", + "hyper-rustls 0.27.9", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls", + "rustls 0.23.40", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "tower", "tower-http", "tower-service", @@ -2854,6 +3394,18 @@ dependencies = [ "semver", ] +[[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.40" @@ -2863,11 +3415,23 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2878,6 +3442,16 @@ 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.13" @@ -2901,12 +3475,31 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sea-query" version = "0.32.7" @@ -2942,6 +3535,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3047,6 +3663,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3151,6 +3778,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -3170,6 +3807,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.7.3" @@ -3302,14 +3945,14 @@ dependencies = [ "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", "rand 0.8.6", "rsa", "serde", - "sha1", + "sha1 0.10.6", "sha2 0.10.9", "smallvec", "sqlx-core", @@ -3342,7 +3985,7 @@ dependencies = [ "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.6", @@ -3565,7 +4208,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3581,13 +4224,23 @@ dependencies = [ "syn 2.0.117", ] +[[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.40", "tokio", ] @@ -3602,6 +4255,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -3669,7 +4336,7 @@ dependencies = [ "axum-core", "cookie", "futures-util", - "http", + "http 1.5.0", "parking_lot", "pin-project-lite", "tower-layer", @@ -3685,8 +4352,8 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http", - "http-body", + "http 1.5.0", + "http-body 1.0.1", "iri-string", "pin-project-lite", "tower", @@ -3713,7 +4380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" dependencies = [ "async-trait", - "http", + "http 1.5.0", "time", "tokio", "tower-cookies", @@ -3733,7 +4400,7 @@ dependencies = [ "async-trait", "base64", "futures", - "http", + "http 1.5.0", "parking_lot", "rand 0.9.4", "serde", @@ -3951,6 +4618,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" @@ -4093,6 +4766,7 @@ name = "web-petting" version = "1.0.3" dependencies = [ "async-trait", + "aws-sdk-s3", "base64", "chrono", "chrono-tz", @@ -4123,7 +4797,7 @@ dependencies = [ "base64ct", "ece-native", "hkdf 0.13.0", - "http", + "http 1.5.0", "jwt-simple", "p256", "serde", @@ -4559,6 +5233,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index 7354538..410a18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "web-petting" -version = "1.0.3" +version = "1.0.4" edition = "2024" default-run = "web-petting" @@ -24,3 +24,4 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } web-push-native = "0.5" async-trait = "0.1" +aws-sdk-s3 = { version = "1", default-features = false, features = ["rustls", "rt-tokio"] } diff --git a/Dockerfile b/Dockerfile index 96e9ef4..1013d45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,13 +4,14 @@ WORKDIR /app COPY Cargo.toml Cargo.lock* ./ COPY src ./src COPY templates ./templates -RUN cargo build --release +RUN cargo build --release --bins FROM debian:bookworm-slim RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /data ENV WEB_PETTING_UPLOAD_DIR=/data/uploads COPY --from=builder /app/target/release/web-petting /usr/local/bin/web-petting +COPY --from=builder /app/target/release/migrate_uploads_to_r2 /usr/local/bin/migrate_uploads_to_r2 COPY static /app/static EXPOSE 3000 CMD ["web-petting"] diff --git a/src/admin.rs b/src/admin.rs index 30baed3..dfd128d 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -13,6 +13,7 @@ use image::codecs::jpeg::JpegEncoder; use image::imageops::FilterType; use serde::Deserialize; use std::io::Cursor; +use std::ops::Deref; use crate::i18n::{Lang, Translations}; use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit}; @@ -145,6 +146,7 @@ fn transcode_uploaded_image(data: &[u8], ext: &str) -> cot::Result cot::Result { if let Some(encoded) = transcode_uploaded_image(data, ext)? { let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.jpg")); - crate::uploads::write_db_file(&path, &encoded) + storage + .write(&path, &encoded) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; - crate::uploads::ensure_thumbnail(&path) + .map_err(crate::uploads::storage_error)?; + crate::uploads::write_thumbnail(storage, &path, &encoded) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + .map_err(crate::uploads::storage_error)?; Ok(path) } else { let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}")); - crate::uploads::write_db_file(&path, data) + storage + .write(&path, data) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + .map_err(crate::uploads::storage_error)?; Ok(path) } } @@ -337,9 +341,12 @@ struct SettingsTemplate<'a> { admin_name: &'a str, settings: Vec, saved: bool, + error: Option<&'a str>, auth_password_checked: bool, auth_sso_checked: bool, client_notifications_checked: bool, + r2_enabled_checked: bool, + r2_secret_configured: bool, push_subscribers: Vec, } @@ -419,12 +426,27 @@ struct ScheduleEditTemplate<'a> { visit: Visit, client: Client, users: Vec, - media: Vec, + media: Vec, +} + +#[derive(Debug)] +struct MediaView { + media: Media, + url: String, + thumbnail_url: String, +} + +impl Deref for MediaView { + type Target = Media; + + fn deref(&self) -> &Self::Target { + &self.media + } } #[derive(Debug)] struct MediaItem { - media: Media, + media: MediaView, client_name: String, visit_date: Option, } @@ -453,6 +475,35 @@ struct MediaUploadTemplate<'a> { visit_label: &'a str, } +async fn admin_media_view( + storage: &crate::uploads::Storage, + media: Media, +) -> cot::Result { + let media_id = media.id.unwrap(); + let url = storage + .public_url(&media.file_path, format!("/admin/uploads/{media_id}")) + .await + .map_err(crate::uploads::storage_error)?; + let thumbnail_path = + if media.file_type == "photo" && crate::uploads::supports_thumbnail(&media.file_path) { + crate::uploads::thumbnail_db_path(&media.file_path) + } else { + media.file_path.clone() + }; + let thumbnail_url = storage + .public_url( + &thumbnail_path, + format!("/admin/uploads/{media_id}/thumbnail"), + ) + .await + .map_err(crate::uploads::storage_error)?; + Ok(MediaView { + media, + url, + thumbnail_url, + }) +} + // --------------------------------------------------------------------------- // Auth Handlers // --------------------------------------------------------------------------- @@ -1254,15 +1305,28 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot: .find(|s| s.key == "client_notifications_enabled") .map(|s| s.value == "true") .unwrap_or(false); + let r2_enabled_checked = settings + .iter() + .find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY) + .map(|setting| setting.value == "true") + .unwrap_or(false); + let r2_secret_configured = settings + .iter() + .find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY) + .map(|setting| !setting.value.trim().is_empty()) + .unwrap_or(false); let body = SettingsTemplate { t: lang.t(), lang, admin_name: &admin_name, settings, saved: false, + error: None, auth_password_checked, auth_sso_checked, client_notifications_checked, + r2_enabled_checked, + r2_secret_configured, push_subscribers: load_push_subscribers(&db).await?, } .render()?; @@ -1346,12 +1410,18 @@ struct SettingsForm { vapid_public_key: String, vapid_private_key: String, vapid_subject: String, + r2_account_id: String, + r2_bucket: String, + r2_access_key_id: String, + r2_secret_access_key: String, #[serde(default)] auth_password_enabled: Option, #[serde(default)] auth_sso_enabled: Option, #[serde(default)] client_notifications_enabled: Option, + #[serde(default)] + r2_enabled: Option, } async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result { @@ -1374,6 +1444,21 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: let vapid_keys_changed = had_vapid_keys && (old_value("vapid_public_key").trim() != form.vapid_public_key.trim() || old_value("vapid_private_key").trim() != form.vapid_private_key.trim()); + let r2_secret_access_key = if form.r2_secret_access_key.trim().is_empty() { + old_value(crate::uploads::R2_SECRET_ACCESS_KEY_KEY).to_string() + } else { + form.r2_secret_access_key.trim().to_string() + }; + let requested_r2_enabled = form.r2_enabled.is_some(); + let r2_config_valid = crate::uploads::R2Config::fields_are_valid( + &form.r2_account_id, + &form.r2_bucket, + &form.r2_access_key_id, + &r2_secret_access_key, + ); + let r2_enabled = requested_r2_enabled && r2_config_valid; + let settings_error = + (requested_r2_enabled && !r2_config_valid).then_some(lang.t().settings_r2_error_incomplete); for (key, value) in [ ("telegram_bot_token", form.telegram_bot_token), @@ -1391,6 +1476,26 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: ("vapid_public_key", form.vapid_public_key), ("vapid_private_key", form.vapid_private_key), ("vapid_subject", form.vapid_subject), + ( + crate::uploads::R2_ACCOUNT_ID_KEY, + form.r2_account_id.trim().to_ascii_lowercase(), + ), + ( + crate::uploads::R2_BUCKET_KEY, + form.r2_bucket.trim().to_string(), + ), + ( + crate::uploads::R2_ACCESS_KEY_ID_KEY, + form.r2_access_key_id.trim().to_string(), + ), + ( + crate::uploads::R2_SECRET_ACCESS_KEY_KEY, + r2_secret_access_key, + ), + ( + crate::uploads::R2_ENABLED_KEY, + if r2_enabled { "true" } else { "false" }.to_string(), + ), ( "auth_password_enabled", if form.auth_password_enabled.is_some() { @@ -1462,15 +1567,28 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: .find(|s| s.key == "client_notifications_enabled") .map(|s| s.value == "true") .unwrap_or(false); + let r2_enabled_checked = settings + .iter() + .find(|setting| setting.key == crate::uploads::R2_ENABLED_KEY) + .map(|setting| setting.value == "true") + .unwrap_or(false); + let r2_secret_configured = settings + .iter() + .find(|setting| setting.key == crate::uploads::R2_SECRET_ACCESS_KEY_KEY) + .map(|setting| !setting.value.trim().is_empty()) + .unwrap_or(false); let rendered = SettingsTemplate { t: lang.t(), lang, admin_name: &admin_name, settings, - saved: true, + saved: settings_error.is_none(), + error: settings_error, auth_password_checked, auth_sso_checked, client_notifications_checked, + r2_enabled_checked, + r2_secret_configured, push_subscribers: load_push_subscribers(&db).await?, } .render()?; @@ -1964,6 +2082,11 @@ async fn schedule_edit_page( .unwrap_or(false) }); visit_media.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + let storage = crate::uploads::Storage::load(&db).await?; + let mut media_views = Vec::with_capacity(visit_media.len()); + for media in visit_media { + media_views.push(admin_media_view(&storage, media).await?); + } let body = ScheduleEditTemplate { t: lang.t(), lang, @@ -1971,7 +2094,7 @@ async fn schedule_edit_page( visit, client, users, - media: visit_media, + media: media_views, } .render()?; html_response(body, lang) @@ -2157,28 +2280,25 @@ async fn media_page(request: Request, session: Session, db: Database) -> cot::Re let page = requested_page.min(total_pages); let page_start = (page - 1) * MEDIA_PER_PAGE; - let items: Vec = media_list - .into_iter() - .skip(page_start) - .take(MEDIA_PER_PAGE) - .map(|m| { - let cid: i64 = m.client_id.primary_key().unwrap(); - let client = clients_all.iter().find(|c| c.id.unwrap() == cid); - let visit_date = m - .visit_id - .as_ref() - .and_then(|fk| { - let vid: i64 = fk.primary_key().unwrap(); - visits_all.iter().find(|v| v.id.unwrap() == vid) - }) - .map(|v| v.visit_date.to_string()); - MediaItem { - client_name: client.map(|c| c.name.clone()).unwrap_or_default(), - visit_date, - media: m, - } - }) - .collect(); + let storage = crate::uploads::Storage::load(&db).await?; + let mut items = Vec::new(); + for media in media_list.into_iter().skip(page_start).take(MEDIA_PER_PAGE) { + let cid: i64 = media.client_id.primary_key().unwrap(); + let client = clients_all.iter().find(|c| c.id.unwrap() == cid); + let visit_date = media + .visit_id + .as_ref() + .and_then(|fk| { + let vid: i64 = fk.primary_key().unwrap(); + visits_all.iter().find(|v| v.id.unwrap() == vid) + }) + .map(|v| v.visit_date.to_string()); + items.push(MediaItem { + client_name: client.map(|c| c.name.clone()).unwrap_or_default(), + visit_date, + media: admin_media_view(&storage, media).await?, + }); + } let active_clients = clients_all .into_iter() @@ -2275,10 +2395,12 @@ async fn media_upload_submit( futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); let mut multipart = multer::Multipart::new(stream, boundary); + let storage = crate::uploads::Storage::load(&db).await?; let upload_dir = crate::uploads::media_dir(client_id, visit_id); - crate::uploads::create_logical_dir(&upload_dir) + storage + .create_logical_dir(&upload_dir) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + .map_err(crate::uploads::storage_error)?; let mut caption = String::new(); let mut saved_files: Vec<(String, String)> = Vec::new(); // (path, file_type) @@ -2326,12 +2448,13 @@ async fn media_upload_submit( continue; } let file_path = if file_type == "photo" { - save_uploaded_image(&upload_dir, file_id, &ext, &data).await? + save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await? } else { let path = crate::uploads::join_db_path(&upload_dir, &format!("{file_id}.{ext}")); - crate::uploads::write_db_file(&path, &data) + storage + .write(&path, &data) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + .map_err(crate::uploads::storage_error)?; path }; @@ -2382,11 +2505,12 @@ async fn media_delete( .get("referer") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + let storage = crate::uploads::Storage::load(&db).await?; if let Some(mut m) = query!(Media, $id == media_id).get(&db).await? { let file_path = m.file_path.clone(); m.status = "archived".to_string(); m.save(&db).await?; - if let Err(err) = crate::uploads::remove_db_file(&file_path).await { + if let Err(err) = storage.remove(&file_path).await { tracing::warn!( target: "uploads", media_id, @@ -2397,10 +2521,8 @@ async fn media_delete( ); } let thumbnail_path = crate::uploads::thumbnail_db_path(&file_path); - if let Err(error) = crate::uploads::remove_db_file(&thumbnail_path).await { - if error.kind() != std::io::ErrorKind::NotFound { - tracing::warn!(%error, %thumbnail_path, "failed to remove media thumbnail"); - } + if let Err(error) = storage.remove(&thumbnail_path).await { + tracing::warn!(%error, %thumbnail_path, "failed to remove media thumbnail"); } } let redirect_url = referer @@ -2423,11 +2545,35 @@ async fn serve_upload_thumbnail( Some(media) if media.status == "active" && media.file_type == "photo" => media, _ => return Html::new("404").into_response(), }; - match crate::uploads::ensure_thumbnail(&media.file_path).await { + let storage = crate::uploads::Storage::load(&db).await?; + let display_path = if crate::uploads::supports_thumbnail(&media.file_path) { + match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await { + Ok(path) => path, + Err(error) => { + tracing::warn!(media_id, %error, "failed to create media thumbnail"); + media.file_path.clone() + } + } + } else { + media.file_path.clone() + }; + if storage.is_r2() { + let url = storage + .public_url(&display_path, String::new()) + .await + .map_err(crate::uploads::storage_error)?; + return Redirect::new(url).into_response(); + } + let display_content_type = if display_path == media.file_path { + crate::uploads::content_type_for_path(&media.file_path) + } else { + "image/jpeg" + }; + match crate::uploads::ranged_local_file_response(&display_path, display_content_type, None) + .await + { Ok(path) => { - let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None) - .await - .map_err(|error| cot::Error::internal(error.to_string()))?; + let mut response = path; response.headers_mut().insert( "cache-control", "private, max-age=31536000, immutable".parse().unwrap(), @@ -2435,8 +2581,8 @@ async fn serve_upload_thumbnail( Ok(response) } Err(error) => { - tracing::warn!(media_id, %error, "failed to create media thumbnail"); - crate::uploads::ranged_file_response( + tracing::warn!(media_id, %error, "failed to read media thumbnail"); + crate::uploads::ranged_local_file_response( &media.file_path, crate::uploads::content_type_for_path(&media.file_path), None, @@ -2470,21 +2616,22 @@ async fn serve_upload( .and_then(|value| value.to_str().ok()) .map(str::to_owned); - match { - let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "heic" | "heif" => "image/heic", - "webp" => "image/webp", - "mp4" => "video/mp4", - "mov" => "video/quicktime", - "avi" => "video/x-msvideo", - "mkv" => "video/x-matroska", - "webm" => "video/webm", - _ => "application/octet-stream", - }; - crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await - } { + let storage = crate::uploads::Storage::load(&db).await?; + if storage.is_r2() { + let url = storage + .public_url(&media.file_path, String::new()) + .await + .map_err(crate::uploads::storage_error)?; + return Redirect::new(url).into_response(); + } + + match crate::uploads::ranged_local_file_response( + &media.file_path, + crate::uploads::content_type_for_path(&media.file_path), + range.as_deref(), + ) + .await + { Ok(response) => Ok(response), Err(err) => { tracing::warn!( @@ -2510,7 +2657,43 @@ struct TestimonialsTemplate<'a> { t: &'a Translations, lang: Lang, admin_name: String, - testimonials: Vec, + testimonials: Vec, +} + +#[derive(Debug)] +struct TestimonialView { + testimonial: Testimonial, + image_url: Option, +} + +impl Deref for TestimonialView { + type Target = Testimonial; + + fn deref(&self) -> &Self::Target { + &self.testimonial + } +} + +async fn admin_testimonial_view( + storage: &crate::uploads::Storage, + testimonial: Testimonial, +) -> cot::Result { + let image_url = match testimonial.image_path.as_deref() { + Some(path) => Some( + storage + .public_url( + path, + format!("/admin/testimonials/{}/image", testimonial.id.unwrap()), + ) + .await + .map_err(crate::uploads::storage_error)?, + ), + None => None, + }; + Ok(TestimonialView { + testimonial, + image_url, + }) } async fn testimonials_page( @@ -2530,12 +2713,17 @@ async fn testimonials_page( .cmp(&b.sort_order) .then(b.id.unwrap().cmp(&a.id.unwrap())) }); + let storage = crate::uploads::Storage::load(&db).await?; + let mut testimonial_views = Vec::with_capacity(testimonials.len()); + for testimonial in testimonials { + testimonial_views.push(admin_testimonial_view(&storage, testimonial).await?); + } let body = TestimonialsTemplate { t: lang.t(), lang, admin_name, - testimonials, + testimonials: testimonial_views, } .render()?; html_response(body, lang) @@ -2558,6 +2746,7 @@ async fn testimonial_add( let stream = futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); let mut multipart = multer::Multipart::new(stream, boundary); + let storage = crate::uploads::Storage::load(&db).await?; let mut text = String::new(); let mut author_note = String::new(); @@ -2604,11 +2793,12 @@ async fn testimonial_add( continue; } let upload_dir = crate::uploads::testimonials_dir(); - crate::uploads::create_logical_dir(&upload_dir) + storage + .create_logical_dir(&upload_dir) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + .map_err(crate::uploads::storage_error)?; let file_id = uuid::Uuid::new_v4(); - let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?; + let path = save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?; image_path = Some(path); } _ => {} @@ -2698,6 +2888,7 @@ async fn testimonial_edit( let stream = futures::stream::once(async move { Result::<_, std::convert::Infallible>::Ok(bytes) }); let mut multipart = multer::Multipart::new(stream, boundary); + let storage = crate::uploads::Storage::load(&db).await?; let mut text = String::new(); let mut author_note = String::new(); @@ -2752,11 +2943,12 @@ async fn testimonial_edit( continue; } let upload_dir = crate::uploads::testimonials_dir(); - crate::uploads::create_logical_dir(&upload_dir) + storage + .create_logical_dir(&upload_dir) .await - .map_err(|e| cot::Error::internal(e.to_string()))?; + .map_err(crate::uploads::storage_error)?; let file_id = uuid::Uuid::new_v4(); - let path = save_uploaded_image(&upload_dir, file_id, &ext, &data).await?; + let path = save_uploaded_image(&storage, &upload_dir, file_id, &ext, &data).await?; new_image_path = Some(path); } _ => {} @@ -2797,7 +2989,15 @@ async fn serve_testimonial_image( Some(p) => p.clone(), None => return Html::new("404").into_response(), }; - match crate::uploads::read_db_file(&path).await { + let storage = crate::uploads::Storage::load(&db).await?; + if storage.is_r2() { + let url = storage + .public_url(&path, String::new()) + .await + .map_err(crate::uploads::storage_error)?; + return Redirect::new(url).into_response(); + } + match storage.read(&path).await { Ok(data) => { let content_type = match path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", diff --git a/src/bin/migrate_uploads_to_r2.rs b/src/bin/migrate_uploads_to_r2.rs new file mode 100644 index 0000000..8f602c2 --- /dev/null +++ b/src/bin/migrate_uploads_to_r2.rs @@ -0,0 +1,144 @@ +#[allow(dead_code)] +#[path = "../models.rs"] +mod models; +#[allow(dead_code)] +#[path = "../uploads.rs"] +mod uploads; + +use std::collections::BTreeMap; + +use cot::db::{Database, Model}; +use models::{Media, Testimonial}; + +fn database_url() -> String { + std::env::var("WEB_PETTING_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string()) +} + +async fn add_thumbnail( + sources: &mut BTreeMap, + db_path: &str, + required: bool, +) -> bool { + if !uploads::supports_thumbnail(db_path) { + return true; + } + match uploads::ensure_local_thumbnail(db_path).await { + Ok(thumbnail_path) => { + sources + .entry(thumbnail_path) + .and_modify(|is_required| *is_required |= required) + .or_insert(required); + true + } + Err(error) => { + eprintln!("Could not create thumbnail for {db_path}: {error}"); + !required + } + } +} + +async fn run() -> Result<(), Box> { + let db = Database::new(database_url()).await?; + let storage = uploads::Storage::load_configured_r2(&db).await?; + let mut sources: BTreeMap = BTreeMap::new(); + let mut preparation_failed = false; + + for media in Media::objects().all(&db).await? { + let required = media.status == "active"; + sources + .entry(media.file_path.clone()) + .and_modify(|is_required| *is_required |= required) + .or_insert(required); + if media.file_type == "photo" + && !add_thumbnail(&mut sources, &media.file_path, required).await + { + preparation_failed = true; + } + } + + for testimonial in Testimonial::objects().all(&db).await? { + let Some(image_path) = testimonial.image_path else { + continue; + }; + let required = testimonial.status == "active"; + sources + .entry(image_path.clone()) + .and_modify(|is_required| *is_required |= required) + .or_insert(required); + // Testimonial thumbnails are retained too, even though the landing page + // currently displays the processed full-size image. + let _ = add_thumbnail(&mut sources, &image_path, false).await; + } + + let mut uploaded = 0usize; + let mut skipped = 0usize; + let mut missing = 0usize; + let mut failed = 0usize; + + for (db_path, required) in sources { + let local_path = uploads::resolve_db_path(&db_path); + match tokio::fs::try_exists(&local_path).await { + Ok(true) => {} + Ok(false) => { + eprintln!("Missing local file {}", local_path.display()); + missing += 1; + if required { + failed += 1; + } + continue; + } + Err(error) => { + eprintln!( + "Could not inspect local file {}: {error}", + local_path.display() + ); + missing += 1; + if required { + failed += 1; + } + continue; + } + }; + + if storage.exists(&db_path).await? { + println!("Already in R2: {db_path}"); + skipped += 1; + continue; + } + + match storage.upload_local_copy(&db_path, &local_path).await { + Ok(()) => { + println!("Uploaded: {db_path}"); + uploaded += 1; + } + Err(error) => { + eprintln!("Failed to upload {db_path}: {error}"); + failed += 1; + } + } + } + + db.close().await?; + println!( + "Migration summary: uploaded={uploaded}, already_present={skipped}, missing_local={missing}, failed={failed}" + ); + println!("Local files were not deleted."); + + if preparation_failed || failed > 0 { + return Err("R2 migration did not complete successfully".into()); + } + Ok(()) +} + +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build Tokio runtime"); + if let Err(error) = runtime.block_on(run()) { + eprintln!("Migration failed: {error}"); + std::process::exit(1); + } +} diff --git a/src/i18n.rs b/src/i18n.rs index 76c32db..6c09231 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -151,6 +151,16 @@ pub struct Translations { pub settings_section_captcha: &'static str, pub settings_section_oidc: &'static str, pub settings_section_general: &'static str, + pub settings_section_storage: &'static str, + pub settings_r2_enabled: &'static str, + pub settings_r2_help: &'static str, + pub settings_r2_account_id: &'static str, + pub settings_r2_bucket: &'static str, + pub settings_r2_access_key_id: &'static str, + pub settings_r2_secret_access_key: &'static str, + pub settings_r2_secret_unchanged: &'static str, + pub settings_r2_migration_help: &'static str, + pub settings_r2_error_incomplete: &'static str, pub settings_client_notifications_enabled: &'static str, pub settings_client_notifications_help: &'static str, pub settings_vapid_public_key: &'static str, @@ -411,6 +421,16 @@ static RU: Translations = Translations { settings_section_captcha: "Защита от ботов", settings_section_oidc: "Единый вход (SSO / OIDC)", settings_section_general: "Сайт", + settings_section_storage: "Хранилище медиа", + settings_r2_enabled: "Использовать Cloudflare R2", + settings_r2_help: "Пока R2 выключен или настроен не полностью, медиа хранится локально. После включения новые файлы и миниатюры загружаются в приватный бакет, а страницы получают подписанные ссылки на 6 часов.", + settings_r2_account_id: "Cloudflare Account ID", + settings_r2_bucket: "Имя R2-бакета", + settings_r2_access_key_id: "R2 Access Key ID", + settings_r2_secret_access_key: "R2 Secret Access Key", + settings_r2_secret_unchanged: "Секрет уже сохранён; оставьте поле пустым, чтобы не менять его", + settings_r2_migration_help: "Порядок перехода: сохраните реквизиты с выключенным R2, выполните в контейнере migrate_uploads_to_r2, затем включите R2 и отключите PVC. Для бакета разрешите CORS GET/HEAD с домена сайта и заголовок Range.", + settings_r2_error_incomplete: "R2 не включён: проверьте Account ID, имя бакета, Access Key ID и Secret Access Key.", settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления", settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.", settings_vapid_public_key: "VAPID — публичный ключ", @@ -661,6 +681,16 @@ static EN: Translations = Translations { settings_section_captcha: "Bot protection", settings_section_oidc: "Single Sign-On (SSO / OIDC)", settings_section_general: "Site", + settings_section_storage: "Media storage", + settings_r2_enabled: "Use Cloudflare R2", + settings_r2_help: "While R2 is disabled or incomplete, media stays in local storage. Once enabled, new files and thumbnails are uploaded to the private bucket and pages receive signed URLs valid for 6 hours.", + settings_r2_account_id: "Cloudflare Account ID", + settings_r2_bucket: "R2 bucket name", + settings_r2_access_key_id: "R2 Access Key ID", + settings_r2_secret_access_key: "R2 Secret Access Key", + settings_r2_secret_unchanged: "A secret is already stored; leave this blank to keep it unchanged", + settings_r2_migration_help: "Migration order: save the credentials with R2 disabled, run migrate_uploads_to_r2 inside the container, then enable R2 and detach the PVC. Allow CORS GET/HEAD from the site domain and the Range header on the bucket.", + settings_r2_error_incomplete: "R2 was not enabled: check the Account ID, bucket name, Access Key ID, and Secret Access Key.", settings_client_notifications_enabled: "Allow client browser notifications", settings_client_notifications_help: "Shows clients the completed-visit notification setting.", settings_vapid_public_key: "VAPID public key", diff --git a/src/main.rs b/src/main.rs index 941791a..1d8c5c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,6 @@ mod tz; mod uploads; mod web_push; -use tracing_subscriber; - use cot::cli::CliMetadata; use cot::config::{ DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig, diff --git a/src/public.rs b/src/public.rs index 3c41d49..a7c9786 100644 --- a/src/public.rs +++ b/src/public.rs @@ -7,6 +7,8 @@ use cot::request::extractors::Path; use cot::response::{IntoResponse, Redirect, Response}; use cot::router::{Route, Router}; use serde::Deserialize; +use std::collections::{HashMap, HashSet}; +use std::ops::Deref; use tracing::info; use cot::db::query; @@ -74,12 +76,26 @@ struct LandingTemplate<'a> { contact_info: String, pricing_info: String, seo_keywords: String, - testimonials: Vec, + testimonials: Vec, site_domain: String, review_count: usize, turnstile_site_key: String, } +#[derive(Debug)] +struct TestimonialView { + testimonial: Testimonial, + image_url: Option, +} + +impl Deref for TestimonialView { + type Target = Testimonial; + + fn deref(&self) -> &Self::Target { + &self.testimonial + } +} + #[derive(Debug, Template)] #[template(path = "thank_you.html")] struct ThankYouTemplate<'a> { @@ -145,13 +161,33 @@ async fn landing_page(request: Request, db: Database) -> cot::Result { testimonials.retain(|t| t.status == "active"); testimonials.sort_by(|a, b| a.sort_order.cmp(&b.sort_order)); let review_count = testimonials.len(); + let storage = crate::uploads::Storage::load(&db).await?; + let mut testimonial_views = Vec::with_capacity(testimonials.len()); + for testimonial in testimonials { + let image_url = match testimonial.image_path.as_deref() { + Some(path) => Some( + storage + .public_url( + path, + format!("/testimonial-image/{}", testimonial.id.unwrap()), + ) + .await + .map_err(crate::uploads::storage_error)?, + ), + None => None, + }; + testimonial_views.push(TestimonialView { + testimonial, + image_url, + }); + } let body = LandingTemplate { t: lang.t(), lang, contact_info, pricing_info, seo_keywords, - testimonials, + testimonials: testimonial_views, site_domain, review_count, turnstile_site_key, @@ -213,7 +249,55 @@ async fn submit_lead(request: Request, db: Database) -> cot::Result { struct PortalVisit { visit: Visit, admin_name: String, - media: Vec, + media: Vec, +} + +#[derive(Clone, Debug)] +struct PortalMediaView { + media: Media, + url: String, + thumbnail_url: String, +} + +impl Deref for PortalMediaView { + type Target = Media; + + fn deref(&self) -> &Self::Target { + &self.media + } +} + +async fn portal_media_view( + storage: &crate::uploads::Storage, + media: Media, + client_token: &str, +) -> cot::Result { + let media_id = media.id.unwrap(); + let url = storage + .public_url( + &media.file_path, + format!("/client/{client_token}/media/{media_id}"), + ) + .await + .map_err(crate::uploads::storage_error)?; + let thumbnail_path = + if media.file_type == "photo" && crate::uploads::supports_thumbnail(&media.file_path) { + crate::uploads::thumbnail_db_path(&media.file_path) + } else { + media.file_path.clone() + }; + let thumbnail_url = storage + .public_url( + &thumbnail_path, + format!("/client/{client_token}/media/{media_id}/thumbnail"), + ) + .await + .map_err(crate::uploads::storage_error)?; + Ok(PortalMediaView { + media, + url, + thumbnail_url, + }) } #[derive(Debug)] @@ -334,36 +418,6 @@ async fn client_portal( .then(a.time_start.cmp(&b.time_start)) }); - let users = User::objects().all(&db).await?; - let all_media = Media::objects().all(&db).await?; - - let build_portal_visit = |v: Visit| -> PortalVisit { - let uid: i64 = v.user_id.primary_key().unwrap(); - let admin_name = users - .iter() - .find(|u| u.id.unwrap() == uid) - .map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string()) - .unwrap_or_default(); - let vid = v.id.unwrap(); - let media: Vec = all_media - .iter() - .filter(|m| { - m.status == "active" - && m.client_id.primary_key().unwrap() == client_id - && m.visit_id - .as_ref() - .map(|fk| fk.primary_key().unwrap() == vid) - .unwrap_or(false) - }) - .cloned() - .collect(); - PortalVisit { - visit: v, - admin_name, - media, - } - }; - let mut upcoming_visits = Vec::new(); let mut past_visits = Vec::new(); for v in visits { @@ -379,6 +433,50 @@ async fn client_portal( let page = requested_page.min(total_pages); let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE; let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len()); + let visible_visit_ids: HashSet = past_visits[page_start..page_end] + .iter() + .chain(upcoming_visits.iter()) + .map(|visit| visit.id.unwrap()) + .collect(); + + let users = User::objects().all(&db).await?; + let all_media = Media::objects().all(&db).await?; + let storage = crate::uploads::Storage::load(&db).await?; + let mut media_by_visit: HashMap> = HashMap::new(); + for media in all_media { + if media.status != "active" || media.client_id.primary_key().unwrap() != client_id { + continue; + } + let Some(visit_id) = media + .visit_id + .as_ref() + .map(|foreign_key| foreign_key.primary_key().unwrap()) + else { + continue; + }; + if !visible_visit_ids.contains(&visit_id) { + continue; + } + let media_view = portal_media_view(&storage, media, &client.media_token).await?; + media_by_visit.entry(visit_id).or_default().push(media_view); + } + + let build_portal_visit = |v: Visit| -> PortalVisit { + let uid: i64 = v.user_id.primary_key().unwrap(); + let admin_name = users + .iter() + .find(|u| u.id.unwrap() == uid) + .map(|u| u.display_name.as_deref().unwrap_or(&u.login).to_string()) + .unwrap_or_default(); + let vid = v.id.unwrap(); + let media = media_by_visit.get(&vid).cloned().unwrap_or_default(); + PortalVisit { + visit: v, + admin_name, + media, + } + }; + let past = past_visits[page_start..page_end] .iter() .cloned() @@ -759,21 +857,22 @@ async fn portal_media( .and_then(|value| value.to_str().ok()) .map(str::to_owned); - match { - let content_type = match media.file_path.rsplit('.').next().unwrap_or("") { - "jpg" | "jpeg" => "image/jpeg", - "png" => "image/png", - "heic" | "heif" => "image/heic", - "webp" => "image/webp", - "mp4" => "video/mp4", - "mov" => "video/quicktime", - "avi" => "video/x-msvideo", - "mkv" => "video/x-matroska", - "webm" => "video/webm", - _ => "application/octet-stream", - }; - crate::uploads::ranged_file_response(&media.file_path, content_type, range.as_deref()).await - } { + let storage = crate::uploads::Storage::load(&db).await?; + if storage.is_r2() { + let url = storage + .public_url(&media.file_path, String::new()) + .await + .map_err(crate::uploads::storage_error)?; + return Redirect::new(url).into_response(); + } + + match crate::uploads::ranged_local_file_response( + &media.file_path, + crate::uploads::content_type_for_path(&media.file_path), + range.as_deref(), + ) + .await + { Ok(response) => Ok(response), Err(err) => { tracing::warn!( @@ -815,11 +914,32 @@ async fn portal_media_thumbnail( _ => return Html::new("404").into_response(), } } - match crate::uploads::ensure_thumbnail(&media.file_path).await { - Ok(path) => { - let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None) - .await - .map_err(|error| cot::Error::internal(error.to_string()))?; + let storage = crate::uploads::Storage::load(&db).await?; + let display_path = if crate::uploads::supports_thumbnail(&media.file_path) { + match crate::uploads::ensure_thumbnail(&storage, &media.file_path).await { + Ok(path) => path, + Err(error) => { + tracing::warn!(media_id, %error, "failed to create portal media thumbnail"); + media.file_path.clone() + } + } + } else { + media.file_path.clone() + }; + if storage.is_r2() { + let url = storage + .public_url(&display_path, String::new()) + .await + .map_err(crate::uploads::storage_error)?; + return Redirect::new(url).into_response(); + } + let content_type = if display_path == media.file_path { + crate::uploads::content_type_for_path(&media.file_path) + } else { + "image/jpeg" + }; + match crate::uploads::ranged_local_file_response(&display_path, content_type, None).await { + Ok(mut response) => { response.headers_mut().insert( "cache-control", "private, max-age=31536000, immutable".parse().unwrap(), @@ -827,14 +947,8 @@ async fn portal_media_thumbnail( Ok(response) } Err(error) => { - tracing::warn!(media_id, %error, "failed to create portal media thumbnail"); - crate::uploads::ranged_file_response( - &media.file_path, - crate::uploads::content_type_for_path(&media.file_path), - None, - ) - .await - .map_err(|error| cot::Error::internal(error.to_string())) + tracing::warn!(media_id, %error, "failed to read portal media thumbnail"); + Html::new("404").into_response() } } } @@ -852,7 +966,15 @@ async fn serve_testimonial_image( Some(p) => p.clone(), None => return Html::new("404").into_response(), }; - match crate::uploads::read_db_file(&path).await { + let storage = crate::uploads::Storage::load(&db).await?; + if storage.is_r2() { + let url = storage + .public_url(&path, String::new()) + .await + .map_err(crate::uploads::storage_error)?; + return Redirect::new(url).into_response(); + } + match storage.read(&path).await { Ok(data) => { let content_type = match path.rsplit('.').next().unwrap_or("") { "jpg" | "jpeg" => "image/jpeg", diff --git a/src/uploads.rs b/src/uploads.rs index d254836..d31ee21 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -1,15 +1,345 @@ +use std::fmt; use std::path::{Path, PathBuf}; +use std::time::Duration; +use aws_sdk_s3::Client; +use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region}; +use aws_sdk_s3::presigning::PresigningConfig; +use aws_sdk_s3::primitives::ByteStream; +use cot::db::{Database, Model}; use cot::response::Response; use cot::{Body, StatusCode}; +use image::codecs::jpeg::JpegEncoder; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use crate::models::Setting; + const THUMBNAIL_MAX_DIMENSION: u32 = 360; const THUMBNAIL_JPEG_QUALITY: u8 = 70; +const PRESIGNED_URL_TTL: Duration = Duration::from_secs(6 * 60 * 60); const DEFAULT_UPLOAD_DIR: &str = "uploads"; const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR"; +pub const R2_ENABLED_KEY: &str = "r2_enabled"; +pub const R2_ACCOUNT_ID_KEY: &str = "r2_account_id"; +pub const R2_BUCKET_KEY: &str = "r2_bucket"; +pub const R2_ACCESS_KEY_ID_KEY: &str = "r2_access_key_id"; +pub const R2_SECRET_ACCESS_KEY_KEY: &str = "r2_secret_access_key"; + +#[derive(Debug)] +pub struct StorageError(String); + +impl StorageError { + fn new(context: &str, error: impl fmt::Display) -> Self { + Self(format!("{context}: {error}")) + } +} + +impl fmt::Display for StorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for StorageError {} + +pub type StorageResult = Result; + +#[derive(Clone, PartialEq, Eq)] +pub struct R2Config { + pub account_id: String, + pub bucket: String, + pub access_key_id: String, + pub secret_access_key: String, +} + +impl R2Config { + pub fn from_settings(settings: &[Setting]) -> Option { + let value = |key: &str| { + settings + .iter() + .find(|setting| setting.key == key) + .map(|setting| setting.value.trim().to_string()) + .unwrap_or_default() + }; + let config = Self { + account_id: value(R2_ACCOUNT_ID_KEY), + bucket: value(R2_BUCKET_KEY), + access_key_id: value(R2_ACCESS_KEY_ID_KEY), + secret_access_key: value(R2_SECRET_ACCESS_KEY_KEY), + }; + config.is_valid().then_some(config) + } + + pub fn fields_are_valid( + account_id: &str, + bucket: &str, + access_key_id: &str, + secret_access_key: &str, + ) -> bool { + let account_id = account_id.trim(); + let bucket = bucket.trim(); + account_id.len() == 32 + && account_id.bytes().all(|byte| byte.is_ascii_hexdigit()) + && (3..=63).contains(&bucket.len()) + && !bucket.starts_with('-') + && !bucket.ends_with('-') + && bucket + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !access_key_id.trim().is_empty() + && !secret_access_key.trim().is_empty() + } + + pub fn is_valid(&self) -> bool { + Self::fields_are_valid( + &self.account_id, + &self.bucket, + &self.access_key_id, + &self.secret_access_key, + ) + } + + fn endpoint(&self) -> String { + format!("https://{}.r2.cloudflarestorage.com", self.account_id) + } +} + +#[derive(Clone)] +pub(crate) struct R2Storage { + client: Client, + bucket: String, +} + +impl R2Storage { + fn new(config: R2Config) -> Self { + let endpoint = config.endpoint(); + let bucket = config.bucket.clone(); + let credentials = Credentials::new( + config.access_key_id, + config.secret_access_key, + None, + None, + "web-petting-r2", + ); + let sdk_config = aws_sdk_s3::Config::builder() + .behavior_version(BehaviorVersion::latest()) + .endpoint_url(endpoint) + .credentials_provider(credentials) + .region(Region::new("auto")) + .build(); + Self { + client: Client::from_conf(sdk_config), + bucket, + } + } + + async fn put(&self, db_path: &str, data: &[u8]) -> StorageResult<()> { + self.put_stream(db_path, ByteStream::from(data.to_vec())) + .await + } + + async fn put_stream(&self, db_path: &str, body: ByteStream) -> StorageResult<()> { + self.client + .put_object() + .bucket(&self.bucket) + .key(object_key(db_path)) + .content_type(content_type_for_path(db_path)) + .cache_control("private, max-age=21600") + .body(body) + .send() + .await + .map_err(|error| StorageError::new("failed to upload object to R2", error))?; + Ok(()) + } + + async fn get(&self, db_path: &str) -> StorageResult> { + let response = self + .client + .get_object() + .bucket(&self.bucket) + .key(object_key(db_path)) + .send() + .await + .map_err(|error| StorageError::new("failed to download object from R2", error))?; + let bytes = response + .body + .collect() + .await + .map_err(|error| StorageError::new("failed to read R2 response body", error))?; + Ok(bytes.into_bytes().to_vec()) + } + + async fn delete(&self, db_path: &str) -> StorageResult<()> { + self.client + .delete_object() + .bucket(&self.bucket) + .key(object_key(db_path)) + .send() + .await + .map_err(|error| StorageError::new("failed to delete object from R2", error))?; + Ok(()) + } + + async fn exists(&self, db_path: &str) -> StorageResult { + match self + .client + .head_object() + .bucket(&self.bucket) + .key(object_key(db_path)) + .send() + .await + { + Ok(_) => Ok(true), + Err(error) + if error + .as_service_error() + .is_some_and(|service_error| service_error.is_not_found()) => + { + Ok(false) + } + Err(error) => Err(StorageError::new("failed to inspect R2 object", error)), + } + } + + async fn presigned_get_url(&self, db_path: &str) -> StorageResult { + let config = PresigningConfig::expires_in(PRESIGNED_URL_TTL) + .map_err(|error| StorageError::new("failed to configure R2 signed URL", error))?; + let request = self + .client + .get_object() + .bucket(&self.bucket) + .key(object_key(db_path)) + .presigned(config) + .await + .map_err(|error| StorageError::new("failed to sign R2 object URL", error))?; + Ok(request.uri().to_string()) + } +} + +#[derive(Clone)] +pub enum Storage { + Local, + R2(R2Storage), +} + +impl Storage { + pub async fn load(db: &Database) -> cot::Result { + let settings = Setting::objects().all(db).await?; + let enabled = settings + .iter() + .find(|setting| setting.key == R2_ENABLED_KEY) + .map(|setting| setting.value == "true") + .unwrap_or(false); + if !enabled { + return Ok(Self::Local); + } + match R2Config::from_settings(&settings) { + Some(config) => Ok(Self::R2(R2Storage::new(config))), + None => { + tracing::error!( + target: "uploads", + "R2 is enabled but its configuration is incomplete; using local storage" + ); + Ok(Self::Local) + } + } + } + + /// Load configured R2 even when it is not enabled yet. This lets the + /// one-time migration run before the site is switched away from local storage. + #[allow(dead_code)] + pub async fn load_configured_r2(db: &Database) -> cot::Result { + let settings = Setting::objects().all(db).await?; + let config = R2Config::from_settings(&settings).ok_or_else(|| { + cot::Error::internal("R2 settings are missing or invalid".to_string()) + })?; + Ok(Self::R2(R2Storage::new(config))) + } + + pub fn is_r2(&self) -> bool { + matches!(self, Self::R2(_)) + } + + pub async fn create_logical_dir(&self, db_dir: &str) -> StorageResult<()> { + if matches!(self, Self::Local) { + tokio::fs::create_dir_all(resolve_db_path(db_dir)) + .await + .map_err(|error| StorageError::new("failed to create upload directory", error))?; + } + Ok(()) + } + + pub async fn write(&self, db_path: &str, data: &[u8]) -> StorageResult<()> { + match self { + Self::Local => { + let physical_path = resolve_db_path(db_path); + if let Some(parent) = physical_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|error| { + StorageError::new("failed to create upload directory", error) + })?; + } + tokio::fs::write(physical_path, data) + .await + .map_err(|error| StorageError::new("failed to write uploaded file", error)) + } + Self::R2(storage) => storage.put(db_path, data).await, + } + } + + pub async fn read(&self, db_path: &str) -> StorageResult> { + match self { + Self::Local => tokio::fs::read(resolve_db_path(db_path)) + .await + .map_err(|error| StorageError::new("failed to read uploaded file", error)), + Self::R2(storage) => storage.get(db_path).await, + } + } + + pub async fn remove(&self, db_path: &str) -> StorageResult<()> { + match self { + Self::Local => match tokio::fs::remove_file(resolve_db_path(db_path)).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(StorageError::new("failed to remove uploaded file", error)), + }, + Self::R2(storage) => storage.delete(db_path).await, + } + } + + pub async fn exists(&self, db_path: &str) -> StorageResult { + match self { + Self::Local => tokio::fs::try_exists(resolve_db_path(db_path)) + .await + .map_err(|error| StorageError::new("failed to inspect uploaded file", error)), + Self::R2(storage) => storage.exists(db_path).await, + } + } + + pub async fn public_url(&self, db_path: &str, local_fallback: String) -> StorageResult { + match self { + Self::Local => Ok(local_fallback), + Self::R2(storage) => storage.presigned_get_url(db_path).await, + } + } + + /// Stream a local PVC file into R2 without loading large videos into memory. + #[allow(dead_code)] + pub async fn upload_local_copy(&self, db_path: &str, local_path: &Path) -> StorageResult<()> { + let Self::R2(storage) = self else { + return Err(StorageError::new( + "failed to migrate local file", + "R2 storage is not configured", + )); + }; + let body = ByteStream::from_path(local_path) + .await + .map_err(|error| StorageError::new("failed to open local file for migration", error))?; + storage.put_stream(db_path, body).await + } +} + pub fn media_dir(client_id: i64, visit_id: i64) -> String { format!("{DEFAULT_UPLOAD_DIR}/{client_id}/{visit_id}") } @@ -44,20 +374,11 @@ pub fn resolved_display_path(db_path: &str) -> String { resolve_db_path(db_path).display().to_string() } -pub async fn create_logical_dir(db_dir: &str) -> std::io::Result<()> { - tokio::fs::create_dir_all(resolve_db_path(db_dir)).await -} - -pub async fn write_db_file(db_path: &str, data: &[u8]) -> std::io::Result<()> { - let physical_path = resolve_db_path(db_path); - if let Some(parent) = physical_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - tokio::fs::write(physical_path, data).await -} - -pub async fn read_db_file(db_path: &str) -> std::io::Result> { - tokio::fs::read(resolve_db_path(db_path)).await +pub fn object_key(db_path: &str) -> String { + db_path + .replace('\\', "/") + .trim_start_matches('/') + .to_string() } pub fn thumbnail_db_path(db_path: &str) -> String { @@ -67,8 +388,26 @@ pub fn thumbnail_db_path(db_path: &str) -> String { } } +pub fn supports_thumbnail(db_path: &str) -> bool { + matches!( + db_path + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str(), + "jpg" | "jpeg" | "png" | "webp" + ) +} + pub fn content_type_for_path(path: &str) -> &'static str { - match path.rsplit('.').next().unwrap_or("") { + match path + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "heic" | "heif" => "image/heic", @@ -82,21 +421,63 @@ pub fn content_type_for_path(path: &str) -> &'static str { } } -pub async fn ensure_thumbnail(db_path: &str) -> std::io::Result { - let thumbnail_path = thumbnail_db_path(db_path); - if tokio::fs::try_exists(resolve_db_path(&thumbnail_path)).await? { - return Ok(thumbnail_path); - } - let data = read_db_file(db_path).await?; - let image = image::load_from_memory(&data) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; +fn encode_thumbnail(data: &[u8]) -> StorageResult> { + let image = image::load_from_memory(data) + .map_err(|error| StorageError::new("failed to decode image for thumbnail", error))?; let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION); let rgb = thumbnail.to_rgb8(); let mut encoded = Vec::new(); - let mut encoder = - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY); - encoder.encode_image(&rgb).map_err(std::io::Error::other)?; - write_db_file(&thumbnail_path, &encoded).await?; + let mut encoder = JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY); + encoder + .encode_image(&rgb) + .map_err(|error| StorageError::new("failed to encode image thumbnail", error))?; + Ok(encoded) +} + +pub async fn ensure_thumbnail(storage: &Storage, db_path: &str) -> StorageResult { + let thumbnail_path = thumbnail_db_path(db_path); + if storage.exists(&thumbnail_path).await? { + return Ok(thumbnail_path); + } + let data = storage.read(db_path).await?; + write_thumbnail(storage, db_path, &data).await?; + Ok(thumbnail_path) +} + +pub async fn write_thumbnail( + storage: &Storage, + db_path: &str, + source_data: &[u8], +) -> StorageResult { + let thumbnail_path = thumbnail_db_path(db_path); + let encoded = encode_thumbnail(source_data)?; + storage.write(&thumbnail_path, &encoded).await?; + Ok(thumbnail_path) +} + +/// Create a missing thumbnail from the local PVC even when R2 is enabled. +#[allow(dead_code)] +pub async fn ensure_local_thumbnail(db_path: &str) -> StorageResult { + let thumbnail_path = thumbnail_db_path(db_path); + if tokio::fs::try_exists(resolve_db_path(&thumbnail_path)) + .await + .map_err(|error| StorageError::new("failed to inspect local thumbnail", error))? + { + return Ok(thumbnail_path); + } + let data = tokio::fs::read(resolve_db_path(db_path)) + .await + .map_err(|error| StorageError::new("failed to read local image", error))?; + let encoded = encode_thumbnail(&data)?; + let physical_path = resolve_db_path(&thumbnail_path); + if let Some(parent) = physical_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| StorageError::new("failed to create thumbnail directory", error))?; + } + tokio::fs::write(physical_path, encoded) + .await + .map_err(|error| StorageError::new("failed to write local thumbnail", error))?; Ok(thumbnail_path) } @@ -155,8 +536,8 @@ fn parse_byte_range(header: Option<&str>, file_len: u64) -> ByteRange { ByteRange::Partial { start, end } } -/// Read a file into an HTTP response, honoring a single `Range: bytes=...` request. -pub async fn ranged_file_response( +/// Read a local file into an HTTP response, honoring one `Range: bytes=...` request. +pub async fn ranged_local_file_response( db_path: &str, content_type: &str, range_header: Option<&str>, @@ -217,13 +598,13 @@ pub async fn ranged_file_response( Ok(response) } -pub async fn remove_db_file(db_path: &str) -> std::io::Result<()> { - tokio::fs::remove_file(resolve_db_path(db_path)).await +pub fn storage_error(error: StorageError) -> cot::Error { + cot::Error::internal(error.to_string()) } #[cfg(test)] mod tests { - use super::{ByteRange, parse_byte_range}; + use super::{ByteRange, R2Config, R2Storage, object_key, parse_byte_range}; #[test] fn parses_byte_ranges() { @@ -244,4 +625,56 @@ mod tests { ByteRange::Unsatisfiable )); } + + #[test] + fn validates_r2_fields() { + assert!(R2Config::fields_are_valid( + "0123456789abcdef0123456789abcdef", + "pet-media", + "access-key", + "secret-key", + )); + assert!(!R2Config::fields_are_valid( + "not-an-account", + "pet-media", + "access-key", + "secret-key", + )); + assert!(!R2Config::fields_are_valid( + "0123456789abcdef0123456789abcdef", + "Invalid_Bucket", + "access-key", + "secret-key", + )); + } + + #[test] + fn converts_database_paths_to_object_keys() { + assert_eq!(object_key("uploads/1/photo.jpg"), "uploads/1/photo.jpg"); + assert_eq!( + object_key("/data/uploads/photo.jpg"), + "data/uploads/photo.jpg" + ); + assert_eq!(object_key("uploads\\1\\photo.jpg"), "uploads/1/photo.jpg"); + } + + #[test] + fn signs_r2_urls_for_six_hours() { + let storage = R2Storage::new(R2Config { + account_id: "0123456789abcdef0123456789abcdef".to_string(), + bucket: "pet-media".to_string(), + access_key_id: "access-key".to_string(), + secret_access_key: "secret-key".to_string(), + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let url = runtime + .block_on(storage.presigned_get_url("uploads/1/photo.jpg")) + .unwrap(); + assert!(url.contains("X-Amz-Expires=21600")); + assert!(url.contains("uploads/1/photo.jpg")); + assert!(url.contains("pet-media")); + } } diff --git a/templates/admin/media.html b/templates/admin/media.html index 4ce499f..d219f5b 100644 --- a/templates/admin/media.html +++ b/templates/admin/media.html @@ -27,13 +27,13 @@ {% for item in &items %}
{% if item.media.file_type == "photo" %} - - + + {% else %} - +
- +
diff --git a/templates/admin/schedule_edit.html b/templates/admin/schedule_edit.html index 7018e3e..7dc8af0 100644 --- a/templates/admin/schedule_edit.html +++ b/templates/admin/schedule_edit.html @@ -113,13 +113,13 @@ {% for m in &media %}
{% if m.file_type == "photo" %} - - + + {% else %} - +
- +
diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 6646ca8..1580529 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -11,6 +11,9 @@ {% if saved %}
{{ t.settings_saved }}
{% endif %} +{% if let Some(message) = error %} +
{{ message }}
+{% endif %}
@@ -58,6 +61,42 @@
+

{{ t.settings_section_storage }}

+
+

{{ t.settings_r2_help }}

+

{{ t.settings_r2_migration_help }}

+
+
+ +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+

{{ t.settings_section_notifications }}