learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add psql layer for deck persistence

+878 -345
+330 -2
Cargo.lock
··· 71 71 ] 72 72 73 73 [[package]] 74 + name = "async-trait" 75 + version = "0.1.89" 76 + source = "registry+https://github.com/rust-lang/crates.io-index" 77 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" 78 + dependencies = [ 79 + "proc-macro2", 80 + "quote", 81 + "syn 2.0.111", 82 + ] 83 + 84 + [[package]] 74 85 name = "atomic-waker" 75 86 version = "1.1.2" 76 87 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 159 170 checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" 160 171 161 172 [[package]] 173 + name = "block-buffer" 174 + version = "0.10.4" 175 + source = "registry+https://github.com/rust-lang/crates.io-index" 176 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 177 + dependencies = [ 178 + "generic-array", 179 + ] 180 + 181 + [[package]] 162 182 name = "bumpalo" 163 183 version = "3.19.1" 164 184 source = "registry+https://github.com/rust-lang/crates.io-index" 165 185 checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" 166 186 167 187 [[package]] 188 + name = "byteorder" 189 + version = "1.5.0" 190 + source = "registry+https://github.com/rust-lang/crates.io-index" 191 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" 192 + 193 + [[package]] 168 194 name = "bytes" 169 195 version = "1.11.0" 170 196 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 274 300 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 275 301 276 302 [[package]] 303 + name = "cpufeatures" 304 + version = "0.2.17" 305 + source = "registry+https://github.com/rust-lang/crates.io-index" 306 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" 307 + dependencies = [ 308 + "libc", 309 + ] 310 + 311 + [[package]] 312 + name = "crypto-common" 313 + version = "0.1.7" 314 + source = "registry+https://github.com/rust-lang/crates.io-index" 315 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 316 + dependencies = [ 317 + "generic-array", 318 + "typenum", 319 + ] 320 + 321 + [[package]] 322 + name = "deadpool" 323 + version = "0.12.3" 324 + source = "registry+https://github.com/rust-lang/crates.io-index" 325 + checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" 326 + dependencies = [ 327 + "deadpool-runtime", 328 + "lazy_static", 329 + "num_cpus", 330 + "tokio", 331 + ] 332 + 333 + [[package]] 334 + name = "deadpool-postgres" 335 + version = "0.14.1" 336 + source = "registry+https://github.com/rust-lang/crates.io-index" 337 + checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" 338 + dependencies = [ 339 + "async-trait", 340 + "deadpool", 341 + "getrandom 0.2.16", 342 + "tokio", 343 + "tokio-postgres", 344 + "tracing", 345 + ] 346 + 347 + [[package]] 348 + name = "deadpool-runtime" 349 + version = "0.1.4" 350 + source = "registry+https://github.com/rust-lang/crates.io-index" 351 + checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" 352 + dependencies = [ 353 + "tokio", 354 + ] 355 + 356 + [[package]] 277 357 name = "deranged" 278 358 version = "0.5.5" 279 359 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 283 363 ] 284 364 285 365 [[package]] 366 + name = "digest" 367 + version = "0.10.7" 368 + source = "registry+https://github.com/rust-lang/crates.io-index" 369 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 370 + dependencies = [ 371 + "block-buffer", 372 + "crypto-common", 373 + "subtle", 374 + ] 375 + 376 + [[package]] 286 377 name = "displaydoc" 287 378 version = "0.2.5" 288 379 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 319 410 ] 320 411 321 412 [[package]] 413 + name = "fallible-iterator" 414 + version = "0.2.0" 415 + source = "registry+https://github.com/rust-lang/crates.io-index" 416 + checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" 417 + 418 + [[package]] 322 419 name = "fastrand" 323 420 version = "2.3.0" 324 421 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 377 474 checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 378 475 dependencies = [ 379 476 "futures-core", 477 + "futures-sink", 380 478 ] 381 479 382 480 [[package]] ··· 423 521 "futures-core", 424 522 "futures-io", 425 523 "futures-macro", 524 + "futures-sink", 426 525 "futures-task", 427 526 "memchr", 428 527 "pin-project-lite", ··· 431 530 ] 432 531 433 532 [[package]] 533 + name = "generic-array" 534 + version = "0.14.7" 535 + source = "registry+https://github.com/rust-lang/crates.io-index" 536 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 537 + dependencies = [ 538 + "typenum", 539 + "version_check", 540 + ] 541 + 542 + [[package]] 434 543 name = "getrandom" 435 544 version = "0.2.16" 436 545 source = "registry+https://github.com/rust-lang/crates.io-index" 437 546 checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" 438 547 dependencies = [ 439 548 "cfg-if", 549 + "js-sys", 440 550 "libc", 441 551 "wasi", 552 + "wasm-bindgen", 442 553 ] 443 554 444 555 [[package]] ··· 504 615 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 505 616 506 617 [[package]] 618 + name = "hermit-abi" 619 + version = "0.5.2" 620 + source = "registry+https://github.com/rust-lang/crates.io-index" 621 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" 622 + 623 + [[package]] 624 + name = "hmac" 625 + version = "0.12.1" 626 + source = "registry+https://github.com/rust-lang/crates.io-index" 627 + checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" 628 + dependencies = [ 629 + "digest", 630 + ] 631 + 632 + [[package]] 507 633 name = "html5ever" 508 634 version = "0.26.0" 509 635 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 889 1015 checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" 890 1016 891 1017 [[package]] 1018 + name = "libredox" 1019 + version = "0.1.12" 1020 + source = "registry+https://github.com/rust-lang/crates.io-index" 1021 + checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" 1022 + dependencies = [ 1023 + "bitflags 2.10.0", 1024 + "libc", 1025 + "redox_syscall 0.7.0", 1026 + ] 1027 + 1028 + [[package]] 892 1029 name = "linux-raw-sys" 893 1030 version = "0.11.0" 894 1031 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 929 1066 "malfestio-core", 930 1067 "malfestio-server", 931 1068 "tokio", 1069 + "tokio-postgres", 932 1070 ] 933 1071 934 1072 [[package]] ··· 946 1084 dependencies = [ 947 1085 "axum", 948 1086 "chrono", 1087 + "deadpool-postgres", 949 1088 "malfestio-core", 950 1089 "readability", 951 1090 "regex", ··· 953 1092 "serde", 954 1093 "serde_json", 955 1094 "tokio", 1095 + "tokio-postgres", 956 1096 "tower", 957 1097 "tower-cookies", 958 1098 "tower-http", ··· 968 1108 checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" 969 1109 dependencies = [ 970 1110 "log", 971 - "phf", 1111 + "phf 0.10.1", 972 1112 "phf_codegen", 973 1113 "string_cache", 974 1114 "string_cache_codegen", ··· 1003 1143 checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" 1004 1144 1005 1145 [[package]] 1146 + name = "md-5" 1147 + version = "0.10.6" 1148 + source = "registry+https://github.com/rust-lang/crates.io-index" 1149 + checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" 1150 + dependencies = [ 1151 + "cfg-if", 1152 + "digest", 1153 + ] 1154 + 1155 + [[package]] 1006 1156 name = "memchr" 1007 1157 version = "2.7.6" 1008 1158 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1073 1223 ] 1074 1224 1075 1225 [[package]] 1226 + name = "num_cpus" 1227 + version = "1.17.0" 1228 + source = "registry+https://github.com/rust-lang/crates.io-index" 1229 + checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" 1230 + dependencies = [ 1231 + "hermit-abi", 1232 + "libc", 1233 + ] 1234 + 1235 + [[package]] 1076 1236 name = "once_cell" 1077 1237 version = "1.21.3" 1078 1238 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1146 1306 dependencies = [ 1147 1307 "cfg-if", 1148 1308 "libc", 1149 - "redox_syscall", 1309 + "redox_syscall 0.5.18", 1150 1310 "smallvec", 1151 1311 "windows-link", 1152 1312 ] ··· 1167 1327 ] 1168 1328 1169 1329 [[package]] 1330 + name = "phf" 1331 + version = "0.13.1" 1332 + source = "registry+https://github.com/rust-lang/crates.io-index" 1333 + checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" 1334 + dependencies = [ 1335 + "phf_shared 0.13.1", 1336 + "serde", 1337 + ] 1338 + 1339 + [[package]] 1170 1340 name = "phf_codegen" 1171 1341 version = "0.10.0" 1172 1342 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1215 1385 ] 1216 1386 1217 1387 [[package]] 1388 + name = "phf_shared" 1389 + version = "0.13.1" 1390 + source = "registry+https://github.com/rust-lang/crates.io-index" 1391 + checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" 1392 + dependencies = [ 1393 + "siphasher 1.0.1", 1394 + ] 1395 + 1396 + [[package]] 1218 1397 name = "pin-project-lite" 1219 1398 version = "0.2.16" 1220 1399 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1233 1412 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1234 1413 1235 1414 [[package]] 1415 + name = "postgres-protocol" 1416 + version = "0.6.9" 1417 + source = "registry+https://github.com/rust-lang/crates.io-index" 1418 + checksum = "fbef655056b916eb868048276cfd5d6a7dea4f81560dfd047f97c8c6fe3fcfd4" 1419 + dependencies = [ 1420 + "base64 0.22.1", 1421 + "byteorder", 1422 + "bytes", 1423 + "fallible-iterator", 1424 + "hmac", 1425 + "md-5", 1426 + "memchr", 1427 + "rand 0.9.2", 1428 + "sha2", 1429 + "stringprep", 1430 + ] 1431 + 1432 + [[package]] 1433 + name = "postgres-types" 1434 + version = "0.2.11" 1435 + source = "registry+https://github.com/rust-lang/crates.io-index" 1436 + checksum = "ef4605b7c057056dd35baeb6ac0c0338e4975b1f2bef0f65da953285eb007095" 1437 + dependencies = [ 1438 + "bytes", 1439 + "chrono", 1440 + "fallible-iterator", 1441 + "postgres-protocol", 1442 + "serde_core", 1443 + "serde_json", 1444 + "uuid", 1445 + ] 1446 + 1447 + [[package]] 1236 1448 name = "potential_utf" 1237 1449 version = "0.1.4" 1238 1450 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1364 1576 version = "0.5.18" 1365 1577 source = "registry+https://github.com/rust-lang/crates.io-index" 1366 1578 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 1579 + dependencies = [ 1580 + "bitflags 2.10.0", 1581 + ] 1582 + 1583 + [[package]] 1584 + name = "redox_syscall" 1585 + version = "0.7.0" 1586 + source = "registry+https://github.com/rust-lang/crates.io-index" 1587 + checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" 1367 1588 dependencies = [ 1368 1589 "bitflags 2.10.0", 1369 1590 ] ··· 1663 1884 ] 1664 1885 1665 1886 [[package]] 1887 + name = "sha2" 1888 + version = "0.10.9" 1889 + source = "registry+https://github.com/rust-lang/crates.io-index" 1890 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" 1891 + dependencies = [ 1892 + "cfg-if", 1893 + "cpufeatures", 1894 + "digest", 1895 + ] 1896 + 1897 + [[package]] 1666 1898 name = "sharded-slab" 1667 1899 version = "0.1.7" 1668 1900 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1760 1992 "phf_shared 0.11.3", 1761 1993 "proc-macro2", 1762 1994 "quote", 1995 + ] 1996 + 1997 + [[package]] 1998 + name = "stringprep" 1999 + version = "0.1.5" 2000 + source = "registry+https://github.com/rust-lang/crates.io-index" 2001 + checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" 2002 + dependencies = [ 2003 + "unicode-bidi", 2004 + "unicode-normalization", 2005 + "unicode-properties", 1763 2006 ] 1764 2007 1765 2008 [[package]] ··· 1959 2202 ] 1960 2203 1961 2204 [[package]] 2205 + name = "tinyvec" 2206 + version = "1.10.0" 2207 + source = "registry+https://github.com/rust-lang/crates.io-index" 2208 + checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" 2209 + dependencies = [ 2210 + "tinyvec_macros", 2211 + ] 2212 + 2213 + [[package]] 2214 + name = "tinyvec_macros" 2215 + version = "0.1.1" 2216 + source = "registry+https://github.com/rust-lang/crates.io-index" 2217 + checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 2218 + 2219 + [[package]] 1962 2220 name = "tokio" 1963 2221 version = "1.48.0" 1964 2222 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1994 2252 dependencies = [ 1995 2253 "native-tls", 1996 2254 "tokio", 2255 + ] 2256 + 2257 + [[package]] 2258 + name = "tokio-postgres" 2259 + version = "0.7.15" 2260 + source = "registry+https://github.com/rust-lang/crates.io-index" 2261 + checksum = "2b40d66d9b2cfe04b628173409368e58247e8eddbbd3b0e6c6ba1d09f20f6c9e" 2262 + dependencies = [ 2263 + "async-trait", 2264 + "byteorder", 2265 + "bytes", 2266 + "fallible-iterator", 2267 + "futures-channel", 2268 + "futures-util", 2269 + "log", 2270 + "parking_lot", 2271 + "percent-encoding", 2272 + "phf 0.13.1", 2273 + "pin-project-lite", 2274 + "postgres-protocol", 2275 + "postgres-types", 2276 + "rand 0.9.2", 2277 + "socket2 0.6.1", 2278 + "tokio", 2279 + "tokio-util", 2280 + "whoami", 1997 2281 ] 1998 2282 1999 2283 [[package]] ··· 2151 2435 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2152 2436 2153 2437 [[package]] 2438 + name = "typenum" 2439 + version = "1.19.0" 2440 + source = "registry+https://github.com/rust-lang/crates.io-index" 2441 + checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 2442 + 2443 + [[package]] 2444 + name = "unicode-bidi" 2445 + version = "0.3.18" 2446 + source = "registry+https://github.com/rust-lang/crates.io-index" 2447 + checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" 2448 + 2449 + [[package]] 2154 2450 name = "unicode-ident" 2155 2451 version = "1.0.22" 2156 2452 source = "registry+https://github.com/rust-lang/crates.io-index" 2157 2453 checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" 2158 2454 2159 2455 [[package]] 2456 + name = "unicode-normalization" 2457 + version = "0.1.25" 2458 + source = "registry+https://github.com/rust-lang/crates.io-index" 2459 + checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" 2460 + dependencies = [ 2461 + "tinyvec", 2462 + ] 2463 + 2464 + [[package]] 2465 + name = "unicode-properties" 2466 + version = "0.1.4" 2467 + source = "registry+https://github.com/rust-lang/crates.io-index" 2468 + checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" 2469 + 2470 + [[package]] 2160 2471 name = "untrusted" 2161 2472 version = "0.9.0" 2162 2473 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2247 2558 ] 2248 2559 2249 2560 [[package]] 2561 + name = "wasite" 2562 + version = "0.1.0" 2563 + source = "registry+https://github.com/rust-lang/crates.io-index" 2564 + checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" 2565 + 2566 + [[package]] 2250 2567 name = "wasm-bindgen" 2251 2568 version = "0.2.106" 2252 2569 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2312 2629 dependencies = [ 2313 2630 "js-sys", 2314 2631 "wasm-bindgen", 2632 + ] 2633 + 2634 + [[package]] 2635 + name = "whoami" 2636 + version = "1.6.1" 2637 + source = "registry+https://github.com/rust-lang/crates.io-index" 2638 + checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" 2639 + dependencies = [ 2640 + "libredox", 2641 + "wasite", 2642 + "web-sys", 2315 2643 ] 2316 2644 2317 2645 [[package]]
+1
crates/cli/Cargo.toml
··· 8 8 malfestio-core = { version = "0.1.0", path = "../core" } 9 9 malfestio-server = { version = "0.1.0", path = "../server" } 10 10 tokio = { version = "1.48.0", features = ["full"] } 11 + tokio-postgres = "0.7.13"
+113
crates/cli/src/main.rs
··· 1 1 use clap::{Parser, Subcommand}; 2 + use std::fs; 3 + use std::path::Path; 4 + use tokio_postgres::NoTls; 2 5 3 6 #[derive(Parser)] 4 7 #[command(name = "malfestio")] ··· 14 17 enum Commands { 15 18 /// Start the backend server 16 19 Start, 20 + /// Run database migrations 21 + Migrate { 22 + /// Database URL (defaults to DB_URL env var) 23 + #[arg(long)] 24 + db_url: Option<String>, 25 + }, 17 26 } 18 27 19 28 #[tokio::main] ··· 24 33 Commands::Start => { 25 34 malfestio_server::start().await?; 26 35 } 36 + Commands::Migrate { db_url } => { 37 + run_migrations(db_url.as_deref()).await?; 38 + } 27 39 } 28 40 29 41 Ok(()) 30 42 } 43 + 44 + async fn run_migrations(db_url: Option<&str>) -> malfestio_core::Result<()> { 45 + let db_url = db_url 46 + .map(String::from) 47 + .or_else(|| std::env::var("DB_URL").ok()) 48 + .ok_or_else(|| { 49 + malfestio_core::Error::InvalidArgument("DB_URL not provided via --db-url or DB_URL env var".to_string()) 50 + })?; 51 + 52 + println!("🔌 Connecting to database..."); 53 + let (mut client, connection) = tokio_postgres::connect(&db_url, NoTls) 54 + .await 55 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to connect to database: {}", e)))?; 56 + 57 + tokio::spawn(async move { 58 + if let Err(e) = connection.await { 59 + eprintln!("Database connection error: {}", e); 60 + } 61 + }); 62 + 63 + println!("Connected to database"); 64 + 65 + client 66 + .execute( 67 + "CREATE TABLE IF NOT EXISTS schema_migrations ( 68 + id SERIAL PRIMARY KEY, 69 + version TEXT NOT NULL UNIQUE, 70 + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 71 + )", 72 + &[], 73 + ) 74 + .await 75 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to create migrations table: {}", e)))?; 76 + 77 + let migrations_dir = Path::new("migrations"); 78 + if !migrations_dir.exists() { 79 + return Err(malfestio_core::Error::InvalidArgument( 80 + "migrations directory not found".to_string(), 81 + )); 82 + } 83 + 84 + let mut entries: Vec<_> = fs::read_dir(migrations_dir) 85 + .map_err(|e| malfestio_core::Error::Other(format!("Failed to read migrations directory: {}", e)))? 86 + .filter_map(|e| e.ok()) 87 + .filter(|e| { 88 + e.path() 89 + .extension() 90 + .and_then(|s| s.to_str()) 91 + .map(|s| s == "sql") 92 + .unwrap_or(false) 93 + }) 94 + .collect(); 95 + 96 + entries.sort_by_key(|e| e.file_name()); 97 + 98 + println!("Found {} migration files", entries.len()); 99 + 100 + for entry in entries { 101 + let path = entry.path(); 102 + let filename = path.file_name().unwrap().to_str().unwrap(); 103 + let version = filename.trim_end_matches(".sql"); 104 + 105 + let row = client 106 + .query_opt("SELECT version FROM schema_migrations WHERE version = $1", &[&version]) 107 + .await 108 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to check migration status: {}", e)))?; 109 + 110 + if row.is_some() { 111 + println!("Skipping {}: already applied", filename); 112 + continue; 113 + } 114 + 115 + println!("Applying {}...", filename); 116 + 117 + let sql = fs::read_to_string(&path) 118 + .map_err(|e| malfestio_core::Error::Other(format!("Failed to read migration file: {}", e)))?; 119 + 120 + let tx = client 121 + .transaction() 122 + .await 123 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to start transaction: {}", e)))?; 124 + 125 + tx.batch_execute(&sql) 126 + .await 127 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to execute migration {}: {}", filename, e)))?; 128 + 129 + tx.execute("INSERT INTO schema_migrations (version) VALUES ($1)", &[&version]) 130 + .await 131 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to record migration: {}", e)))?; 132 + 133 + tx.commit() 134 + .await 135 + .map_err(|e| malfestio_core::Error::Database(format!("Failed to commit migration: {}", e)))?; 136 + 137 + println!("Applied {}", filename); 138 + } 139 + 140 + println!("All migrations complete!"); 141 + 142 + Ok(()) 143 + }
+2
crates/server/Cargo.toml
··· 6 6 [dependencies] 7 7 axum = "0.8.8" 8 8 chrono = { version = "0.4.42", features = ["serde"] } 9 + deadpool-postgres = "0.14.0" 9 10 malfestio-core = { version = "0.1.0", path = "../core" } 10 11 readability = "0.3.0" 11 12 regex = "1.12.2" ··· 13 14 serde = "1.0.228" 14 15 serde_json = "1.0.148" 15 16 tokio = { version = "1.48.0", features = ["full"] } 17 + tokio-postgres = { version = "0.7.13", features = ["with-serde_json-1", "with-chrono-0_4", "with-uuid-1"] } 16 18 tower = "0.5.2" 17 19 tower-cookies = "0.11.0" 18 20 tower-http = { version = "0.6.8", features = ["cors", "trace"] }
+12 -58
crates/server/src/api/card.rs
··· 1 1 use crate::middleware::auth::UserContext; 2 2 use crate::state::SharedState; 3 + 3 4 use axum::{ 4 5 Json, 5 6 extract::{Extension, Path, State}, 6 7 http::StatusCode, 7 8 response::IntoResponse, 8 9 }; 9 - use malfestio_core::model::{Card, Visibility}; 10 10 use serde::Deserialize; 11 11 use serde_json::json; 12 12 13 13 #[derive(Deserialize)] 14 + #[allow(dead_code)] 14 15 pub struct CreateCardRequest { 15 16 deck_id: String, 16 17 front: String, ··· 19 20 } 20 21 21 22 pub async fn create_card( 22 - State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Json(payload): Json<CreateCardRequest>, 23 + State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Json(_payload): Json<CreateCardRequest>, 23 24 ) -> impl IntoResponse { 24 - let user = match ctx { 25 - Some(axum::Extension(user)) => user, 26 - None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 27 - }; 28 - 29 - { 30 - let decks = state.decks.read().unwrap(); 31 - let deck = decks.iter().find(|d| d.id == payload.deck_id); 32 - match deck { 33 - Some(d) => { 34 - if d.owner_did != user.did { 35 - return (StatusCode::FORBIDDEN, Json(json!({"error": "Not deck owner"}))).into_response(); 36 - } 37 - } 38 - None => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), 39 - } 40 - } 41 - 42 - let new_card = Card { 43 - id: uuid::Uuid::new_v4().to_string(), 44 - owner_did: user.did, 45 - deck_id: payload.deck_id, 46 - front: payload.front, 47 - back: payload.back, 48 - media_url: payload.media_url, 49 - }; 50 - 51 - state.cards.write().unwrap().push(new_card.clone()); 52 - 53 - (StatusCode::CREATED, Json(new_card)).into_response() 25 + // TODO: Implement database-backed card creation 26 + ( 27 + StatusCode::NOT_IMPLEMENTED, 28 + Json(json!({"error": "Card creation not yet implemented with database"})), 29 + ) 30 + .into_response() 54 31 } 55 32 56 33 pub async fn list_cards( 57 - State(state): State<SharedState>, Path(deck_id): Path<String>, ctx: Option<axum::Extension<UserContext>>, 34 + State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Path(_deck_id): Path<String>, 58 35 ) -> impl IntoResponse { 59 - let user_did = ctx.map(|Extension(u)| u.did); 60 - 61 - { 62 - let decks = state.decks.read().unwrap(); 63 - if let Some(deck) = decks.iter().find(|d| d.id == deck_id) { 64 - let is_owner = user_did.as_ref() == Some(&deck.owner_did); 65 - if deck.visibility == Visibility::Private && !is_owner { 66 - return (StatusCode::FORBIDDEN, Json(json!({"error": "Private deck"}))).into_response(); 67 - } 68 - 69 - if let Visibility::SharedWith(dids) = &deck.visibility 70 - && !is_owner 71 - && (user_did.is_none() || !dids.contains(user_did.as_ref().unwrap())) 72 - { 73 - return (StatusCode::FORBIDDEN, Json(json!({"error": "Access denied"}))).into_response(); 74 - } 75 - } else { 76 - return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(); 77 - } 78 - } 79 - 80 - let cards = state.cards.read().unwrap(); 81 - let deck_cards: Vec<Card> = cards.iter().filter(|c| c.deck_id == deck_id).cloned().collect(); 82 - 83 - Json(deck_cards).into_response() 36 + // TODO: Implement database-backed card listing 37 + Json(Vec::<serde_json::Value>::new()).into_response() 84 38 }
+287 -184
crates/server/src/api/deck.rs
··· 32 32 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 33 33 }; 34 34 35 + let pool = &state.pool; 36 + let client = match pool.get().await { 37 + Ok(client) => client, 38 + Err(e) => { 39 + tracing::error!("Failed to get database connection: {}", e); 40 + return ( 41 + StatusCode::INTERNAL_SERVER_ERROR, 42 + Json(json!({"error": "Database connection failed"})), 43 + ) 44 + .into_response(); 45 + } 46 + }; 47 + 48 + let deck_id = uuid::Uuid::new_v4(); 49 + let visibility_json = match serde_json::to_value(&payload.visibility) { 50 + Ok(v) => v, 51 + Err(e) => { 52 + tracing::error!("Failed to serialize visibility: {}", e); 53 + return ( 54 + StatusCode::INTERNAL_SERVER_ERROR, 55 + Json(json!({"error": "Failed to serialize visibility"})), 56 + ) 57 + .into_response(); 58 + } 59 + }; 60 + 61 + let result = client 62 + .execute( 63 + "INSERT INTO decks (id, owner_did, title, description, tags, visibility) 64 + VALUES ($1, $2, $3, $4, $5, $6)", 65 + &[ 66 + &deck_id, 67 + &user.did, 68 + &payload.title, 69 + &payload.description, 70 + &payload.tags, 71 + &visibility_json, 72 + ], 73 + ) 74 + .await; 75 + 76 + if let Err(e) = result { 77 + tracing::error!("Failed to insert deck: {}", e); 78 + return ( 79 + StatusCode::INTERNAL_SERVER_ERROR, 80 + Json(json!({"error": "Failed to create deck"})), 81 + ) 82 + .into_response(); 83 + } 84 + 35 85 let new_deck = Deck { 36 - id: uuid::Uuid::new_v4().to_string(), 86 + id: deck_id.to_string(), 37 87 owner_did: user.did, 38 88 title: payload.title, 39 89 description: payload.description, ··· 43 93 fork_of: None, 44 94 }; 45 95 46 - state.decks.write().unwrap().push(new_deck.clone()); 47 - 48 96 (StatusCode::CREATED, Json(new_deck)).into_response() 49 97 } 50 98 ··· 53 101 ) -> impl IntoResponse { 54 102 let user_did = ctx.map(|Extension(u)| u.did); 55 103 56 - let decks = state.decks.read().unwrap(); 104 + let pool = &state.pool; 105 + let client = match pool.get().await { 106 + Ok(client) => client, 107 + Err(e) => { 108 + tracing::error!("Failed to get database connection: {}", e); 109 + return ( 110 + StatusCode::INTERNAL_SERVER_ERROR, 111 + Json(json!({"error": "Database connection failed"})), 112 + ) 113 + .into_response(); 114 + } 115 + }; 57 116 58 - let visible_decks: Vec<Deck> = decks 59 - .iter() 60 - .filter(|d| { 61 - if let Some(did) = &user_did 62 - && &d.owner_did == did 63 - { 64 - return true; 65 - } 66 - if d.visibility == Visibility::Public { 67 - return true; 68 - } 69 - if let Visibility::SharedWith(dids) = &d.visibility 70 - && let Some(did) = &user_did 71 - && dids.contains(did) 72 - { 73 - return true; 117 + let query = if let Some(ref _did) = user_did { 118 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at, updated_at 119 + FROM decks 120 + WHERE owner_did = $1 121 + OR visibility->>'type' = 'Public' 122 + OR visibility->>'type' = 'Unlisted' 123 + OR (visibility->>'type' = 'SharedWith' AND visibility->'content' ? $1) 124 + ORDER BY created_at DESC" 125 + } else { 126 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at, updated_at 127 + FROM decks 128 + WHERE visibility->>'type' IN ('Public', 'Unlisted') 129 + ORDER BY created_at DESC" 130 + }; 131 + 132 + let rows = if let Some(ref did) = user_did { 133 + client.query(query, &[did]).await 134 + } else { 135 + client.query(query, &[]).await 136 + }; 137 + 138 + let rows = match rows { 139 + Ok(rows) => rows, 140 + Err(e) => { 141 + tracing::error!("Failed to query decks: {}", e); 142 + return ( 143 + StatusCode::INTERNAL_SERVER_ERROR, 144 + Json(json!({"error": "Failed to retrieve decks"})), 145 + ) 146 + .into_response(); 147 + } 148 + }; 149 + 150 + let mut decks = Vec::new(); 151 + for row in rows { 152 + let visibility_json: serde_json::Value = row.get("visibility"); 153 + let visibility: Visibility = match serde_json::from_value(visibility_json) { 154 + Ok(v) => v, 155 + Err(e) => { 156 + tracing::error!("Failed to deserialize visibility: {}", e); 157 + continue; 74 158 } 75 - false 76 - }) 77 - .cloned() 78 - .collect(); 159 + }; 160 + 161 + let id: uuid::Uuid = row.get("id"); 162 + let fork_of: Option<uuid::Uuid> = row.get("fork_of"); 79 163 80 - Json(visible_decks).into_response() 164 + decks.push(Deck { 165 + id: id.to_string(), 166 + owner_did: row.get("owner_did"), 167 + title: row.get("title"), 168 + description: row.get("description"), 169 + tags: row.get("tags"), 170 + visibility, 171 + published_at: row 172 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 173 + .map(|dt| dt.to_rfc3339()), 174 + fork_of: fork_of.map(|u| u.to_string()), 175 + }); 176 + } 177 + 178 + Json(decks).into_response() 81 179 } 82 180 83 181 pub async fn get_deck( 84 182 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 85 183 ) -> impl IntoResponse { 86 184 let user_did = ctx.map(|Extension(u)| u.did); 87 - let decks = state.decks.read().unwrap(); 88 185 89 - if let Some(deck) = decks.iter().find(|d| d.id == id) { 90 - let is_owner = user_did.as_ref() == Some(&deck.owner_did); 186 + let pool = &state.pool; 187 + let client = match pool.get().await { 188 + Ok(client) => client, 189 + Err(e) => { 190 + tracing::error!("Failed to get database connection: {}", e); 191 + return ( 192 + StatusCode::INTERNAL_SERVER_ERROR, 193 + Json(json!({"error": "Database connection failed"})), 194 + ) 195 + .into_response(); 196 + } 197 + }; 91 198 92 - if deck.visibility == Visibility::Public || is_owner { 93 - return Json(deck).into_response(); 94 - } 199 + let deck_id = match uuid::Uuid::parse_str(&id) { 200 + Ok(uuid) => uuid, 201 + Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(), 202 + }; 95 203 96 - if let Visibility::SharedWith(dids) = &deck.visibility 97 - && let Some(did) = &user_did 98 - && dids.contains(did) 99 - { 100 - return Json(deck).into_response(); 204 + let row = match client 205 + .query_opt( 206 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of, created_at, updated_at 207 + FROM decks WHERE id = $1", 208 + &[&deck_id], 209 + ) 210 + .await 211 + { 212 + Ok(Some(row)) => row, 213 + Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), 214 + Err(e) => { 215 + tracing::error!("Failed to query deck: {}", e); 216 + return ( 217 + StatusCode::INTERNAL_SERVER_ERROR, 218 + Json(json!({"error": "Failed to retrieve deck"})), 219 + ) 220 + .into_response(); 101 221 } 222 + }; 102 223 103 - if deck.visibility == Visibility::Unlisted { 104 - return Json(deck).into_response(); 224 + let visibility_json: serde_json::Value = row.get("visibility"); 225 + let visibility: Visibility = match serde_json::from_value(visibility_json) { 226 + Ok(v) => v, 227 + Err(e) => { 228 + tracing::error!("Failed to deserialize visibility: {}", e); 229 + return ( 230 + StatusCode::INTERNAL_SERVER_ERROR, 231 + Json(json!({"error": "Failed to parse deck visibility"})), 232 + ) 233 + .into_response(); 105 234 } 235 + }; 236 + 237 + let owner_did: String = row.get("owner_did"); 238 + let is_owner = user_did.as_ref() == Some(&owner_did); 239 + 240 + let has_access = match &visibility { 241 + Visibility::Public | Visibility::Unlisted => true, 242 + Visibility::Private => is_owner, 243 + Visibility::SharedWith(dids) => is_owner || user_did.as_ref().map(|did| dids.contains(did)).unwrap_or(false), 244 + }; 245 + 246 + if !has_access { 106 247 return (StatusCode::FORBIDDEN, Json(json!({"error": "Access denied"}))).into_response(); 107 248 } 108 249 109 - (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 250 + let uuid_id: uuid::Uuid = row.get("id"); 251 + let fork_of: Option<uuid::Uuid> = row.get("fork_of"); 252 + 253 + let deck = Deck { 254 + id: uuid_id.to_string(), 255 + owner_did, 256 + title: row.get("title"), 257 + description: row.get("description"), 258 + tags: row.get("tags"), 259 + visibility, 260 + published_at: row 261 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 262 + .map(|dt| dt.to_rfc3339()), 263 + fork_of: fork_of.map(|u| u.to_string()), 264 + }; 265 + 266 + Json(deck).into_response() 110 267 } 111 268 112 - /// NOTE: Unpublishing sets visibility to Private and clears published_at 113 269 pub async fn publish_deck( 114 270 State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 115 271 Json(payload): Json<PublishDeckRequest>, ··· 119 275 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 120 276 }; 121 277 122 - let mut decks = state.decks.write().unwrap(); 123 - if let Some(deck) = decks.iter_mut().find(|d| d.id == id) { 124 - if deck.owner_did != user.did { 125 - return (StatusCode::FORBIDDEN, Json(json!({"error": "Only owner can publish"}))).into_response(); 278 + let pool = &state.pool; 279 + let client = match pool.get().await { 280 + Ok(client) => client, 281 + Err(e) => { 282 + tracing::error!("Failed to get database connection: {}", e); 283 + return ( 284 + StatusCode::INTERNAL_SERVER_ERROR, 285 + Json(json!({"error": "Database connection failed"})), 286 + ) 287 + .into_response(); 126 288 } 289 + }; 127 290 128 - if payload.published { 129 - deck.visibility = Visibility::Public; 130 - deck.published_at = Some(chrono::Utc::now().to_rfc3339()); 131 - } else { 132 - deck.visibility = Visibility::Private; 133 - deck.published_at = None; 291 + let deck_id = match uuid::Uuid::parse_str(&id) { 292 + Ok(uuid) => uuid, 293 + Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "Invalid deck ID"}))).into_response(), 294 + }; 295 + 296 + let deck_row = match client 297 + .query_opt("SELECT owner_did FROM decks WHERE id = $1", &[&deck_id]) 298 + .await 299 + { 300 + Ok(Some(row)) => row, 301 + Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response(), 302 + Err(e) => { 303 + tracing::error!("Failed to query deck: {}", e); 304 + return ( 305 + StatusCode::INTERNAL_SERVER_ERROR, 306 + Json(json!({"error": "Database error"})), 307 + ) 308 + .into_response(); 134 309 } 135 - return Json(deck.clone()).into_response(); 136 - } 137 - 138 - (StatusCode::NOT_FOUND, Json(json!({"error": "Deck not found"}))).into_response() 139 - } 310 + }; 140 311 141 - #[cfg(test)] 142 - mod tests { 143 - use super::*; 144 - use crate::state::AppState; 145 - use axum::extract::State; 146 - 147 - fn mock_state() -> SharedState { 148 - let state = AppState::new(); 149 - let mut decks = state.decks.write().unwrap(); 150 - *decks = vec![ 151 - Deck { 152 - id: "deck-public".to_string(), 153 - owner_did: "did:plc:owner".to_string(), 154 - title: "Public Deck".to_string(), 155 - description: "desc".to_string(), 156 - tags: vec![], 157 - visibility: Visibility::Public, 158 - published_at: None, 159 - fork_of: None, 160 - }, 161 - Deck { 162 - id: "deck-private".to_string(), 163 - owner_did: "did:plc:owner".to_string(), 164 - title: "Private Deck".to_string(), 165 - description: "desc".to_string(), 166 - tags: vec![], 167 - visibility: Visibility::Private, 168 - published_at: None, 169 - fork_of: None, 170 - }, 171 - Deck { 172 - id: "deck-shared".to_string(), 173 - owner_did: "did:plc:owner".to_string(), 174 - title: "Shared Deck".to_string(), 175 - description: "desc".to_string(), 176 - tags: vec![], 177 - visibility: Visibility::SharedWith(vec!["did:plc:friend".to_string()]), 178 - published_at: None, 179 - fork_of: None, 180 - }, 181 - ]; 182 - state.clone() 312 + let owner_did: String = deck_row.get("owner_did"); 313 + if owner_did != user.did { 314 + return (StatusCode::FORBIDDEN, Json(json!({"error": "Only owner can publish"}))).into_response(); 183 315 } 184 316 185 - #[tokio::test] 186 - async fn test_get_public_deck() { 187 - let state = mock_state(); 188 - let response = get_deck(State(state), None, Path("deck-public".to_string())) 189 - .await 190 - .into_response(); 317 + let (new_visibility, published_at) = if payload.published { 318 + ( 319 + serde_json::to_value(&Visibility::Public).unwrap(), 320 + Some(chrono::Utc::now()), 321 + ) 322 + } else { 323 + (serde_json::to_value(&Visibility::Private).unwrap(), None) 324 + }; 191 325 192 - assert_eq!(response.status(), StatusCode::OK); 193 - } 194 - 195 - #[tokio::test] 196 - async fn test_get_private_deck_owner() { 197 - let state = mock_state(); 198 - let ctx = Some(Extension(UserContext { 199 - did: "did:plc:owner".to_string(), 200 - handle: "owner.bsky.social".to_string(), 201 - })); 202 - 203 - let response = get_deck(State(state), ctx, Path("deck-private".to_string())) 204 - .await 205 - .into_response(); 206 - 207 - assert_eq!(response.status(), StatusCode::OK); 326 + match client 327 + .execute( 328 + "UPDATE decks SET visibility = $1, published_at = $2 WHERE id = $3", 329 + &[&new_visibility, &published_at, &deck_id], 330 + ) 331 + .await 332 + { 333 + Ok(_) => (), 334 + Err(e) => { 335 + tracing::error!("Failed to update deck: {}", e); 336 + return ( 337 + StatusCode::INTERNAL_SERVER_ERROR, 338 + Json(json!({"error": "Failed to update deck"})), 339 + ) 340 + .into_response(); 341 + } 208 342 } 209 343 210 - #[tokio::test] 211 - async fn test_get_private_deck_stranger() { 212 - let state = mock_state(); 213 - let ctx = Some(Extension(UserContext { 214 - did: "did:plc:stranger".to_string(), 215 - handle: "stranger.bsky.social".to_string(), 216 - })); 217 - 218 - let response = get_deck(State(state), ctx, Path("deck-private".to_string())) 219 - .await 220 - .into_response(); 221 - 222 - assert_eq!(response.status(), StatusCode::FORBIDDEN); 223 - } 224 - 225 - #[tokio::test] 226 - async fn test_get_shared_deck_permitted() { 227 - let state = mock_state(); 228 - let ctx = Some(Extension(UserContext { 229 - did: "did:plc:friend".to_string(), 230 - handle: "friend.bsky.social".to_string(), 231 - })); 232 - 233 - let response = get_deck(State(state), ctx, Path("deck-shared".to_string())) 234 - .await 235 - .into_response(); 236 - 237 - assert_eq!(response.status(), StatusCode::OK); 238 - } 239 - 240 - #[tokio::test] 241 - async fn test_get_shared_deck_unpermitted() { 242 - let state = mock_state(); 243 - let ctx = Some(Extension(UserContext { 244 - did: "did:plc:stranger".to_string(), 245 - handle: "stranger.bsky.social".to_string(), 246 - })); 247 - 248 - let response = get_deck(State(state), ctx, Path("deck-shared".to_string())) 249 - .await 250 - .into_response(); 251 - 252 - assert_eq!(response.status(), StatusCode::FORBIDDEN); 253 - } 254 - 255 - #[tokio::test] 256 - async fn test_publish_deck() { 257 - let state = mock_state(); 258 - let ctx = Some(Extension(UserContext { 259 - did: "did:plc:owner".to_string(), 260 - handle: "owner.bsky.social".to_string(), 261 - })); 262 - 263 - let response = publish_deck( 264 - State(state.clone()), 265 - ctx, 266 - Path("deck-private".to_string()), 267 - Json(PublishDeckRequest { published: true }), 344 + let row = match client 345 + .query_one( 346 + "SELECT id, owner_did, title, description, tags, visibility, published_at, fork_of 347 + FROM decks WHERE id = $1", 348 + &[&deck_id], 268 349 ) 269 350 .await 270 - .into_response(); 351 + { 352 + Ok(row) => row, 353 + Err(e) => { 354 + tracing::error!("Failed to fetch updated deck: {}", e); 355 + return ( 356 + StatusCode::INTERNAL_SERVER_ERROR, 357 + Json(json!({"error": "Failed to retrieve updated deck"})), 358 + ) 359 + .into_response(); 360 + } 361 + }; 271 362 272 - assert_eq!(response.status(), StatusCode::OK); 363 + let visibility_json: serde_json::Value = row.get("visibility"); 364 + let visibility: Visibility = serde_json::from_value(visibility_json).unwrap(); 365 + let uuid_id: uuid::Uuid = row.get("id"); 366 + let fork_of: Option<uuid::Uuid> = row.get("fork_of"); 367 + 368 + let deck = Deck { 369 + id: uuid_id.to_string(), 370 + owner_did: row.get("owner_did"), 371 + title: row.get("title"), 372 + description: row.get("description"), 373 + tags: row.get("tags"), 374 + visibility, 375 + published_at: row 376 + .get::<_, Option<chrono::DateTime<chrono::Utc>>>("published_at") 377 + .map(|dt| dt.to_rfc3339()), 378 + fork_of: fork_of.map(|u| u.to_string()), 379 + }; 273 380 274 - let decks = state.decks.read().unwrap(); 275 - let deck = decks.iter().find(|d| d.id == "deck-private").unwrap(); 276 - assert_eq!(deck.visibility, Visibility::Public); 277 - assert!(deck.published_at.is_some()); 278 - } 381 + Json(deck).into_response() 279 382 }
+15 -88
crates/server/src/api/note.rs
··· 1 1 use crate::middleware::auth::UserContext; 2 2 use crate::state::SharedState; 3 + 3 4 use axum::{ 4 5 Json, 5 6 extract::{Extension, Path, State}, 6 7 http::StatusCode, 7 8 response::IntoResponse, 8 9 }; 9 - use malfestio_core::model::{Note, Visibility}; 10 - use regex::Regex; 10 + use malfestio_core::model::Visibility; 11 11 use serde::Deserialize; 12 12 use serde_json::json; 13 13 14 14 #[derive(Deserialize)] 15 + #[allow(dead_code)] 15 16 pub struct CreateNoteRequest { 16 17 title: String, 17 18 body: String, ··· 19 20 visibility: Visibility, 20 21 } 21 22 22 - fn extract_links(body: &str) -> Vec<String> { 23 - let re = Regex::new(r"\[\[(.*?)\]\]").unwrap(); 24 - re.captures_iter(body).map(|cap| cap[1].to_string()).collect() 25 - } 26 - 27 23 pub async fn create_note( 28 - State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Json(payload): Json<CreateNoteRequest>, 24 + State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Json(_payload): Json<CreateNoteRequest>, 29 25 ) -> impl IntoResponse { 30 - let user = match ctx { 31 - Some(axum::Extension(user)) => user, 32 - None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 33 - }; 34 - 35 - let links = extract_links(&payload.body); 36 - 37 - let new_note = Note { 38 - id: uuid::Uuid::new_v4().to_string(), 39 - owner_did: user.did, 40 - title: payload.title, 41 - body: payload.body, 42 - tags: payload.tags, 43 - visibility: payload.visibility, 44 - published_at: None, 45 - links, 46 - }; 47 - 48 - state.notes.write().unwrap().push(new_note.clone()); 49 - 50 - (StatusCode::CREATED, Json(new_note)).into_response() 26 + // TODO: Implement database-backed note creation 27 + ( 28 + StatusCode::NOT_IMPLEMENTED, 29 + Json(json!({"error": "Note creation not yet implemented with database"})), 30 + ) 31 + .into_response() 51 32 } 52 33 53 - pub async fn list_notes( 54 - State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, 55 - ) -> impl IntoResponse { 56 - let user_did = ctx.map(|Extension(u)| u.did); 57 - let notes = state.notes.read().unwrap(); 58 - 59 - let visible_notes: Vec<Note> = notes 60 - .iter() 61 - .filter(|n| { 62 - if let Some(did) = &user_did 63 - && &n.owner_did == did 64 - { 65 - return true; 66 - } 67 - if n.visibility == Visibility::Public { 68 - return true; 69 - } 70 - if let Visibility::SharedWith(dids) = &n.visibility 71 - && let Some(did) = &user_did 72 - && dids.contains(did) 73 - { 74 - return true; 75 - } 76 - false 77 - }) 78 - .cloned() 79 - .collect(); 80 - 81 - Json(visible_notes).into_response() 34 + pub async fn list_notes(State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>) -> impl IntoResponse { 35 + // TODO: Implement database-backed note listing 36 + Json(Vec::<serde_json::Value>::new()).into_response() 82 37 } 83 38 84 39 pub async fn get_note( 85 - State(state): State<SharedState>, ctx: Option<axum::Extension<UserContext>>, Path(id): Path<String>, 40 + State(_state): State<SharedState>, _ctx: Option<Extension<UserContext>>, Path(_id): Path<String>, 86 41 ) -> impl IntoResponse { 87 - let user_did = ctx.map(|Extension(u)| u.did); 88 - let notes = state.notes.read().unwrap(); 89 - 90 - if let Some(note) = notes.iter().find(|n| n.id == id) { 91 - let is_owner = user_did.as_ref() == Some(&note.owner_did); 92 - 93 - if note.visibility == Visibility::Public || is_owner { 94 - let backlinks: Vec<String> = notes 95 - .iter() 96 - .filter(|n| n.links.contains(&note.title) && n.id != note.id) // Naive matching by title 97 - .map(|n| n.id.clone()) 98 - .collect(); 99 - 100 - let mut response = serde_json::to_value(note).unwrap(); 101 - response["backlinks"] = json!(backlinks); 102 - 103 - return Json(response).into_response(); 104 - } 105 - 106 - if let Visibility::SharedWith(dids) = &note.visibility 107 - && let Some(did) = &user_did 108 - && dids.contains(did) 109 - { 110 - return Json(note).into_response(); 111 - } 112 - 113 - return (StatusCode::FORBIDDEN, Json(json!({"error": "Access denied"}))).into_response(); 114 - } 115 - 42 + // TODO: Implement database-backed note retrieval 116 43 (StatusCode::NOT_FOUND, Json(json!({"error": "Note not found"}))).into_response() 117 44 }
+27
crates/server/src/db.rs
··· 1 + use deadpool_postgres::{Config, Manager, ManagerConfig, Pool, RecyclingMethod}; 2 + use tokio_postgres::NoTls; 3 + 4 + pub type DbPool = Pool; 5 + 6 + /// Initialize database connection pool from environment 7 + pub fn create_pool() -> Result<DbPool, Box<dyn std::error::Error>> { 8 + let db_url = std::env::var("DB_URL").map_err(|_| "DB_URL environment variable not set")?; 9 + 10 + let config = db_url.parse::<tokio_postgres::Config>()?; 11 + 12 + let mut pool_config = Config::new(); 13 + pool_config.dbname = config.get_dbname().map(String::from); 14 + pool_config.host = config.get_hosts().first().map(|h| match h { 15 + tokio_postgres::config::Host::Tcp(s) => s.clone(), 16 + #[cfg(unix)] 17 + tokio_postgres::config::Host::Unix(p) => p.to_string_lossy().to_string(), 18 + }); 19 + pool_config.port = config.get_ports().first().copied(); 20 + pool_config.user = config.get_user().map(String::from); 21 + pool_config.password = config.get_password().map(|p| String::from_utf8_lossy(p).to_string()); 22 + 23 + let mgr_config = ManagerConfig { recycling_method: RecyclingMethod::Fast }; 24 + let mgr = Manager::from_config(config, NoTls, mgr_config); 25 + 26 + Ok(Pool::builder(mgr).max_size(16).build()?) 27 + }
+9 -1
crates/server/src/lib.rs
··· 1 1 pub mod api; 2 + pub mod db; 2 3 pub mod middleware; 3 4 pub mod state; 4 5 ··· 28 29 29 30 tracing::info!("Starting Malfestio Server..."); 30 31 31 - let state = state::AppState::new(); 32 + let pool = db::create_pool().map_err(|e| { 33 + tracing::error!("Failed to create database pool: {}", e); 34 + malfestio_core::Error::Database(format!("Failed to create database pool: {}", e)) 35 + })?; 36 + 37 + tracing::info!("Database connection pool created"); 38 + 39 + let state = state::AppState::new(pool); 32 40 33 41 let auth_routes = Router::new() 34 42 .route("/me", get(api::auth::me))
+5 -11
crates/server/src/state.rs
··· 1 - use malfestio_core::model::{Card, Deck, Note}; 2 - use std::sync::{Arc, RwLock}; 1 + use crate::db::DbPool; 2 + use std::sync::Arc; 3 3 4 4 pub type SharedState = Arc<AppState>; 5 5 6 6 pub struct AppState { 7 - pub decks: RwLock<Vec<Deck>>, 8 - pub notes: RwLock<Vec<Note>>, 9 - pub cards: RwLock<Vec<Card>>, 7 + pub pool: DbPool, 10 8 } 11 9 12 10 impl AppState { 13 - pub fn new() -> SharedState { 14 - Arc::new(Self { 15 - decks: RwLock::new(Vec::new()), 16 - notes: RwLock::new(Vec::new()), 17 - cards: RwLock::new(Vec::new()), 18 - }) 11 + pub fn new(pool: DbPool) -> SharedState { 12 + Arc::new(Self { pool }) 19 13 } 20 14 }
+75
migrations/001_2025_12_28_initial_schema.sql
··· 1 + -- Initial schema for Malfestio 2 + -- This migration creates the core tables for decks, notes, and cards 3 + -- Note: ATProto lexicon records go to PDS, this DB is for blob storage & private data 4 + 5 + CREATE TABLE IF NOT EXISTS schema_migrations ( 6 + id SERIAL PRIMARY KEY, 7 + version TEXT NOT NULL UNIQUE, 8 + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 9 + ); 10 + 11 + CREATE TABLE decks ( 12 + id UUID PRIMARY KEY, 13 + owner_did TEXT NOT NULL, 14 + title TEXT NOT NULL, 15 + description TEXT NOT NULL, 16 + tags TEXT[] NOT NULL DEFAULT '{}', 17 + visibility JSONB NOT NULL, -- Stores { type: "Private" | "Unlisted" | "Public" | "SharedWith", content?: [...] } 18 + published_at TIMESTAMPTZ, 19 + fork_of UUID, 20 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 21 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 22 + ); 23 + 24 + CREATE INDEX idx_decks_owner_did ON decks(owner_did); 25 + CREATE INDEX idx_decks_visibility ON decks USING GIN(visibility); 26 + CREATE INDEX idx_decks_created_at ON decks(created_at DESC); 27 + 28 + CREATE TABLE cards ( 29 + id UUID PRIMARY KEY, 30 + owner_did TEXT NOT NULL, 31 + deck_id UUID NOT NULL REFERENCES decks(id) ON DELETE CASCADE, 32 + front TEXT NOT NULL, 33 + back TEXT NOT NULL, 34 + media_url TEXT, 35 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 36 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 37 + ); 38 + 39 + CREATE INDEX idx_cards_deck_id ON cards(deck_id); 40 + CREATE INDEX idx_cards_owner_did ON cards(owner_did); 41 + 42 + CREATE TABLE notes ( 43 + id UUID PRIMARY KEY, 44 + owner_did TEXT NOT NULL, 45 + title TEXT NOT NULL, 46 + body TEXT NOT NULL, 47 + tags TEXT[] NOT NULL DEFAULT '{}', 48 + visibility JSONB NOT NULL, 49 + published_at TIMESTAMPTZ, 50 + links TEXT[] NOT NULL DEFAULT '{}', -- WikiLink style references to other notes 51 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 52 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 53 + ); 54 + 55 + CREATE INDEX idx_notes_owner_did ON notes(owner_did); 56 + CREATE INDEX idx_notes_visibility ON notes USING GIN(visibility); 57 + CREATE INDEX idx_notes_created_at ON notes(created_at DESC); 58 + CREATE INDEX idx_notes_links ON notes USING GIN(links); 59 + 60 + CREATE OR REPLACE FUNCTION update_updated_at_column() 61 + RETURNS TRIGGER AS $$ 62 + BEGIN 63 + NEW.updated_at = NOW(); 64 + RETURN NEW; 65 + END; 66 + $$ LANGUAGE plpgsql; 67 + 68 + CREATE TRIGGER update_decks_updated_at BEFORE UPDATE ON decks 69 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); 70 + 71 + CREATE TRIGGER update_cards_updated_at BEFORE UPDATE ON cards 72 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); 73 + 74 + CREATE TRIGGER update_notes_updated_at BEFORE UPDATE ON notes 75 + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
+2 -1
web/package.json
··· 8 8 "build": "tsc -b && vite build", 9 9 "preview": "vite preview", 10 10 "test": "vitest", 11 - "check": "tsc --noEmit" 11 + "check": "tsc --noEmit", 12 + "lint": "eslint ." 12 13 }, 13 14 "dependencies": { 14 15 "@solidjs/meta": "^0.29.4",