feat: adds s3 support to posts for files
ee1fc57c
9 file(s) · +421 −40
| 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", |
|
| 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 |
| 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" |
| 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"); |
|
| 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() { |
| 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 | } |
|
| 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) => { |
| 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() |
|
| 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 | + | } |