Merge pull request #42 from stevedylandev/feat/posts-s3-support 1c50de63
feat: adds s3 support to posts for files
Steve Simkins · 2026-05-03 20:47 9 file(s) · +421 −40
Cargo.lock +182 −18
16 16
dependencies = [
17 17
 "cfg-if",
18 18
 "cipher",
19 -
 "cpufeatures",
19 +
 "cpufeatures 0.2.17",
20 20
]
21 21
22 22
[[package]]
618 618
]
619 619
620 620
[[package]]
621 +
name = "block-buffer"
622 +
version = "0.12.0"
623 +
source = "registry+https://github.com/rust-lang/crates.io-index"
624 +
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
625 +
dependencies = [
626 +
 "hybrid-array",
627 +
]
628 +
629 +
[[package]]
621 630
name = "bookmarks"
622 631
version = "0.1.0"
623 632
dependencies = [
787 796
source = "registry+https://github.com/rust-lang/crates.io-index"
788 797
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
789 798
dependencies = [
790 -
 "crypto-common",
799 +
 "crypto-common 0.1.7",
791 800
 "inout",
792 801
]
793 802
850 859
]
851 860
852 861
[[package]]
862 +
name = "cmov"
863 +
version = "0.5.3"
864 +
source = "registry+https://github.com/rust-lang/crates.io-index"
865 +
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
866 +
867 +
[[package]]
853 868
name = "color_quant"
854 869
version = "1.1.0"
855 870
source = "registry+https://github.com/rust-lang/crates.io-index"
884 899
 "ryu",
885 900
 "static_assertions",
886 901
]
902 +
903 +
[[package]]
904 +
name = "const-oid"
905 +
version = "0.10.2"
906 +
source = "registry+https://github.com/rust-lang/crates.io-index"
907 +
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
887 908
888 909
[[package]]
889 910
name = "constant_time_eq"
945 966
]
946 967
947 968
[[package]]
969 +
name = "cpufeatures"
970 +
version = "0.3.0"
971 +
source = "registry+https://github.com/rust-lang/crates.io-index"
972 +
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
973 +
dependencies = [
974 +
 "libc",
975 +
]
976 +
977 +
[[package]]
948 978
name = "crc"
949 979
version = "3.4.0"
950 980
source = "registry+https://github.com/rust-lang/crates.io-index"
1037 1067
]
1038 1068
1039 1069
[[package]]
1070 +
name = "crypto-common"
1071 +
version = "0.2.1"
1072 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1073 +
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
1074 +
dependencies = [
1075 +
 "hybrid-array",
1076 +
]
1077 +
1078 +
[[package]]
1040 1079
name = "csscolorparser"
1041 1080
version = "0.6.2"
1042 1081
source = "registry+https://github.com/rust-lang/crates.io-index"
1070 1109
]
1071 1110
1072 1111
[[package]]
1112 +
name = "ctutils"
1113 +
version = "0.4.2"
1114 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1115 +
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
1116 +
dependencies = [
1117 +
 "cmov",
1118 +
]
1119 +
1120 +
[[package]]
1073 1121
name = "darling"
1074 1122
version = "0.23.0"
1075 1123
source = "registry+https://github.com/rust-lang/crates.io-index"
1174 1222
source = "registry+https://github.com/rust-lang/crates.io-index"
1175 1223
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
1176 1224
dependencies = [
1177 -
 "block-buffer",
1178 -
 "crypto-common",
1225 +
 "block-buffer 0.10.4",
1226 +
 "crypto-common 0.1.7",
1179 1227
 "subtle",
1228 +
]
1229 +
1230 +
[[package]]
1231 +
name = "digest"
1232 +
version = "0.11.3"
1233 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1234 +
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
1235 +
dependencies = [
1236 +
 "block-buffer 0.12.0",
1237 +
 "const-oid",
1238 +
 "crypto-common 0.2.1",
1239 +
 "ctutils",
1180 1240
]
1181 1241
1182 1242
[[package]]
1738 1798
source = "registry+https://github.com/rust-lang/crates.io-index"
1739 1799
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1740 1800
dependencies = [
1741 -
 "digest",
1801 +
 "digest 0.10.7",
1802 +
]
1803 +
1804 +
[[package]]
1805 +
name = "hmac"
1806 +
version = "0.13.0"
1807 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1808 +
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
1809 +
dependencies = [
1810 +
 "digest 0.11.3",
1742 1811
]
1743 1812
1744 1813
[[package]]
1811 1880
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
1812 1881
dependencies = [
1813 1882
 "libm",
1883 +
]
1884 +
1885 +
[[package]]
1886 +
name = "hybrid-array"
1887 +
version = "0.4.11"
1888 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1889 +
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
1890 +
dependencies = [
1891 +
 "typenum",
1814 1892
]
1815 1893
1816 1894
[[package]]
2194 2272
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
2195 2273
2196 2274
[[package]]
2275 +
name = "jiff"
2276 +
version = "0.2.24"
2277 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2278 +
checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d"
2279 +
dependencies = [
2280 +
 "jiff-static",
2281 +
 "log",
2282 +
 "portable-atomic",
2283 +
 "portable-atomic-util",
2284 +
 "serde_core",
2285 +
]
2286 +
2287 +
[[package]]
2288 +
name = "jiff-static"
2289 +
version = "0.2.24"
2290 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2291 +
checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7"
2292 +
dependencies = [
2293 +
 "proc-macro2",
2294 +
 "quote",
2295 +
 "syn 2.0.117",
2296 +
]
2297 +
2298 +
[[package]]
2197 2299
name = "jni"
2198 2300
version = "0.21.1"
2199 2301
source = "registry+https://github.com/rust-lang/crates.io-index"
2542 2644
dependencies = [
2543 2645
 "cfg-if",
2544 2646
 "rayon",
2647 +
]
2648 +
2649 +
[[package]]
2650 +
name = "md-5"
2651 +
version = "0.11.0"
2652 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2653 +
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
2654 +
dependencies = [
2655 +
 "cfg-if",
2656 +
 "digest 0.11.3",
2545 2657
]
2546 2658
2547 2659
[[package]]
3050 3162
source = "registry+https://github.com/rust-lang/crates.io-index"
3051 3163
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
3052 3164
dependencies = [
3053 -
 "digest",
3054 -
 "hmac",
3165 +
 "digest 0.10.7",
3166 +
 "hmac 0.12.1",
3055 3167
]
3056 3168
3057 3169
[[package]]
3100 3212
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
3101 3213
dependencies = [
3102 3214
 "pest",
3103 -
 "sha2",
3215 +
 "sha2 0.10.9",
3104 3216
]
3105 3217
3106 3218
[[package]]
3200 3312
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
3201 3313
3202 3314
[[package]]
3315 +
name = "portable-atomic-util"
3316 +
version = "0.2.7"
3317 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3318 +
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
3319 +
dependencies = [
3320 +
 "portable-atomic",
3321 +
]
3322 +
3323 +
[[package]]
3203 3324
name = "posts"
3204 3325
version = "0.1.5"
3205 3326
dependencies = [
3214 3335
 "nanoid",
3215 3336
 "pulldown-cmark",
3216 3337
 "rand 0.8.5",
3338 +
 "reqwest 0.12.28",
3217 3339
 "rusqlite",
3218 3340
 "rust-embed",
3341 +
 "rusty-s3",
3219 3342
 "serde",
3220 3343
 "serde_json",
3221 3344
 "serde_rusqlite",
3224 3347
 "tower-http",
3225 3348
 "tracing",
3226 3349
 "tracing-subscriber",
3350 +
 "url",
3227 3351
 "zip",
3228 3352
]
3229 3353
3352 3476
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
3353 3477
dependencies = [
3354 3478
 "memchr",
3479 +
]
3480 +
3481 +
[[package]]
3482 +
name = "quick-xml"
3483 +
version = "0.39.2"
3484 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3485 +
checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d"
3486 +
dependencies = [
3487 +
 "memchr",
3488 +
 "serde",
3355 3489
]
3356 3490
3357 3491
[[package]]
3866 4000
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
3867 4001
dependencies = [
3868 4002
 "mime_guess",
3869 -
 "sha2",
4003 +
 "sha2 0.10.9",
3870 4004
 "walkdir",
3871 4005
]
3872 4006
3979 4113
version = "1.0.22"
3980 4114
source = "registry+https://github.com/rust-lang/crates.io-index"
3981 4115
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
4116 +
4117 +
[[package]]
4118 +
name = "rusty-s3"
4119 +
version = "0.9.1"
4120 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4121 +
checksum = "15ec4851cde7bd44c6b1dbd7e68f70ac50f9dec29bb1f1ffd69426578af02d6b"
4122 +
dependencies = [
4123 +
 "base64",
4124 +
 "hmac 0.13.0",
4125 +
 "jiff",
4126 +
 "md-5",
4127 +
 "percent-encoding",
4128 +
 "quick-xml 0.39.2",
4129 +
 "serde",
4130 +
 "serde_json",
4131 +
 "sha2 0.11.0",
4132 +
 "url",
4133 +
 "zeroize",
4134 +
]
3982 4135
3983 4136
[[package]]
3984 4137
name = "ryu"
4174 4327
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
4175 4328
dependencies = [
4176 4329
 "cfg-if",
4177 -
 "cpufeatures",
4178 -
 "digest",
4330 +
 "cpufeatures 0.2.17",
4331 +
 "digest 0.10.7",
4179 4332
]
4180 4333
4181 4334
[[package]]
4185 4338
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
4186 4339
dependencies = [
4187 4340
 "cfg-if",
4188 -
 "cpufeatures",
4189 -
 "digest",
4341 +
 "cpufeatures 0.2.17",
4342 +
 "digest 0.10.7",
4343 +
]
4344 +
4345 +
[[package]]
4346 +
name = "sha2"
4347 +
version = "0.11.0"
4348 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4349 +
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
4350 +
dependencies = [
4351 +
 "cfg-if",
4352 +
 "cpufeatures 0.3.0",
4353 +
 "digest 0.11.3",
4190 4354
]
4191 4355
4192 4356
[[package]]
4567 4731
 "pest",
4568 4732
 "pest_derive",
4569 4733
 "phf",
4570 -
 "sha2",
4734 +
 "sha2 0.10.9",
4571 4735
 "signal-hook",
4572 4736
 "siphasher",
4573 4737
 "terminfo",
4931 5095
4932 5096
[[package]]
4933 5097
name = "typenum"
4934 -
version = "1.19.0"
5098 +
version = "1.20.0"
4935 5099
source = "registry+https://github.com/rust-lang/crates.io-index"
4936 -
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
5100 +
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
4937 5101
4938 5102
[[package]]
4939 5103
name = "ucd-trie"
5269 5433
dependencies = [
5270 5434
 "getrandom 0.3.4",
5271 5435
 "mac_address",
5272 -
 "sha2",
5436 +
 "sha2 0.10.9",
5273 5437
 "thiserror 1.0.69",
5274 5438
 "uuid",
5275 5439
]
5941 6105
 "displaydoc",
5942 6106
 "flate2",
5943 6107
 "getrandom 0.3.4",
5944 -
 "hmac",
6108 +
 "hmac 0.12.1",
5945 6109
 "indexmap",
5946 6110
 "lzma-rs",
5947 6111
 "memchr",
apps/posts/.env.example +7 −0
5 5
HOST=127.0.0.1
6 6
PORT=3000
7 7
SITE_URL=http://localhost:3000
8 +
9 +
# Optional: Cloudflare R2 (set ALL to enable; otherwise local filesystem is used)
10 +
# R2_ACCOUNT_ID=
11 +
# R2_ACCESS_KEY_ID=
12 +
# R2_SECRET_ACCESS_KEY=
13 +
# R2_BUCKET=
14 +
# R2_PUBLIC_URL=https://pub-xxxx.r2.dev
apps/posts/Cargo.toml +3 −0
30 30
chrono = "0.4"
31 31
zip = { workspace = true }
32 32
tower-http = { version = "0.6.8", features = ["cors"] }
33 +
rusty-s3 = "0.9"
34 +
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
35 +
url = "2"
apps/posts/src/db.rs +46 −12
90 90
    pub content_type: String,
91 91
    pub size: i64,
92 92
    pub created_at: String,
93 +
    pub storage_backend: String,
93 94
}
94 95
95 96
const SCHEMA: &str = "
135 136
    );
136 137
137 138
    CREATE TABLE IF NOT EXISTS files (
138 -
        id            INTEGER PRIMARY KEY AUTOINCREMENT,
139 -
        short_id      TEXT NOT NULL UNIQUE,
140 -
        filename      TEXT NOT NULL UNIQUE,
141 -
        original_name TEXT NOT NULL,
142 -
        content_type  TEXT NOT NULL DEFAULT 'application/octet-stream',
143 -
        size          INTEGER NOT NULL,
144 -
        created_at    TEXT NOT NULL DEFAULT (datetime('now'))
139 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
140 +
        short_id        TEXT NOT NULL UNIQUE,
141 +
        filename        TEXT NOT NULL UNIQUE,
142 +
        original_name   TEXT NOT NULL,
143 +
        content_type    TEXT NOT NULL DEFAULT 'application/octet-stream',
144 +
        size            INTEGER NOT NULL,
145 +
        created_at      TEXT NOT NULL DEFAULT (datetime('now')),
146 +
        storage_backend TEXT NOT NULL DEFAULT 'local'
145 147
    );
146 148
";
147 149
165 167
166 168
    conn.execute_batch(SCHEMA).expect("Failed to create tables");
167 169
    migrate_post_title_nullable(&conn).expect("Failed to migrate posts.title");
170 +
    migrate_files_storage_backend(&conn).expect("Failed to migrate files.storage_backend");
168 171
169 172
    for (key, value) in DEFAULT_SETTINGS {
170 173
        conn.execute(
215 218
    )
216 219
}
217 220
221 +
fn migrate_files_storage_backend(conn: &Connection) -> rusqlite::Result<()> {
222 +
    let exists: i64 = conn
223 +
        .query_row(
224 +
            "SELECT COUNT(*) FROM pragma_table_info('files') WHERE name = 'storage_backend'",
225 +
            [],
226 +
            |row| row.get(0),
227 +
        )
228 +
        .unwrap_or(0);
229 +
    if exists > 0 {
230 +
        return Ok(());
231 +
    }
232 +
    conn.execute(
233 +
        "ALTER TABLE files ADD COLUMN storage_backend TEXT NOT NULL DEFAULT 'local'",
234 +
        [],
235 +
    )?;
236 +
    Ok(())
237 +
}
238 +
218 239
// --- Post CRUD ---
219 240
220 241
const POST_COLS: &str = "id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at";
480 501
481 502
// --- File CRUD ---
482 503
483 -
const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at";
504 +
const FILE_COLS: &str = "id, short_id, filename, original_name, content_type, size, created_at, storage_backend";
484 505
485 506
pub fn create_file(
486 507
    db: &Db,
488 509
    original_name: &str,
489 510
    content_type: &str,
490 511
    size: i64,
512 +
    storage_backend: &str,
491 513
) -> Result<UploadedFile, DbError> {
492 514
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
493 515
    let short_id = nanoid!(10);
494 516
    conn.execute(
495 -
        "INSERT INTO files (short_id, filename, original_name, content_type, size) VALUES (?1, ?2, ?3, ?4, ?5)",
496 -
        params![short_id, filename, original_name, content_type, size],
517 +
        "INSERT INTO files (short_id, filename, original_name, content_type, size, storage_backend) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
518 +
        params![short_id, filename, original_name, content_type, size, storage_backend],
497 519
    )?;
498 520
    let id = conn.last_insert_rowid();
499 521
    let file = conn.query_row(
504 526
    Ok(file)
505 527
}
506 528
529 +
pub fn get_file_by_filename(db: &Db, filename: &str) -> Result<Option<UploadedFile>, DbError> {
530 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
531 +
    let file = conn
532 +
        .query_row(
533 +
            &format!("SELECT {} FROM files WHERE filename = ?1", FILE_COLS),
534 +
            params![filename],
535 +
            from_row,
536 +
        )
537 +
        .optional()?;
538 +
    Ok(file)
539 +
}
540 +
507 541
pub fn get_all_files(db: &Db) -> Result<Vec<UploadedFile>, DbError> {
508 542
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
509 543
    let mut stmt = conn.prepare(
764 798
    #[test]
765 799
    fn create_and_get_files() {
766 800
        let db = test_db();
767 -
        let file = create_file(&db, "abc123.jpg", "photo.jpg", "image/jpeg", 1024).unwrap();
801 +
        let file = create_file(&db, "abc123.jpg", "photo.jpg", "image/jpeg", 1024, "local").unwrap();
768 802
        assert_eq!(file.filename, "abc123.jpg");
769 803
        assert_eq!(file.original_name, "photo.jpg");
770 804
        assert_eq!(file.size, 1024);
776 810
    #[test]
777 811
    fn delete_file_returns_deleted() {
778 812
        let db = test_db();
779 -
        let file = create_file(&db, "f.txt", "f.txt", "text/plain", 10).unwrap();
813 +
        let file = create_file(&db, "f.txt", "f.txt", "text/plain", 10, "local").unwrap();
780 814
        let deleted = delete_file(&db, &file.short_id).unwrap();
781 815
        assert!(deleted.is_some());
782 816
        assert_eq!(deleted.unwrap().filename, "f.txt");
apps/posts/src/main.rs +1 −0
1 1
mod auth;
2 2
mod db;
3 3
mod server;
4 +
mod storage;
4 5
5 6
#[tokio::main]
6 7
async fn main() {
apps/posts/src/server/handlers/admin.rs +41 −10
487 487
        format!("{}.{}", id, ext)
488 488
    };
489 489
490 -
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
491 -
    if let Err(e) = tokio::fs::write(&path, &bytes).await {
492 -
        tracing::error!("Failed to write file: {}", e);
493 -
        return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
494 -
    }
490 +
    let backend = if let Some(r2) = &state.r2 {
491 +
        if let Err(e) = r2.put_object(&stored_name, &content_type, bytes.clone()).await {
492 +
            tracing::error!("Failed to upload to R2: {}", e);
493 +
            return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
494 +
        }
495 +
        "r2"
496 +
    } else {
497 +
        let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
498 +
        if let Err(e) = tokio::fs::write(&path, &bytes).await {
499 +
            tracing::error!("Failed to write file: {}", e);
500 +
            return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
501 +
        }
502 +
        "local"
503 +
    };
495 504
496 -
    match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64) {
505 +
    match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64, backend) {
497 506
        Ok(_) => Redirect::to("/admin/files?success=true").into_response(),
498 507
        Err(e) => {
499 508
            tracing::error!("Failed to record file: {}", e);
500 -
            let _ = tokio::fs::remove_file(&path).await;
509 +
            if backend == "r2" {
510 +
                if let Some(r2) = &state.r2 {
511 +
                    if let Err(e) = r2.delete_object(&stored_name).await {
512 +
                        tracing::warn!("Failed to roll back R2 upload: {}", e);
513 +
                    }
514 +
                }
515 +
            } else {
516 +
                let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
517 +
                let _ = tokio::fs::remove_file(&path).await;
518 +
            }
501 519
            Redirect::to("/admin/files?error=Failed+to+record+file").into_response()
502 520
        }
503 521
    }
510 528
) -> Response {
511 529
    match db::delete_file(&state.db, &short_id) {
512 530
        Ok(Some(file)) => {
513 -
            let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename);
514 -
            if let Err(e) = tokio::fs::remove_file(&path).await {
515 -
                tracing::warn!("Failed to delete file from disk: {}", e);
531 +
            if file.storage_backend == "r2" {
532 +
                if let Some(r2) = &state.r2 {
533 +
                    if let Err(e) = r2.delete_object(&file.filename).await {
534 +
                        tracing::warn!("Failed to delete file from R2: {}", e);
535 +
                    }
536 +
                } else {
537 +
                    tracing::warn!(
538 +
                        "File {} stored in R2 but R2 not configured; skipping remote delete",
539 +
                        file.filename
540 +
                    );
541 +
                }
542 +
            } else {
543 +
                let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename);
544 +
                if let Err(e) = tokio::fs::remove_file(&path).await {
545 +
                    tracing::warn!("Failed to delete file from disk: {}", e);
546 +
                }
516 547
            }
517 548
            Redirect::to("/admin/files").into_response()
518 549
        }
apps/posts/src/server/handlers/public.rs +13 −0
170 170
        return StatusCode::NOT_FOUND.into_response();
171 171
    }
172 172
173 +
    if let Ok(Some(file)) = db::get_file_by_filename(&state.db, &filename) {
174 +
        if file.storage_backend == "r2" {
175 +
            if let Some(r2) = &state.r2 {
176 +
                return Redirect::temporary(&r2.public_url_for(&filename)).into_response();
177 +
            }
178 +
            tracing::warn!(
179 +
                "File {} stored in R2 but R2 not configured; cannot serve",
180 +
                filename
181 +
            );
182 +
            return StatusCode::NOT_FOUND.into_response();
183 +
        }
184 +
    }
185 +
173 186
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename);
174 187
    match tokio::fs::read(&path).await {
175 188
        Ok(bytes) => {
apps/posts/src/server/mod.rs +10 −0
11 11
use tower_http::cors::{Any, CorsLayer};
12 12
13 13
use crate::db::{self, Db, Page, Post, UploadedFile};
14 +
use crate::storage::R2Config;
14 15
15 16
mod handlers;
16 17
27 28
    pub cookie_secure: bool,
28 29
    pub uploads_dir: String,
29 30
    pub site_url: String,
31 +
    pub r2: Option<R2Config>,
30 32
}
31 33
32 34
#[derive(Embed)]
547 549
        .trim_end_matches('/')
548 550
        .to_string();
549 551
552 +
    let r2 = R2Config::from_env();
553 +
    if r2.is_some() {
554 +
        tracing::info!("Cloudflare R2 storage enabled for new uploads");
555 +
    } else {
556 +
        tracing::info!("R2 not configured, using local filesystem for uploads");
557 +
    }
558 +
550 559
    let state = Arc::new(AppState {
551 560
        db,
552 561
        app_password,
553 562
        cookie_secure,
554 563
        uploads_dir,
555 564
        site_url,
565 +
        r2,
556 566
    });
557 567
558 568
    let api_router = Router::new()
apps/posts/src/storage.rs (added) +118 −0
1 +
use std::time::Duration;
2 +
3 +
use rusty_s3::actions::S3Action;
4 +
use rusty_s3::{Bucket, Credentials, UrlStyle, actions};
5 +
6 +
#[derive(Clone)]
7 +
pub struct R2Config {
8 +
    bucket: Bucket,
9 +
    creds: Credentials,
10 +
    public_url: String,
11 +
    http: reqwest::Client,
12 +
}
13 +
14 +
#[derive(Debug)]
15 +
pub enum R2Error {
16 +
    Http(reqwest::Error),
17 +
    Status(u16, String),
18 +
}
19 +
20 +
impl std::fmt::Display for R2Error {
21 +
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 +
        match self {
23 +
            R2Error::Http(e) => write!(f, "http error: {}", e),
24 +
            R2Error::Status(code, body) => write!(f, "R2 returned {}: {}", code, body),
25 +
        }
26 +
    }
27 +
}
28 +
29 +
impl std::error::Error for R2Error {}
30 +
31 +
impl From<reqwest::Error> for R2Error {
32 +
    fn from(e: reqwest::Error) -> Self {
33 +
        R2Error::Http(e)
34 +
    }
35 +
}
36 +
37 +
const SIGN_TTL: Duration = Duration::from_secs(60);
38 +
39 +
impl R2Config {
40 +
    pub fn from_env() -> Option<Self> {
41 +
        let account_id = std::env::var("R2_ACCOUNT_ID").ok()?;
42 +
        let access_key = std::env::var("R2_ACCESS_KEY_ID").ok()?;
43 +
        let secret_key = std::env::var("R2_SECRET_ACCESS_KEY").ok()?;
44 +
        let bucket_name = std::env::var("R2_BUCKET").ok()?;
45 +
        let public_url = std::env::var("R2_PUBLIC_URL").ok()?;
46 +
47 +
        let endpoint_str = format!("https://{}.r2.cloudflarestorage.com", account_id);
48 +
        let endpoint = match endpoint_str.parse() {
49 +
            Ok(u) => u,
50 +
            Err(e) => {
51 +
                tracing::error!("Invalid R2 endpoint URL: {}", e);
52 +
                return None;
53 +
            }
54 +
        };
55 +
        let bucket = match Bucket::new(endpoint, UrlStyle::Path, bucket_name, "auto") {
56 +
            Ok(b) => b,
57 +
            Err(e) => {
58 +
                tracing::error!("Failed to construct R2 bucket: {:?}", e);
59 +
                return None;
60 +
            }
61 +
        };
62 +
        let creds = Credentials::new(access_key, secret_key);
63 +
        let http = reqwest::Client::builder()
64 +
            .timeout(Duration::from_secs(30))
65 +
            .build()
66 +
            .ok()?;
67 +
68 +
        Some(Self {
69 +
            bucket,
70 +
            creds,
71 +
            public_url: public_url.trim_end_matches('/').to_string(),
72 +
            http,
73 +
        })
74 +
    }
75 +
76 +
    pub async fn put_object(
77 +
        &self,
78 +
        key: &str,
79 +
        content_type: &str,
80 +
        bytes: Vec<u8>,
81 +
    ) -> Result<(), R2Error> {
82 +
        let mut action = actions::PutObject::new(&self.bucket, Some(&self.creds), key);
83 +
        action.headers_mut().insert("content-type", content_type);
84 +
        let url = action.sign(SIGN_TTL);
85 +
86 +
        let resp = self
87 +
            .http
88 +
            .put(url)
89 +
            .header("content-type", content_type)
90 +
            .body(bytes)
91 +
            .send()
92 +
            .await?;
93 +
94 +
        if !resp.status().is_success() {
95 +
            let code = resp.status().as_u16();
96 +
            let body = resp.text().await.unwrap_or_default();
97 +
            return Err(R2Error::Status(code, body));
98 +
        }
99 +
        Ok(())
100 +
    }
101 +
102 +
    pub async fn delete_object(&self, key: &str) -> Result<(), R2Error> {
103 +
        let action = actions::DeleteObject::new(&self.bucket, Some(&self.creds), key);
104 +
        let url = action.sign(SIGN_TTL);
105 +
106 +
        let resp = self.http.delete(url).send().await?;
107 +
        let status = resp.status();
108 +
        if status.is_success() || status.as_u16() == 404 {
109 +
            return Ok(());
110 +
        }
111 +
        let body = resp.text().await.unwrap_or_default();
112 +
        Err(R2Error::Status(status.as_u16(), body))
113 +
    }
114 +
115 +
    pub fn public_url_for(&self, key: &str) -> String {
116 +
        format!("{}/{}", self.public_url, key)
117 +
    }
118 +
}