Challenges Framework #3

merged
opened by baileytownsend.dev targeting main from feature/Challenges

Whew sorry. This ended up being a much bigger PR than I expected. Just kept growing. There is still some rough edges unwraps, and places code could be shared. But it's gotten too big and is in a spot i think people can start making their own challenges to try it out

  • Session store in redis now so don't logout
  • WireFrame HTML layout that I am not at all attached to but needed for testing the framework
  • What I'm calling the challenge framework. Lets you create a class based on a trait to check challenges. 100% customizable from loading in custom markdown, to writing your own render for the questions to a function to check each challenge's answers
  • /day/{day} endpoints for both view and check. Ideally should not have to touch these when making challenges but i'm sure it will need some changeing (like taking an input for a code. Kind of there already)
  • Middleware now checks for day of the month and if it is Dec. IF you have PROD=false in your .env does not turn it on tho
+2 -1
.env.template
··· 3 3 DB_NAME=advent 4 4 DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}" 5 5 DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:5432/${DB_NAME}" 6 - REDIS_URL=redis://127.0.0.1:6379/ 6 + REDIS_URL=redis://127.0.0.1:6379/ 7 + PROD=true
+2
.gitignore
··· 4 4 at-advent.db 5 5 at-advent.db-shm 6 6 at-advent.db-wal 7 + 8 + .DS_Store
+426 -16
Cargo.lock
··· 47 47 "libc", 48 48 ] 49 49 50 + [[package]] 51 + name = "askama" 52 + version = "0.14.0" 53 + source = "registry+https://github.com/rust-lang/crates.io-index" 54 + checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" 55 + dependencies = [ 56 + "askama_derive", 57 + "itoa", 58 + "percent-encoding", 59 + "serde", 60 + "serde_json", 61 + ] 62 + 63 + [[package]] 64 + name = "askama_derive" 65 + version = "0.14.0" 66 + source = "registry+https://github.com/rust-lang/crates.io-index" 67 + checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" 68 + dependencies = [ 69 + "askama_parser", 70 + "basic-toml", 71 + "memchr", 72 + "proc-macro2", 73 + "quote", 74 + "rustc-hash", 75 + "serde", 76 + "serde_derive", 77 + "syn", 78 + ] 79 + 80 + [[package]] 81 + name = "askama_parser" 82 + version = "0.14.0" 83 + source = "registry+https://github.com/rust-lang/crates.io-index" 84 + checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" 85 + dependencies = [ 86 + "memchr", 87 + "serde", 88 + "serde_derive", 89 + "winnow", 90 + ] 91 + 50 92 [[package]] 51 93 name = "async-compression" 52 94 version = "0.4.27" ··· 161 203 "jose-jwa", 162 204 "jose-jwk", 163 205 "p256", 164 - "rand", 206 + "rand 0.8.5", 165 207 "reqwest", 166 208 "serde", 167 209 "serde_html_form", ··· 285 327 source = "registry+https://github.com/rust-lang/crates.io-index" 286 328 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 287 329 330 + [[package]] 331 + name = "basic-toml" 332 + version = "0.1.10" 333 + source = "registry+https://github.com/rust-lang/crates.io-index" 334 + checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" 335 + dependencies = [ 336 + "serde", 337 + ] 338 + 288 339 [[package]] 289 340 name = "bb8" 290 341 version = "0.9.0" ··· 324 375 "generic-array", 325 376 ] 326 377 378 + [[package]] 379 + name = "bstr" 380 + version = "1.12.0" 381 + source = "registry+https://github.com/rust-lang/crates.io-index" 382 + checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" 383 + dependencies = [ 384 + "memchr", 385 + "serde", 386 + ] 387 + 327 388 [[package]] 328 389 name = "bumpalo" 329 390 version = "3.17.0" ··· 524 585 checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 525 586 dependencies = [ 526 587 "generic-array", 527 - "rand_core", 588 + "rand_core 0.6.4", 528 589 "subtle", 529 590 "zeroize", 530 591 ] ··· 539 600 "typenum", 540 601 ] 541 602 603 + [[package]] 604 + name = "darling" 605 + version = "0.20.11" 606 + source = "registry+https://github.com/rust-lang/crates.io-index" 607 + checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" 608 + dependencies = [ 609 + "darling_core", 610 + "darling_macro", 611 + ] 612 + 613 + [[package]] 614 + name = "darling_core" 615 + version = "0.20.11" 616 + source = "registry+https://github.com/rust-lang/crates.io-index" 617 + checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" 618 + dependencies = [ 619 + "fnv", 620 + "ident_case", 621 + "proc-macro2", 622 + "quote", 623 + "strsim", 624 + "syn", 625 + ] 626 + 627 + [[package]] 628 + name = "darling_macro" 629 + version = "0.20.11" 630 + source = "registry+https://github.com/rust-lang/crates.io-index" 631 + checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" 632 + dependencies = [ 633 + "darling_core", 634 + "quote", 635 + "syn", 636 + ] 637 + 542 638 [[package]] 543 639 name = "dashmap" 544 640 version = "6.1.0" ··· 600 696 "serde", 601 697 ] 602 698 699 + [[package]] 700 + name = "derive_builder" 701 + version = "0.20.2" 702 + source = "registry+https://github.com/rust-lang/crates.io-index" 703 + checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" 704 + dependencies = [ 705 + "derive_builder_macro", 706 + ] 707 + 708 + [[package]] 709 + name = "derive_builder_core" 710 + version = "0.20.2" 711 + source = "registry+https://github.com/rust-lang/crates.io-index" 712 + checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" 713 + dependencies = [ 714 + "darling", 715 + "proc-macro2", 716 + "quote", 717 + "syn", 718 + ] 719 + 720 + [[package]] 721 + name = "derive_builder_macro" 722 + version = "0.20.2" 723 + source = "registry+https://github.com/rust-lang/crates.io-index" 724 + checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" 725 + dependencies = [ 726 + "derive_builder_core", 727 + "syn", 728 + ] 729 + 603 730 [[package]] 604 731 name = "digest" 605 732 version = "0.10.7" ··· 669 796 "ff", 670 797 "generic-array", 671 798 "group", 672 - "rand_core", 799 + "rand_core 0.6.4", 673 800 "sec1", 674 801 "subtle", 675 802 "zeroize", ··· 747 874 source = "registry+https://github.com/rust-lang/crates.io-index" 748 875 checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 749 876 dependencies = [ 750 - "rand_core", 877 + "rand_core 0.6.4", 751 878 "subtle", 752 879 ] 753 880 ··· 960 1087 source = "registry+https://github.com/rust-lang/crates.io-index" 961 1088 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 962 1089 1090 + [[package]] 1091 + name = "globset" 1092 + version = "0.4.16" 1093 + source = "registry+https://github.com/rust-lang/crates.io-index" 1094 + checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" 1095 + dependencies = [ 1096 + "aho-corasick", 1097 + "bstr", 1098 + "log", 1099 + "regex-automata 0.4.9", 1100 + "regex-syntax 0.8.5", 1101 + ] 1102 + 963 1103 [[package]] 964 1104 name = "group" 965 1105 version = "0.13.0" ··· 967 1107 checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 968 1108 dependencies = [ 969 1109 "ff", 970 - "rand_core", 1110 + "rand_core 0.6.4", 971 1111 "subtle", 972 1112 ] 973 1113 1114 + [[package]] 1115 + name = "handlebars" 1116 + version = "6.3.2" 1117 + source = "registry+https://github.com/rust-lang/crates.io-index" 1118 + checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" 1119 + dependencies = [ 1120 + "derive_builder", 1121 + "log", 1122 + "num-order", 1123 + "pest", 1124 + "pest_derive", 1125 + "serde", 1126 + "serde_json", 1127 + "thiserror 2.0.12", 1128 + ] 1129 + 974 1130 [[package]] 975 1131 name = "hashbrown" 976 1132 version = "0.14.5" ··· 1025 1181 "idna", 1026 1182 "ipnet", 1027 1183 "once_cell", 1028 - "rand", 1184 + "rand 0.8.5", 1029 1185 "thiserror 1.0.69", 1030 1186 "tinyvec", 1031 1187 "tokio", ··· 1046 1202 "lru-cache", 1047 1203 "once_cell", 1048 1204 "parking_lot", 1049 - "rand", 1205 + "rand 0.8.5", 1050 1206 "resolv-conf", 1051 1207 "smallvec", 1052 1208 "thiserror 1.0.69", ··· 1081 1237 "windows-sys 0.59.0", 1082 1238 ] 1083 1239 1240 + [[package]] 1241 + name = "html-escape" 1242 + version = "0.2.13" 1243 + source = "registry+https://github.com/rust-lang/crates.io-index" 1244 + checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" 1245 + dependencies = [ 1246 + "utf8-width", 1247 + ] 1248 + 1084 1249 [[package]] 1085 1250 name = "http" 1086 1251 version = "1.3.1" ··· 1187 1352 "tracing", 1188 1353 ] 1189 1354 1355 + [[package]] 1356 + name = "hypertext" 1357 + version = "0.12.1" 1358 + source = "registry+https://github.com/rust-lang/crates.io-index" 1359 + checksum = "eb73b82c6a76434fd87a0668ef3ff1a8182512dfb610eef9138169a7e2d3a0ed" 1360 + dependencies = [ 1361 + "html-escape", 1362 + "hypertext-macros", 1363 + "itoa", 1364 + "ryu", 1365 + ] 1366 + 1367 + [[package]] 1368 + name = "hypertext-macros" 1369 + version = "0.12.1" 1370 + source = "registry+https://github.com/rust-lang/crates.io-index" 1371 + checksum = "c120534b9d41bd317a5b111aacc38a34071d15df9462c0e21f6093ade3a03660" 1372 + dependencies = [ 1373 + "html-escape", 1374 + "proc-macro2", 1375 + "quote", 1376 + "syn", 1377 + ] 1378 + 1190 1379 [[package]] 1191 1380 name = "iana-time-zone" 1192 1381 version = "0.1.63" ··· 1297 1486 "zerovec", 1298 1487 ] 1299 1488 1489 + [[package]] 1490 + name = "ident_case" 1491 + version = "1.0.1" 1492 + source = "registry+https://github.com/rust-lang/crates.io-index" 1493 + checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" 1494 + 1300 1495 [[package]] 1301 1496 name = "idna" 1302 1497 version = "1.0.3" ··· 1538 1733 "linked-hash-map", 1539 1734 ] 1540 1735 1736 + [[package]] 1737 + name = "markdown" 1738 + version = "1.0.0" 1739 + source = "registry+https://github.com/rust-lang/crates.io-index" 1740 + checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" 1741 + dependencies = [ 1742 + "unicode-id", 1743 + ] 1744 + 1541 1745 [[package]] 1542 1746 name = "matchers" 1543 1747 version = "0.1.0" ··· 1688 1892 "num-integer", 1689 1893 "num-iter", 1690 1894 "num-traits", 1691 - "rand", 1895 + "rand 0.8.5", 1692 1896 "smallvec", 1693 1897 "zeroize", 1694 1898 ] ··· 1719 1923 "num-traits", 1720 1924 ] 1721 1925 1926 + [[package]] 1927 + name = "num-modular" 1928 + version = "0.6.1" 1929 + source = "registry+https://github.com/rust-lang/crates.io-index" 1930 + checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" 1931 + 1932 + [[package]] 1933 + name = "num-order" 1934 + version = "1.2.0" 1935 + source = "registry+https://github.com/rust-lang/crates.io-index" 1936 + checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" 1937 + dependencies = [ 1938 + "num-modular", 1939 + ] 1940 + 1722 1941 [[package]] 1723 1942 name = "num-traits" 1724 1943 version = "0.2.19" ··· 1850 2069 source = "registry+https://github.com/rust-lang/crates.io-index" 1851 2070 checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1852 2071 2072 + [[package]] 2073 + name = "pest" 2074 + version = "2.8.1" 2075 + source = "registry+https://github.com/rust-lang/crates.io-index" 2076 + checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" 2077 + dependencies = [ 2078 + "memchr", 2079 + "thiserror 2.0.12", 2080 + "ucd-trie", 2081 + ] 2082 + 2083 + [[package]] 2084 + name = "pest_derive" 2085 + version = "2.8.1" 2086 + source = "registry+https://github.com/rust-lang/crates.io-index" 2087 + checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" 2088 + dependencies = [ 2089 + "pest", 2090 + "pest_generator", 2091 + ] 2092 + 2093 + [[package]] 2094 + name = "pest_generator" 2095 + version = "2.8.1" 2096 + source = "registry+https://github.com/rust-lang/crates.io-index" 2097 + checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" 2098 + dependencies = [ 2099 + "pest", 2100 + "pest_meta", 2101 + "proc-macro2", 2102 + "quote", 2103 + "syn", 2104 + ] 2105 + 2106 + [[package]] 2107 + name = "pest_meta" 2108 + version = "2.8.1" 2109 + source = "registry+https://github.com/rust-lang/crates.io-index" 2110 + checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" 2111 + dependencies = [ 2112 + "pest", 2113 + "sha2", 2114 + ] 2115 + 1853 2116 [[package]] 1854 2117 name = "pin-project-lite" 1855 2118 version = "0.2.16" ··· 1889 2152 source = "registry+https://github.com/rust-lang/crates.io-index" 1890 2153 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1891 2154 2155 + [[package]] 2156 + name = "pool" 2157 + version = "0.1.4" 2158 + source = "registry+https://github.com/rust-lang/crates.io-index" 2159 + checksum = "c7ac1531a0016945992b4e816e81538dfad0b9f00d280bcb707d711839f1536d" 2160 + 1892 2161 [[package]] 1893 2162 name = "portable-atomic" 1894 2163 version = "1.11.1" ··· 1959 2228 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1960 2229 dependencies = [ 1961 2230 "libc", 1962 - "rand_chacha", 1963 - "rand_core", 2231 + "rand_chacha 0.3.1", 2232 + "rand_core 0.6.4", 2233 + ] 2234 + 2235 + [[package]] 2236 + name = "rand" 2237 + version = "0.9.2" 2238 + source = "registry+https://github.com/rust-lang/crates.io-index" 2239 + checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" 2240 + dependencies = [ 2241 + "rand_chacha 0.9.0", 2242 + "rand_core 0.9.3", 1964 2243 ] 1965 2244 1966 2245 [[package]] ··· 1970 2249 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1971 2250 dependencies = [ 1972 2251 "ppv-lite86", 1973 - "rand_core", 2252 + "rand_core 0.6.4", 2253 + ] 2254 + 2255 + [[package]] 2256 + name = "rand_chacha" 2257 + version = "0.9.0" 2258 + source = "registry+https://github.com/rust-lang/crates.io-index" 2259 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" 2260 + dependencies = [ 2261 + "ppv-lite86", 2262 + "rand_core 0.9.3", 1974 2263 ] 1975 2264 1976 2265 [[package]] ··· 1982 2271 "getrandom 0.2.16", 1983 2272 ] 1984 2273 2274 + [[package]] 2275 + name = "rand_core" 2276 + version = "0.9.3" 2277 + source = "registry+https://github.com/rust-lang/crates.io-index" 2278 + checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" 2279 + dependencies = [ 2280 + "getrandom 0.3.3", 2281 + ] 2282 + 1985 2283 [[package]] 1986 2284 name = "redis" 1987 2285 version = "0.32.4" ··· 2125 2423 "num-traits", 2126 2424 "pkcs1", 2127 2425 "pkcs8", 2128 - "rand_core", 2426 + "rand_core 0.6.4", 2129 2427 "signature", 2130 2428 "spki", 2131 2429 "subtle", 2132 2430 "zeroize", 2133 2431 ] 2134 2432 2433 + [[package]] 2434 + name = "rust-embed" 2435 + version = "8.7.2" 2436 + source = "registry+https://github.com/rust-lang/crates.io-index" 2437 + checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" 2438 + dependencies = [ 2439 + "rust-embed-impl", 2440 + "rust-embed-utils", 2441 + "walkdir", 2442 + ] 2443 + 2444 + [[package]] 2445 + name = "rust-embed-impl" 2446 + version = "8.7.2" 2447 + source = "registry+https://github.com/rust-lang/crates.io-index" 2448 + checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" 2449 + dependencies = [ 2450 + "proc-macro2", 2451 + "quote", 2452 + "rust-embed-utils", 2453 + "syn", 2454 + "walkdir", 2455 + ] 2456 + 2457 + [[package]] 2458 + name = "rust-embed-utils" 2459 + version = "8.7.2" 2460 + source = "registry+https://github.com/rust-lang/crates.io-index" 2461 + checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" 2462 + dependencies = [ 2463 + "globset", 2464 + "sha2", 2465 + "walkdir", 2466 + ] 2467 + 2135 2468 [[package]] 2136 2469 name = "rustc-demangle" 2137 2470 version = "0.1.25" 2138 2471 source = "registry+https://github.com/rust-lang/crates.io-index" 2139 2472 checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" 2140 2473 2474 + [[package]] 2475 + name = "rustc-hash" 2476 + version = "2.1.1" 2477 + source = "registry+https://github.com/rust-lang/crates.io-index" 2478 + checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 2479 + 2141 2480 [[package]] 2142 2481 name = "rustc_version" 2143 2482 version = "0.4.1" ··· 2181 2520 source = "registry+https://github.com/rust-lang/crates.io-index" 2182 2521 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2183 2522 2523 + [[package]] 2524 + name = "same-file" 2525 + version = "1.0.6" 2526 + source = "registry+https://github.com/rust-lang/crates.io-index" 2527 + checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 2528 + dependencies = [ 2529 + "winapi-util", 2530 + ] 2531 + 2184 2532 [[package]] 2185 2533 name = "schannel" 2186 2534 version = "0.1.27" ··· 2361 2709 name = "shared" 2362 2710 version = "0.1.0" 2363 2711 dependencies = [ 2712 + "async-trait", 2364 2713 "atrium-api", 2365 2714 "atrium-common", 2366 2715 "atrium-identity", ··· 2368 2717 "axum", 2369 2718 "bb8", 2370 2719 "bb8-redis", 2720 + "handlebars", 2371 2721 "hickory-resolver", 2372 2722 "log", 2723 + "markdown", 2724 + "rand 0.9.2", 2725 + "rust-embed", 2373 2726 "serde", 2374 2727 "serde_json", 2375 2728 "sqlx", ··· 2398 2751 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2399 2752 dependencies = [ 2400 2753 "digest", 2401 - "rand_core", 2754 + "rand_core 0.6.4", 2402 2755 ] 2403 2756 2404 2757 [[package]] ··· 2571 2924 "memchr", 2572 2925 "once_cell", 2573 2926 "percent-encoding", 2574 - "rand", 2927 + "rand 0.8.5", 2575 2928 "rsa", 2576 2929 "serde", 2577 2930 "sha1", ··· 2610 2963 "md-5", 2611 2964 "memchr", 2612 2965 "once_cell", 2613 - "rand", 2966 + "rand 0.8.5", 2614 2967 "serde", 2615 2968 "serde_json", 2616 2969 "sha2", ··· 2664 3017 "unicode-properties", 2665 3018 ] 2666 3019 3020 + [[package]] 3021 + name = "strsim" 3022 + version = "0.11.1" 3023 + source = "registry+https://github.com/rust-lang/crates.io-index" 3024 + checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 3025 + 2667 3026 [[package]] 2668 3027 name = "subtle" 2669 3028 version = "2.6.1" ··· 2983 3342 "futures", 2984 3343 "http", 2985 3344 "parking_lot", 2986 - "rand", 3345 + "rand 0.8.5", 2987 3346 "serde", 2988 3347 "serde_json", 2989 3348 "thiserror 2.0.12", ··· 3089 3448 source = "registry+https://github.com/rust-lang/crates.io-index" 3090 3449 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 3091 3450 3451 + [[package]] 3452 + name = "ucd-trie" 3453 + version = "0.1.7" 3454 + source = "registry+https://github.com/rust-lang/crates.io-index" 3455 + checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" 3456 + 3092 3457 [[package]] 3093 3458 name = "unicode-bidi" 3094 3459 version = "0.3.18" 3095 3460 source = "registry+https://github.com/rust-lang/crates.io-index" 3096 3461 checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" 3097 3462 3463 + [[package]] 3464 + name = "unicode-id" 3465 + version = "0.3.5" 3466 + source = "registry+https://github.com/rust-lang/crates.io-index" 3467 + checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561" 3468 + 3098 3469 [[package]] 3099 3470 name = "unicode-ident" 3100 3471 version = "1.0.18" ··· 3133 3504 "percent-encoding", 3134 3505 ] 3135 3506 3507 + [[package]] 3508 + name = "utf8-width" 3509 + version = "0.1.7" 3510 + source = "registry+https://github.com/rust-lang/crates.io-index" 3511 + checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" 3512 + 3136 3513 [[package]] 3137 3514 name = "utf8_iter" 3138 3515 version = "1.0.4" ··· 3168 3545 source = "registry+https://github.com/rust-lang/crates.io-index" 3169 3546 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3170 3547 3548 + [[package]] 3549 + name = "walkdir" 3550 + version = "2.5.0" 3551 + source = "registry+https://github.com/rust-lang/crates.io-index" 3552 + checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 3553 + dependencies = [ 3554 + "same-file", 3555 + "winapi-util", 3556 + ] 3557 + 3171 3558 [[package]] 3172 3559 name = "want" 3173 3560 version = "0.3.1" ··· 3273 3660 name = "web" 3274 3661 version = "0.1.0" 3275 3662 dependencies = [ 3663 + "askama", 3664 + "async-trait", 3276 3665 "atrium-api", 3277 3666 "atrium-common", 3278 3667 "atrium-identity", ··· 3280 3669 "axum", 3281 3670 "bb8", 3282 3671 "bb8-redis", 3672 + "chrono", 3283 3673 "dotenv", 3674 + "hypertext", 3284 3675 "log", 3676 + "pool", 3285 3677 "redis", 3286 3678 "serde", 3287 3679 "serde_json", ··· 3346 3738 source = "registry+https://github.com/rust-lang/crates.io-index" 3347 3739 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 3348 3740 3741 + [[package]] 3742 + name = "winapi-util" 3743 + version = "0.1.10" 3744 + source = "registry+https://github.com/rust-lang/crates.io-index" 3745 + checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" 3746 + dependencies = [ 3747 + "windows-sys 0.59.0", 3748 + ] 3749 + 3349 3750 [[package]] 3350 3751 name = "winapi-x86_64-pc-windows-gnu" 3351 3752 version = "0.4.0" ··· 3611 4012 source = "registry+https://github.com/rust-lang/crates.io-index" 3612 4013 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 3613 4014 4015 + [[package]] 4016 + name = "winnow" 4017 + version = "0.7.13" 4018 + source = "registry+https://github.com/rust-lang/crates.io-index" 4019 + checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" 4020 + dependencies = [ 4021 + "memchr", 4022 + ] 4023 + 3614 4024 [[package]] 3615 4025 name = "winreg" 3616 4026 version = "0.50.0"
+5 -1
Cargo.toml
··· 9 9 atrium-api = "0.25.4" 10 10 atrium-identity = "0.1.5" 11 11 atrium-oauth = "0.1.3" 12 + chrono = { version = "0.4", features = ["serde", "now"] } 12 13 hickory-resolver = "0.24.1" 13 14 dotenv = "0.15.0" 14 15 log = "0.4.24" ··· 20 21 tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 21 22 bb8 = "0.9.0" 22 23 bb8-redis = "0.24.0" 23 - redis = "0.32.4" 24 + redis = "0.32.4" 25 + tokio = { version = "1.46.1", features = ["full"] } 26 + markdown = "1.0.0" 27 + rust-embed = { version = "8.7.2", features = ["include-exclude"] }
+2 -1
compose.dev.yml
··· 1 1 services: 2 2 postgres: 3 3 image: postgres:latest 4 + restart: unless-stopped 4 5 environment: 5 6 POSTGRES_USER: ${DB_USER} 6 7 POSTGRES_PASSWORD: ${DB_PASSWORD} ··· 15 16 - advent-network 16 17 redis: 17 18 image: 'redis:alpine' 18 - restart: always 19 + restart: unless-stopped 19 20 ports: 20 21 - '${FORWARD_REDIS_PORT:-6379}:6379' 21 22 volumes:
+17
migrations/20250904073900_create_challenges.sql
··· 1 + -- Advent challenges table 2 + CREATE TABLE IF NOT EXISTS challenges ( 3 + id BIGSERIAL PRIMARY KEY, 4 + user_did TEXT NOT NULL, 5 + day INT NOT NULL, 6 + time_started TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 + time_challenge_one_completed TIMESTAMPTZ NULL, 8 + time_challenge_two_completed TIMESTAMPTZ NULL, 9 + verification_code_one TEXT NULL, 10 + verification_code_two TEXT NULL, 11 + CONSTRAINT challenges_user_day_unique UNIQUE(user_did, day), 12 + CONSTRAINT challenges_day_range CHECK (day >= 1 AND day <= 25) 13 + ); 14 + 15 + -- Indexes to speed up common lookups 16 + CREATE INDEX IF NOT EXISTS idx_challenges_user_did ON challenges(user_did); 17 + CREATE INDEX IF NOT EXISTS idx_challenges_day ON challenges(day);
+5
shared/Cargo.toml
··· 17 17 thiserror = "1.0.69" 18 18 serde_json.workspace = true 19 19 log.workspace = true 20 + rust-embed.workspace = true 21 + markdown.workspace = true 22 + rand = "0.9.2" 23 + handlebars = { version = "6.3.2" } 24 + async-trait = "0.1.88"
+15
shared/challenges_markdown/one/part_one.md
··· 1 + Hey! Welcome to at://advent! A 25 day challenge to learn atproto with a new set of challenges every day. 2 + 3 + (Pretend this is going into more details explaining everything) 4 + 5 + Starting out simple, create a record at the collection `codes.advent.challenge.day` 6 + with the record key `1` and put this as the record. 7 + 8 + ```json 9 + { 10 + "$type": "codes.advent.challenge.day", 11 + "partOne": "{{code}}" 12 + } 13 + ``` 14 + 15 + [//]: # (<input type="file" id="part_one_input" placeholder="Enter your code here" />)
+7
shared/challenges_markdown/one/part_two.md
··· 1 + Great job beating Part 1! Now onto Part 2. 2 + 3 + Keeping it simple proof of concept, blah, blah will have a real one here another time. Add a new field `partTwo` to the 4 + record with the value `{{code}}` 5 + 6 + 7 + [//]: # (<input type="file" id="part_one_input" placeholder="Enter your code here" />)
+28
shared/lexicons/codes/advent/day.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "codes.advent.challenge.day", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "any", 8 + "record": { 9 + "type": "object", 10 + "required": [ 11 + "partOne" 12 + ], 13 + "properties": { 14 + "partOne": { 15 + "type": "string" 16 + }, 17 + "partTwo": { 18 + "type": "string" 19 + }, 20 + "createdAt": { 21 + "type": "string", 22 + "format": "datetime" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + }
+201
shared/src/advent/challenges/day_one.rs
··· 1 + use crate::OAuthAgentType; 2 + use crate::advent::day::Day; 3 + use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse}; 4 + use crate::atrium::safe_check_unknown_record_parse; 5 + use async_trait::async_trait; 6 + use atrium_api::types::Collection; 7 + use sqlx::PgPool; 8 + 9 + pub struct DayOne { 10 + pub pool: PgPool, 11 + pub oauth_client: Option<OAuthAgentType>, 12 + } 13 + 14 + #[async_trait] 15 + impl AdventChallenge for DayOne { 16 + fn pool(&self) -> &PgPool { 17 + &self.pool 18 + } 19 + 20 + fn day(&self) -> Day { 21 + Day::One 22 + } 23 + 24 + fn has_part_two(&self) -> bool { 25 + true 26 + } 27 + 28 + async fn check_part_one( 29 + &self, 30 + did: String, 31 + _verification_code: Option<String>, 32 + ) -> Result<ChallengeCheckResponse, AdventError> { 33 + match &self.oauth_client { 34 + None => Err(AdventError::ShouldNotHappen( 35 + "No oauth client. This should not happen".to_string(), 36 + )), 37 + Some(client) => { 38 + match client 39 + .api 40 + .com 41 + .atproto 42 + .repo 43 + .get_record( 44 + atrium_api::com::atproto::repo::get_record::ParametersData { 45 + cid: None, 46 + collection: crate::lexicons::codes::advent::challenge::Day::NSID 47 + .parse() 48 + .unwrap(), 49 + repo: did.parse().unwrap(), 50 + rkey: "1".parse().unwrap(), 51 + } 52 + .into(), 53 + ) 54 + .await 55 + { 56 + Ok(record) => { 57 + //TODO trouble, and make it double 58 + let challenge = self.get_days_challenge(did.clone()).await?; 59 + 60 + match challenge { 61 + None => { 62 + log::error!( 63 + "Could not find a challenge record for day: {} for the user: {}", 64 + self.day(), 65 + did.clone() 66 + ); 67 + Err(AdventError::ShouldNotHappen( 68 + "Could not find a challenge record".to_string(), 69 + )) 70 + } 71 + Some(challenge) => { 72 + let parse_record_result = 73 + safe_check_unknown_record_parse::< 74 + crate::lexicons::codes::advent::challenge::day::RecordData, 75 + >(record.value.clone()); 76 + 77 + match parse_record_result { 78 + Ok(record_data) => { 79 + match record_data.part_one 80 + == challenge 81 + .verification_code_one 82 + .unwrap_or("".to_string()) 83 + { 84 + true => Ok(ChallengeCheckResponse::Correct), 85 + false => { 86 + Ok(ChallengeCheckResponse::Incorrect(format!( 87 + "The code {} is incorrect", 88 + record_data.part_one 89 + ))) 90 + } 91 + } 92 + } 93 + Err(err) => { 94 + log::error!("Error parsing record: {}", err); 95 + Ok(ChallengeCheckResponse::Incorrect(format!( 96 + "There is a record at the correct location, but it does not seem like it is correct. Try again:\n{err}" 97 + ))) 98 + } 99 + } 100 + } 101 + } 102 + } 103 + Err(err) => { 104 + log::error!("Error getting record: {}", err); 105 + Ok(ChallengeCheckResponse::Incorrect("Does not appear to be a record in your repo in the collection codes.advent.challenge.day with the record key of 1".to_string())) 106 + } 107 + } 108 + } 109 + } 110 + } 111 + 112 + ///TODO this is just a straight copy and paste of part one since it's a proof of concept needs to share code better between the two 113 + async fn check_part_two( 114 + &self, 115 + did: String, 116 + _verification_code: Option<String>, 117 + ) -> Result<ChallengeCheckResponse, AdventError> { 118 + match &self.oauth_client { 119 + None => Err(AdventError::ShouldNotHappen( 120 + "No oauth client. This should not happen".to_string(), 121 + )), 122 + Some(client) => { 123 + match client 124 + .api 125 + .com 126 + .atproto 127 + .repo 128 + .get_record( 129 + atrium_api::com::atproto::repo::get_record::ParametersData { 130 + cid: None, 131 + collection: crate::lexicons::codes::advent::challenge::Day::NSID 132 + .parse() 133 + .unwrap(), 134 + repo: did.parse().unwrap(), 135 + rkey: "1".parse().unwrap(), 136 + } 137 + .into(), 138 + ) 139 + .await 140 + { 141 + Ok(record) => { 142 + //TODO trouble, and make it double 143 + let challenge = self.get_days_challenge(did.clone()).await?; 144 + 145 + match challenge { 146 + None => { 147 + log::error!( 148 + "Could not find a challenge record for day: {} for the user: {}", 149 + self.day(), 150 + did.clone() 151 + ); 152 + Err(AdventError::ShouldNotHappen( 153 + "Could not find a challenge record".to_string(), 154 + )) 155 + } 156 + Some(challenge) => { 157 + let parse_record_result = 158 + safe_check_unknown_record_parse::< 159 + crate::lexicons::codes::advent::challenge::day::RecordData, 160 + >(record.value.clone()); 161 + 162 + match parse_record_result { 163 + Ok(record_data) => match record_data.part_two { 164 + None => { 165 + Ok(ChallengeCheckResponse::Incorrect("The record is there, it's the right kind. But aren't you forgetting something?".to_string())) 166 + } 167 + Some(part_two_code) => { 168 + match part_two_code 169 + == challenge 170 + .verification_code_two 171 + .unwrap_or("".to_string()) 172 + { 173 + true => Ok(ChallengeCheckResponse::Correct), 174 + false => { 175 + Ok(ChallengeCheckResponse::Incorrect(format!( 176 + "The code {} is incorrect", 177 + record_data.part_one 178 + ))) 179 + } 180 + } 181 + } 182 + }, 183 + Err(err) => { 184 + log::error!("Error parsing record: {}", err); 185 + Ok(ChallengeCheckResponse::Incorrect(format!( 186 + "There is a record at the correct location, but it does not seem like it is correct. Try again:\n{err}" 187 + ))) 188 + } 189 + } 190 + } 191 + } 192 + } 193 + Err(err) => { 194 + log::error!("Error getting record: {}", err); 195 + Ok(ChallengeCheckResponse::Incorrect("Does not appear to be a record in your repo in the collection codes.advent.challenge.day with the record key of 1".to_string())) 196 + } 197 + } 198 + } 199 + } 200 + } 201 + }
+33
shared/src/advent/challenges/day_two.rs
··· 1 + use crate::OAuthAgentType; 2 + use crate::advent::day::Day; 3 + use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse}; 4 + use async_trait::async_trait; 5 + use sqlx::PgPool; 6 + 7 + pub struct DayTwo { 8 + pub pool: PgPool, 9 + pub oauth_client: Option<OAuthAgentType>, 10 + } 11 + 12 + #[async_trait] 13 + impl AdventChallenge for DayTwo { 14 + fn pool(&self) -> &PgPool { 15 + &self.pool 16 + } 17 + 18 + fn day(&self) -> Day { 19 + Day::Two 20 + } 21 + 22 + fn has_part_two(&self) -> bool { 23 + false 24 + } 25 + 26 + async fn check_part_one( 27 + &self, 28 + _did: String, 29 + _verification_code: Option<String>, 30 + ) -> Result<ChallengeCheckResponse, AdventError> { 31 + todo!() 32 + } 33 + }
+2
shared/src/advent/challenges/mod.rs
··· 1 + pub mod day_one; 2 + pub mod day_two;
+172
shared/src/advent/day.rs
··· 1 + /// Decided to just go with an enum for the day. Seems a bit silly, but seemed the easiest way to do matches and translate from "1" to "One" etc without a dependency on a crate. 2 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 3 + pub enum Day { 4 + One = 1, 5 + Two = 2, 6 + Three = 3, 7 + Four = 4, 8 + Five = 5, 9 + Six = 6, 10 + Seven = 7, 11 + Eight = 8, 12 + Nine = 9, 13 + Ten = 10, 14 + Eleven = 11, 15 + Twelve = 12, 16 + Thirteen = 13, 17 + Fourteen = 14, 18 + Fifteen = 15, 19 + Sixteen = 16, 20 + Seventeen = 17, 21 + Eighteen = 18, 22 + Nineteen = 19, 23 + Twenty = 20, 24 + TwentyOne = 21, 25 + TwentyTwo = 22, 26 + TwentyThree = 23, 27 + TwentyFour = 24, 28 + TwentyFive = 25, 29 + } 30 + 31 + impl From<Day> for u8 { 32 + fn from(day: Day) -> Self { 33 + match day { 34 + Day::One => 1, 35 + Day::Two => 2, 36 + Day::Three => 3, 37 + Day::Four => 4, 38 + Day::Five => 5, 39 + Day::Six => 6, 40 + Day::Seven => 7, 41 + Day::Eight => 8, 42 + Day::Nine => 9, 43 + Day::Ten => 10, 44 + Day::Eleven => 11, 45 + Day::Twelve => 12, 46 + Day::Thirteen => 13, 47 + Day::Fourteen => 14, 48 + Day::Fifteen => 15, 49 + Day::Sixteen => 16, 50 + Day::Seventeen => 17, 51 + Day::Eighteen => 18, 52 + Day::Nineteen => 19, 53 + Day::Twenty => 20, 54 + Day::TwentyOne => 21, 55 + Day::TwentyTwo => 22, 56 + Day::TwentyThree => 23, 57 + Day::TwentyFour => 24, 58 + Day::TwentyFive => 25, 59 + } 60 + } 61 + } 62 + 63 + impl core::fmt::Display for Day { 64 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 65 + f.write_str(match self { 66 + Day::One => "One", 67 + Day::Two => "Two", 68 + Day::Three => "Three", 69 + Day::Four => "Four", 70 + Day::Five => "Five", 71 + Day::Six => "Six", 72 + Day::Seven => "Seven", 73 + Day::Eight => "Eight", 74 + Day::Nine => "Nine", 75 + Day::Ten => "Ten", 76 + Day::Eleven => "Eleven", 77 + Day::Twelve => "Twelve", 78 + Day::Thirteen => "Thirteen", 79 + Day::Fourteen => "Fourteen", 80 + Day::Fifteen => "Fifteen", 81 + Day::Sixteen => "Sixteen", 82 + Day::Seventeen => "Seventeen", 83 + Day::Eighteen => "Eighteen", 84 + Day::Nineteen => "Nineteen", 85 + Day::Twenty => "Twenty", 86 + Day::TwentyOne => "TwentyOne", 87 + Day::TwentyTwo => "TwentyTwo", 88 + Day::TwentyThree => "TwentyThree", 89 + Day::TwentyFour => "TwentyFour", 90 + Day::TwentyFive => "TwentyFive", 91 + }) 92 + } 93 + } 94 + 95 + impl From<Day> for String { 96 + fn from(day: Day) -> Self { 97 + day.to_string() 98 + } 99 + } 100 + 101 + impl core::convert::From<u8> for Day { 102 + fn from(value: u8) -> Self { 103 + match value { 104 + 1 => Day::One, 105 + 2 => Day::Two, 106 + 3 => Day::Three, 107 + 4 => Day::Four, 108 + 5 => Day::Five, 109 + 6 => Day::Six, 110 + 7 => Day::Seven, 111 + 8 => Day::Eight, 112 + 9 => Day::Nine, 113 + 10 => Day::Ten, 114 + 11 => Day::Eleven, 115 + 12 => Day::Twelve, 116 + 13 => Day::Thirteen, 117 + 14 => Day::Fourteen, 118 + 15 => Day::Fifteen, 119 + 16 => Day::Sixteen, 120 + 17 => Day::Seventeen, 121 + 18 => Day::Eighteen, 122 + 19 => Day::Nineteen, 123 + 20 => Day::Twenty, 124 + 21 => Day::TwentyOne, 125 + 22 => Day::TwentyTwo, 126 + 23 => Day::TwentyThree, 127 + 24 => Day::TwentyFour, 128 + 25 => Day::TwentyFive, 129 + _ => panic!("day out of range (1..=25)"), 130 + } 131 + } 132 + } 133 + 134 + impl core::convert::From<&str> for Day { 135 + fn from(s: &str) -> Self { 136 + match s { 137 + "One" => Day::One, 138 + "Two" => Day::Two, 139 + "Three" => Day::Three, 140 + "Four" => Day::Four, 141 + "Five" => Day::Five, 142 + "Six" => Day::Six, 143 + "Seven" => Day::Seven, 144 + "Eight" => Day::Eight, 145 + "Nine" => Day::Nine, 146 + "Ten" => Day::Ten, 147 + "Eleven" => Day::Eleven, 148 + "Twelve" => Day::Twelve, 149 + "Thirteen" => Day::Thirteen, 150 + "Fourteen" => Day::Fourteen, 151 + "Fifteen" => Day::Fifteen, 152 + "Sixteen" => Day::Sixteen, 153 + "Seventeen" => Day::Seventeen, 154 + "Eighteen" => Day::Eighteen, 155 + "Nineteen" => Day::Nineteen, 156 + "Twenty" => Day::Twenty, 157 + "TwentyOne" => Day::TwentyOne, 158 + "TwentyTwo" => Day::TwentyTwo, 159 + "TwentyThree" => Day::TwentyThree, 160 + "TwentyFour" => Day::TwentyFour, 161 + "TwentyFive" => Day::TwentyFive, 162 + _ => panic!("unknown day string"), 163 + } 164 + } 165 + } 166 + 167 + impl core::convert::From<String> for Day { 168 + fn from(value: String) -> Self { 169 + Day::from(value.as_str()) 170 + } 171 + } 172 +
+327
shared/src/advent/mod.rs
··· 1 + pub mod challenges; 2 + pub mod day; 3 + 4 + use crate::advent::day::Day; 5 + use crate::assets::ChallengesMarkdown; 6 + use crate::models::db_models::ChallengeProgress; 7 + use async_trait::async_trait; 8 + use handlebars::{Handlebars, RenderError}; 9 + use markdown::{CompileOptions, Options}; 10 + use rand::distr::{Alphanumeric, SampleString}; 11 + use rust_embed::EmbeddedFile; 12 + use serde_json::json; 13 + use sqlx::PgPool; 14 + use std::str::Utf8Error; 15 + use thiserror::Error; 16 + 17 + #[derive(Debug, Error)] 18 + pub enum AdventError { 19 + #[error("Database error: {0}")] 20 + Database(#[from] sqlx::Error), 21 + #[error("Io error: {0}")] 22 + Io(#[from] std::io::Error), 23 + #[error("Invalid day: {0}. Day must be between 1 and 25")] 24 + InvalidDay(i32), 25 + #[error("This challenge only has a single challenge")] 26 + NoPartTwo, 27 + #[error("UTF-8 error: {0}")] 28 + Utf8Error(#[from] Utf8Error), 29 + #[error("Render error: {0}")] 30 + RenderError(#[from] RenderError), 31 + #[error("This was not designed to happen: {0}")] 32 + ShouldNotHappen(String), 33 + } 34 + 35 + pub enum AdventAction { 36 + //If Part one is done it shows both, if not it only shows part one 37 + // the Option<String> here is the did of the user. If none only show part one no matter what 38 + ViewChallenge(Option<String>), 39 + 40 + //The strings here are the users did's 41 + StartPartOne(String), 42 + StartPartTwo(String), 43 + 44 + //TODO should I have like SubmitPartOne(String) and it's the code? 45 + //These actions will be locked behind logged in 46 + SubmitPartOne, 47 + SubmitPartTwo, 48 + } 49 + 50 + pub enum AdventPart { 51 + One, 52 + Two, 53 + } 54 + 55 + pub enum CompletionStatus { 56 + ///None of the day's challenges have been completed 57 + None, 58 + ///PartOne of the day's challenges has been completed 59 + PartOne, 60 + ///PartTwo of the day's challenges has been completed 61 + //i dont think this was needed 62 + // PartTwo, 63 + ///Both of the day's challenges have been completed 64 + Both, 65 + } 66 + 67 + pub enum ChallengeCheckResponse { 68 + Correct, 69 + ///Error message on why it was incorrect 70 + Incorrect(String), 71 + } 72 + 73 + pub enum AdventActionResult { 74 + ShowPartOne, 75 + // If partone is completed, this will be shown 76 + ShowPartTwo, 77 + 78 + CorrectSubmission(AdventPart), 79 + IncorrectSubmission, 80 + 81 + Completed, 82 + 83 + Error(String), 84 + } 85 + 86 + #[async_trait] 87 + pub trait AdventChallenge { 88 + /// The db pool in case the challenge needs extra access 89 + fn pool(&self) -> &PgPool; 90 + 91 + /// The day of the challenge 1-25 92 + fn day(&self) -> Day; 93 + 94 + fn get_day_markdown_file(&self, part: AdventPart) -> Result<Option<EmbeddedFile>, AdventError> { 95 + let day = self.day().to_string().to_ascii_lowercase(); 96 + let path = match part { 97 + AdventPart::One => format!("{day}/part_one.md"), 98 + AdventPart::Two => format!("{day}/part_two.md"), 99 + }; 100 + match ChallengesMarkdown::get(path.as_str()) { 101 + None => { 102 + log::error!("Missing the part one challenge file for day: {}", day); 103 + Ok(None) 104 + } 105 + Some(day_one_file) => Ok(Some(day_one_file)), 106 + } 107 + } 108 + 109 + /// Does the day have a part two challenge? 110 + fn has_part_two(&self) -> bool; 111 + //Commenting this out and just going leave it to who makes the impl. This is less code to write, 112 + // but really it's a faster response to just have the author put true or false 113 + // 114 + // { 115 + // match self.get_day_markdown_file(AdventPart::Two) { 116 + // Ok(_) => true, 117 + // Err(_) => false, 118 + // } 119 + // } 120 + 121 + /// The text Markdown for challenge 1 122 + fn markdown_text_part_one( 123 + &self, 124 + verification_code: Option<String>, 125 + ) -> Result<String, AdventError> { 126 + let day = self.day(); 127 + 128 + //May acutally leave this unwrap or put a panic in case it doesn't exist since it is needed to have a part one 129 + match self.get_day_markdown_file(AdventPart::One)? { 130 + None => { 131 + log::error!("Missing the part one challenge file for day: {}", day); 132 + Ok("Someone let the admins know this page is missing. No, this is not an Easter egg, it's an actual bug".to_string()) 133 + } 134 + Some(day_one_file) => { 135 + //TODO probably should be a shared variable, but prototyping 136 + let reg = Handlebars::new(); 137 + 138 + let day_one_text = std::str::from_utf8(day_one_file.data.as_ref())?; 139 + let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string()); 140 + let handlebar_rendered = 141 + reg.render_template(day_one_text, &json!({"code": code}))?; 142 + 143 + Ok( 144 + markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options()) 145 + .unwrap(), 146 + ) 147 + } 148 + } 149 + } 150 + 151 + /// The text Markdown for challenge 2, could be None 152 + fn markdown_text_part_two( 153 + &self, 154 + verification_code: Option<String>, 155 + ) -> Result<Option<String>, AdventError> { 156 + match self.get_day_markdown_file(AdventPart::Two)? { 157 + None => Ok(None), 158 + Some(day_two_file) => { 159 + //TODO probably should be a shared variable, but prototyping 160 + let reg = Handlebars::new(); 161 + 162 + let day_two_text = std::str::from_utf8(day_two_file.data.as_ref())?; 163 + let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string()); 164 + let handlebar_rendered = 165 + reg.render_template(day_two_text, &json!({"code": code}))?; 166 + 167 + Ok(Some( 168 + markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options()) 169 + .unwrap(), 170 + )) 171 + } 172 + } 173 + } 174 + 175 + /// Checks to see if the day's challenge had been started for the user 176 + async fn day_started(&self, did: &str) -> Result<bool, AdventError> { 177 + let exists = sqlx::query_scalar::<_, i64>( 178 + "SELECT id FROM challenges WHERE user_did = $1 AND day = $2 LIMIT 1", 179 + ) 180 + .bind(did) 181 + .bind(self.day() as i16) 182 + .fetch_optional(self.pool()) 183 + .await?; 184 + Ok(exists.is_some()) 185 + } 186 + 187 + async fn get_days_challenge( 188 + &self, 189 + did: String, 190 + ) -> Result<Option<ChallengeProgress>, AdventError> { 191 + let day = self.day(); 192 + Ok(sqlx::query_as::<_, ChallengeProgress>( 193 + "SELECT * FROM challenges WHERE user_did = $1 AND day = $2", 194 + ) 195 + .bind(did) 196 + .bind(day as i16) 197 + .fetch_optional(self.pool()) 198 + .await?) 199 + } 200 + 201 + async fn start_challenge(&self, did: String, part: AdventPart) -> Result<String, AdventError> { 202 + let code = get_random_token(); 203 + match part { 204 + AdventPart::One => sqlx::query( 205 + "INSERT INTO challenges (user_did, day, time_started, verification_code_one) 206 + VALUES ($1, $2, NOW(), $3) 207 + ON CONFLICT (user_did, day) 208 + DO UPDATE SET verification_code_one = $3 209 + WHERE challenges.user_did = $1 AND challenges.day = $2", 210 + ), 211 + //TODO just going leave these as an update. It should never ideally be an insert 212 + AdventPart::Two => sqlx::query( 213 + "UPDATE challenges 214 + SET verification_code_two = $3 215 + WHERE challenges.user_did = $1 AND challenges.day = $2", 216 + ), 217 + } 218 + .bind(did) 219 + .bind(self.day() as i16) 220 + .bind(code.clone()) 221 + .execute(self.pool()) 222 + .await?; 223 + Ok(code) 224 + } 225 + 226 + /// Marks the challenge as completed. 227 + async fn complete_part_one(&self, did: String) -> Result<(), AdventError> { 228 + sqlx::query( 229 + "UPDATE challenges 230 + SET time_challenge_one_completed = COALESCE(time_challenge_one_completed, NOW()) 231 + WHERE user_did = $1 AND day = $2", 232 + ) 233 + .bind(did) 234 + .bind(self.day() as i16) 235 + .execute(self.pool()) 236 + .await?; 237 + Ok(()) 238 + } 239 + 240 + /// Marks the challenge as completed. 241 + async fn complete_part_two(&self, did: String) -> Result<(), AdventError> { 242 + sqlx::query( 243 + "UPDATE challenges 244 + SET time_challenge_two_completed = COALESCE(time_challenge_two_completed, NOW()) 245 + WHERE user_did = $1 AND day = $2", 246 + ) 247 + .bind(did) 248 + .bind(self.day() as i16) 249 + .execute(self.pool()) 250 + .await?; 251 + Ok(()) 252 + } 253 + 254 + async fn get_completed_status( 255 + &self, 256 + did: Option<String>, 257 + ) -> Result<CompletionStatus, AdventError> { 258 + match did { 259 + None => Ok(CompletionStatus::None), 260 + Some(did) => { 261 + let day = self.day() as i32; 262 + let result = sqlx::query!( 263 + "SELECT time_challenge_one_completed, time_challenge_two_completed 264 + FROM challenges 265 + WHERE user_did = $1 AND day = $2", 266 + did, 267 + day 268 + ) 269 + .fetch_optional(self.pool()) 270 + .await?; 271 + 272 + Ok(match result { 273 + None => CompletionStatus::None, 274 + Some(row) => match ( 275 + row.time_challenge_one_completed, 276 + row.time_challenge_two_completed, 277 + ) { 278 + (None, None) => CompletionStatus::None, 279 + (Some(_), None) => CompletionStatus::PartOne, 280 + (Some(_), Some(_)) => CompletionStatus::Both, 281 + _ => panic!( 282 + "This should never happen as in part one shouldn't be not done but 2 is" 283 + ), 284 + }, 285 + }) 286 + } 287 + } 288 + } 289 + 290 + ///This is where the magic happens, aka logic to check if the user got it. Verification code is optional cause sometiems you may need to find it somewhere, sometimes maybe the backend does 291 + async fn check_part_one( 292 + &self, 293 + did: String, 294 + verification_code: Option<String>, 295 + ) -> Result<ChallengeCheckResponse, AdventError>; 296 + 297 + ///This is where the magic happens, aka logic to check if the user got it. Verification code is optional cause sometiems you may need to find it somewhere, sometimes maybe the backend does 298 + /// part two does have a hard error if its called and there is not a part 2 299 + async fn check_part_two( 300 + &self, 301 + _did: String, 302 + _verification_code: Option<String>, 303 + ) -> Result<ChallengeCheckResponse, AdventError> { 304 + unimplemented!("Second day challenges are optional") 305 + } 306 + } 307 + 308 + pub fn get_random_token() -> String { 309 + let mut rng = rand::rng(); 310 + 311 + let full_code = Alphanumeric.sample_string(&mut rng, 10); 312 + 313 + let slice_one = &full_code[0..5].to_ascii_uppercase(); 314 + let slice_two = &full_code[5..10].to_ascii_uppercase(); 315 + format!("{slice_one}-{slice_two}") 316 + } 317 + 318 + fn get_markdown_options() -> Options { 319 + Options { 320 + parse: Default::default(), 321 + compile: CompileOptions { 322 + //Setting this to allow HTML in the markdown. So pleas be careful what you put in there 323 + allow_dangerous_html: true, 324 + ..Default::default() 325 + }, 326 + } 327 + }
+6
shared/src/assets.rs
··· 1 + use rust_embed::Embed; 2 + 3 + #[derive(Embed)] 4 + #[folder = "challenges_markdown/"] 5 + #[include = "*.md"] 6 + pub struct ChallengesMarkdown;
+15
shared/src/atrium/mod.rs
··· 1 + use atrium_api::types::Unknown; 2 + use serde::de; 3 + 1 4 pub mod dns_resolver; 2 5 pub mod stores; 6 + 7 + /// Safely parses an unknown record into a type. If it fails, it logs the error and returns an error. 8 + pub fn safe_check_unknown_record_parse<T>(unknown: Unknown) -> serde_json::Result<T> 9 + where 10 + T: de::DeserializeOwned, 11 + { 12 + let json = serde_json::to_vec(&unknown).map_err(|err| { 13 + log::error!("Error getting the bytes of a record: {}", err); 14 + err 15 + })?; 16 + serde_json::from_slice::<T>(&json) 17 + }
+7 -4
shared/src/atrium/stores.rs
··· 1 1 /// Storage impls to persis OAuth sessions if you are not using the memory stores 2 2 /// https://github.com/bluesky-social/statusphere-example-app/blob/main/src/auth/storage.ts 3 - use crate::cache::{ATRIUM_SESSION_STORE_PREFIX, ATRIUM_STATE_STORE_KEY, create_prefixed_key}; 3 + use crate::cache::{ 4 + ATRIUM_SESSION_STORE_PREFIX, ATRIUM_STATE_STORE_KEY, Cache, create_prefixed_key, 5 + }; 4 6 use atrium_api::types::string::Did; 5 7 use atrium_common::store::Store; 6 8 use atrium_oauth::store::session::SessionStore; ··· 118 120 119 121 async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 120 122 let cache_key = create_prefixed_key(ATRIUM_STATE_STORE_KEY, key.as_ref()); 121 - let json_value = serde_json::to_string(&value)?; 122 - let mut cache = self.cache_pool.get().await?; 123 - let _: () = cache.set(cache_key, json_value).await?; 123 + let mut cache = Cache::new(self.cache_pool.get().await?); 124 + let _ = cache 125 + .write_to_cache_with_seconds(&cache_key, value, 3_6000) 126 + .await?; 124 127 Ok(()) 125 128 } 126 129
+3 -1
shared/src/cache.rs
··· 11 11 pub const ATRIUM_SESSION_STORE_PREFIX: &str = "atrium_session:"; 12 12 pub const ATRIUM_STATE_STORE_KEY: &str = "atrium_state:"; 13 13 14 + pub const TOWER_SESSION_KEY: &str = "tower_session:"; 15 + 14 16 pub fn create_prefixed_key(prefix: &str, key: &str) -> String { 15 17 format!("{}{}", prefix, key) 16 18 } 17 19 18 20 pub struct Cache<'a> { 19 - redis_pool: PooledConnection<'a, RedisConnectionManager>, 21 + pub redis_pool: PooledConnection<'a, RedisConnectionManager>, 20 22 } 21 23 22 24 #[derive(Debug, Error)]
+3
shared/src/lexicons/codes.rs
··· 1 + // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 + //!Definitions for the `codes` namespace. 3 + pub mod advent;
+3
shared/src/lexicons/codes/advent.rs
··· 1 + // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 + //!Definitions for the `codes.advent` namespace. 3 + pub mod challenge;
+9
shared/src/lexicons/codes/advent/challenge.rs
··· 1 + // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 + //!Definitions for the `codes.advent.challenge` namespace. 3 + pub mod day; 4 + #[derive(Debug)] 5 + pub struct Day; 6 + impl atrium_api::types::Collection for Day { 7 + const NSID: &'static str = "codes.advent.challenge.day"; 8 + type Record = day::Record; 9 + }
+18
shared/src/lexicons/codes/advent/challenge/day.rs
··· 1 + // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 + //!Definitions for the `codes.advent.challenge.day` namespace. 3 + use atrium_api::types::TryFromUnknown; 4 + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 5 + #[serde(rename_all = "camelCase")] 6 + pub struct RecordData { 7 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 8 + pub created_at: core::option::Option<atrium_api::types::string::Datetime>, 9 + pub part_one: String, 10 + #[serde(skip_serializing_if = "core::option::Option::is_none")] 11 + pub part_two: core::option::Option<String>, 12 + } 13 + pub type Record = atrium_api::types::Object<RecordData>; 14 + impl From<atrium_api::types::Unknown> for RecordData { 15 + fn from(value: atrium_api::types::Unknown) -> Self { 16 + Self::try_from_unknown(value).unwrap() 17 + } 18 + }
+3
shared/src/lexicons/mod.rs
··· 1 + // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 + pub mod record; 3 + pub mod codes;
+27
shared/src/lexicons/record.rs
··· 1 + // @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT. 2 + //!A collection of known record types. 3 + #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)] 4 + #[serde(tag = "$type")] 5 + pub enum KnownRecord { 6 + #[serde(rename = "codes.advent.challenge.day")] 7 + LexiconsCodesAdventChallengeDay( 8 + Box<crate::lexicons::codes::advent::challenge::day::Record>, 9 + ), 10 + } 11 + impl From<crate::lexicons::codes::advent::challenge::day::Record> for KnownRecord { 12 + fn from(record: crate::lexicons::codes::advent::challenge::day::Record) -> Self { 13 + KnownRecord::LexiconsCodesAdventChallengeDay(Box::new(record)) 14 + } 15 + } 16 + impl From<crate::lexicons::codes::advent::challenge::day::RecordData> for KnownRecord { 17 + fn from( 18 + record_data: crate::lexicons::codes::advent::challenge::day::RecordData, 19 + ) -> Self { 20 + KnownRecord::LexiconsCodesAdventChallengeDay(Box::new(record_data.into())) 21 + } 22 + } 23 + impl Into<atrium_api::types::Unknown> for KnownRecord { 24 + fn into(self) -> atrium_api::types::Unknown { 25 + atrium_api::types::TryIntoUnknown::try_into_unknown(&self).unwrap() 26 + } 27 + }
+37
shared/src/lib.rs
··· 1 + extern crate core; 2 + 3 + use crate::atrium::dns_resolver::HickoryDnsTxtResolver; 4 + use crate::atrium::stores::{AtriumSessionStore, AtriumStateStore}; 5 + use atrium_api::agent::Agent; 6 + use atrium_identity::did::CommonDidResolver; 7 + use atrium_identity::handle::AtprotoHandleResolver; 8 + use atrium_oauth::{DefaultHttpClient, OAuthClient}; 9 + use std::sync::Arc; 10 + 11 + pub mod advent; 12 + pub mod assets; 1 13 pub mod atrium; 2 14 pub mod cache; 3 15 pub mod db; 4 16 pub mod models; 5 17 pub mod web_helpers; 18 + 19 + pub mod lexicons; 20 + 21 + /// OAuthClientType to make it easier to access the OAuthClient in web requests 22 + pub type OAuthClientType = Arc< 23 + OAuthClient< 24 + AtriumStateStore, 25 + AtriumSessionStore, 26 + CommonDidResolver<DefaultHttpClient>, 27 + AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>, 28 + >, 29 + >; 30 + 31 + /// HandleResolver type to make it easier to access the resolver in web requests 32 + pub type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 33 + 34 + /// The agent(what makes atproto calls) 35 + pub type OAuthAgentType = Agent< 36 + atrium_oauth::OAuthSession< 37 + DefaultHttpClient, 38 + CommonDidResolver<DefaultHttpClient>, 39 + AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>, 40 + AtriumSessionStore, 41 + >, 42 + >;
+13
shared/src/models/db_models.rs
··· 1 1 use serde::{Deserialize, Serialize}; 2 2 use sqlx::FromRow; 3 + use sqlx::types::chrono::{DateTime, Utc}; 3 4 4 5 #[derive(FromRow, Serialize, Deserialize, Debug, Default)] 5 6 pub struct TestModel { 6 7 pub id: i64, 7 8 pub test: String, 8 9 } 10 + 11 + #[derive(FromRow, Serialize, Deserialize, Debug, Clone)] 12 + pub struct ChallengeProgress { 13 + pub id: i64, 14 + pub user_did: String, 15 + pub day: i32, 16 + pub time_started: DateTime<Utc>, 17 + pub time_challenge_one_completed: Option<DateTime<Utc>>, 18 + pub time_challenge_two_completed: Option<DateTime<Utc>>, 19 + pub verification_code_one: Option<String>, 20 + pub verification_code_two: Option<String>, 21 + }
+9 -2
web/Cargo.toml
··· 9 9 atrium-identity.workspace = true 10 10 atrium-oauth.workspace = true 11 11 axum.workspace = true 12 + chrono.workspace = true 12 13 bb8.workspace = true 13 14 bb8-redis.workspace = true 14 15 dotenv.workspace = true ··· 17 18 serde_json.workspace = true 18 19 shared.workspace = true 19 20 sqlx.workspace = true 20 - tokio = { version = "1.46.1", features = ["full"] } 21 + tokio.workspace = true 21 22 tower-http = { version = "0.6.6", features = ["trace"] } 22 23 tower-sessions = "0.14.0" 23 24 tracing.workspace = true 24 25 tracing-subscriber.workspace = true 25 - serde = { version = "1.0.219", features = ["derive"] } 26 + serde = { version = "1.0.219", features = ["derive"] } 27 + askama = "0.14" 28 + async-trait = "0.1.88" 29 + hypertext = "0.12.1" 30 + 31 + [build-dependencies] 32 + pool = "0.1.4"
+112
web/src/handlers/auth.rs
··· 1 + use crate::session::{AxumSessionStore, FlashMessage, get_flash_message, set_flash_message}; 2 + use crate::templates::{HtmlTemplate, login::LoginTemplate}; 3 + use crate::{error_response, oauth_scopes}; 4 + use atrium_api::agent::Agent; 5 + use atrium_oauth::{AuthorizeOptions, CallbackParams}; 6 + use axum::{ 7 + extract::{Path, Query, State}, 8 + http::StatusCode, 9 + response::{IntoResponse, Redirect, Response}, 10 + }; 11 + use shared::OAuthClientType; 12 + 13 + pub async fn login_page_handler( 14 + mut session: AxumSessionStore, 15 + ) -> Result<impl IntoResponse, Response> { 16 + let possible_error = match get_flash_message(&mut session, "error").await? { 17 + Some(FlashMessage::Error(msg)) => Some(msg), 18 + _ => None, 19 + }; 20 + 21 + Ok(HtmlTemplate(LoginTemplate { 22 + title: "at://advent - Login", 23 + error: possible_error, 24 + })) 25 + } 26 + 27 + pub async fn login_handle( 28 + Path(handle): Path<String>, 29 + State(oauth_client): State<OAuthClientType>, 30 + mut session: AxumSessionStore, 31 + ) -> Result<impl IntoResponse, Response> { 32 + match atrium_api::types::string::Handle::new(handle) { 33 + Ok(handle) => { 34 + match oauth_client 35 + .authorize( 36 + &handle, 37 + AuthorizeOptions { 38 + scopes: oauth_scopes(), 39 + ..Default::default() 40 + }, 41 + ) 42 + .await 43 + { 44 + Ok(url) => Ok(Redirect::to(url.as_str())), 45 + Err(err) => { 46 + log::error!("Error generating OAuth URL: {err}"); 47 + set_flash_message( 48 + &mut session, 49 + "error", 50 + FlashMessage::Error("Error creating login URL".to_string()), 51 + ) 52 + .await?; 53 + Err(error_response( 54 + StatusCode::INTERNAL_SERVER_ERROR, 55 + "Error creating login URL", 56 + )) 57 + } 58 + } 59 + } 60 + Err(err) => { 61 + log::error!("Error parsing the handle: {err}"); 62 + set_flash_message( 63 + &mut session, 64 + "error", 65 + FlashMessage::Error("Error parsing the handle".to_string()), 66 + ) 67 + .await?; 68 + 69 + Ok(Redirect::to("/login")) 70 + } 71 + } 72 + } 73 + 74 + pub async fn handle_root_handler() -> impl IntoResponse { 75 + Redirect::to("/login") 76 + } 77 + 78 + ///End point that takes back the OAuth call back and creates a session 79 + pub async fn oauth_callback_handler( 80 + params: Query<CallbackParams>, 81 + State(oauth_client): State<OAuthClientType>, 82 + mut session: AxumSessionStore, 83 + ) -> Response { 84 + let call_back_params = CallbackParams { 85 + code: params.code.clone(), 86 + state: params.state.clone(), 87 + iss: params.iss.clone(), 88 + }; 89 + match oauth_client.callback(call_back_params).await { 90 + Ok((bsky_session, _)) => { 91 + let agent = Agent::new(bsky_session); 92 + match agent.did().await { 93 + Some(did) => { 94 + if let Err(err) = session.set_did(did.clone().to_string()).await { 95 + log::error!("Failed to write session: {err}"); 96 + return error_response( 97 + StatusCode::INTERNAL_SERVER_ERROR, 98 + "Failed to create session", 99 + ); 100 + } 101 + 102 + Redirect::permanent("/day/1").into_response() 103 + } 104 + None => error_response(StatusCode::INTERNAL_SERVER_ERROR, "No DID found"), 105 + } 106 + } 107 + Err(err) => { 108 + log::error!("OAuth callback error: {err}"); 109 + error_response(StatusCode::INTERNAL_SERVER_ERROR, "OAuth callback failed") 110 + } 111 + } 112 + }
+378
web/src/handlers/day.rs
··· 1 + use crate::error_response; 2 + use crate::session::{AxumSessionStore, FlashMessage, get_flash_message, set_flash_message}; 3 + use crate::templates::{HtmlTemplate, day::DayTemplate}; 4 + use atrium_api::agent::Agent; 5 + use atrium_api::types::string::Did; 6 + use axum::{ 7 + extract::{Form, Path, State}, 8 + http::StatusCode, 9 + response::{IntoResponse, Redirect, Response}, 10 + }; 11 + use shared::advent::ChallengeCheckResponse; 12 + use shared::{ 13 + OAuthAgentType, OAuthClientType, 14 + advent::challenges::day_one::DayOne, 15 + advent::challenges::day_two::DayTwo, 16 + advent::day::Day, 17 + advent::{AdventChallenge, AdventError}, 18 + advent::{AdventPart, CompletionStatus}, 19 + }; 20 + use sqlx::PgPool; 21 + 22 + fn pick_day( 23 + day: Day, 24 + pool: PgPool, 25 + oauth_client: Option<OAuthAgentType>, 26 + ) -> Result<Box<dyn AdventChallenge + Send + Sync>, AdventError> { 27 + match day { 28 + Day::One => Ok(Box::new(DayOne { pool, oauth_client })), 29 + Day::Two => Ok(Box::new(DayTwo { pool, oauth_client })), 30 + _ => Err(AdventError::InvalidDay(0)), // Day::Three => {} 31 + // Day::Four => {} 32 + // Day::Five => {} 33 + // Day::Six => {} 34 + // Day::Seven => {} 35 + // Day::Eight => {} 36 + // Day::Nine => {} 37 + // Day::Ten => {} 38 + // Day::Eleven => {} 39 + // Day::Twelve => {} 40 + // Day::Thirteen => {} 41 + // Day::Fourteen => {} 42 + // Day::Fifteen => {} 43 + // Day::Sixteen => {} 44 + // Day::Seventeen => {} 45 + // Day::Eighteen => {} 46 + // Day::Nineteen => {} 47 + // Day::Twenty => {} 48 + // Day::TwentyOne => {} 49 + // Day::TwentyTwo => {} 50 + // Day::TwentyThree => {} 51 + // Day::TwentyFour => {} 52 + // Day::TwentyFive => {} 53 + } 54 + } 55 + 56 + fn log_and_respond<E: std::fmt::Display>( 57 + status: StatusCode, 58 + context: &'static str, 59 + ) -> impl FnOnce(E) -> Response { 60 + move |err| { 61 + log::error!("{context}: {err}"); 62 + error_response(status, context) 63 + } 64 + } 65 + 66 + pub async fn view_day_handler( 67 + Path(id): Path<u8>, 68 + State(pool): State<PgPool>, 69 + session: AxumSessionStore, 70 + ) -> Result<impl IntoResponse, Response> { 71 + let day = Day::from(id); 72 + 73 + let did = session.get_did(); 74 + let did_clone = did.clone(); 75 + let challenge = pick_day(day, pool, None).map_err(|err| { 76 + log::error!("Error picking day: {err}"); 77 + error_response(StatusCode::INTERNAL_SERVER_ERROR, "Error picking day") 78 + })?; 79 + 80 + let title = format!("at://advent - Day {}", day as u8); 81 + let part_one_text = match did_clone { 82 + None => challenge 83 + .markdown_text_part_one(None) 84 + .map(|s| s.to_string()) 85 + .unwrap_or_else(|_| "Error loading part one".to_string()), 86 + Some(ref users_did) => match challenge.get_days_challenge(users_did.clone()).await { 87 + Ok(current_challenge) => match current_challenge { 88 + None => { 89 + let new_code = challenge 90 + .start_challenge(users_did.to_string(), AdventPart::One) 91 + .await 92 + .unwrap(); 93 + challenge 94 + .markdown_text_part_one(Some(new_code)) 95 + .map(|s| s.to_string()) 96 + .unwrap_or_else(|_| "Error loading part one".to_string()) 97 + } 98 + Some(current_challenge) => match current_challenge.verification_code_one { 99 + None => { 100 + let new_code = challenge 101 + .start_challenge(users_did.to_string(), AdventPart::One) 102 + .await 103 + .unwrap(); 104 + challenge 105 + .markdown_text_part_one(Some(new_code)) 106 + .map(|s| s.to_string()) 107 + .unwrap_or_else(|_| "Error loading part one".to_string()) 108 + } 109 + Some(code) => challenge 110 + .markdown_text_part_one(Some(code)) 111 + .map(|s| s.to_string()) 112 + .unwrap_or_else(|_| "Error loading part one".to_string()), 113 + }, 114 + }, 115 + 116 + Err(err) => { 117 + log::error!("Error loading today's challenge for the user: {users_did} \n {err}"); 118 + "There was an error loading the challenge...sorry about that".to_string() 119 + } 120 + }, 121 + }; 122 + 123 + let status = challenge.get_completed_status(did).await.map_err(|err| { 124 + log::error!("Error getting completed status: {err}"); 125 + error_response( 126 + StatusCode::INTERNAL_SERVER_ERROR, 127 + "Error getting completed status", 128 + ) 129 + })?; 130 + 131 + let mut session = session; 132 + let part_one_flash = get_flash_message(&mut session, "part_one_result").await?; 133 + let part_two_flash = get_flash_message(&mut session, "part_two_result").await?; 134 + 135 + let template = match status { 136 + CompletionStatus::None => DayTemplate { 137 + title, 138 + day: id, 139 + challenge_one_text: part_one_text, 140 + challenge_one_completed: false, 141 + challenge_two_text: None, 142 + challenge_two_completed: false, 143 + part_one_submit_message: part_one_flash, 144 + part_two_submit_message: part_two_flash, 145 + }, 146 + CompletionStatus::PartOne => { 147 + let part_two_text = get_part_two_text(did_clone, &challenge).await; 148 + let completed = part_two_text.is_none(); 149 + DayTemplate { 150 + title, 151 + day: id, 152 + challenge_one_text: part_one_text, 153 + challenge_one_completed: true, 154 + challenge_two_text: part_two_text, 155 + challenge_two_completed: completed, 156 + part_one_submit_message: part_one_flash, 157 + part_two_submit_message: part_two_flash, 158 + } 159 + } 160 + CompletionStatus::Both => { 161 + let part_two_text = get_part_two_text(did_clone, &challenge).await; 162 + DayTemplate { 163 + title, 164 + day: id, 165 + challenge_one_text: part_one_text, 166 + challenge_one_completed: true, 167 + challenge_two_text: part_two_text, 168 + challenge_two_completed: true, 169 + part_one_submit_message: part_one_flash, 170 + part_two_submit_message: part_two_flash, 171 + } 172 + } 173 + }; 174 + 175 + Ok(HtmlTemplate(template)) 176 + } 177 + 178 + ///TODO prob look and see if this can be shared between part one since it is similar logic... 179 + /// Also this is in a function since PartOne and Both load the partwo text 180 + async fn get_part_two_text( 181 + did_clone: Option<String>, 182 + challenge: &Box<dyn AdventChallenge + Send + Sync>, 183 + ) -> Option<String> { 184 + let part_two_text: Option<String> = match did_clone { 185 + None => challenge 186 + .markdown_text_part_two(None) 187 + .map(|opt| opt.map(|s| s.to_string())) 188 + .unwrap_or(None), 189 + Some(users_did) => match challenge.get_days_challenge(users_did.clone()).await { 190 + Ok(current_challenge) => match current_challenge { 191 + None => { 192 + if challenge.has_part_two() { 193 + let new_code = challenge 194 + .start_challenge(users_did.to_string(), AdventPart::Two) 195 + .await 196 + .unwrap(); 197 + challenge 198 + .markdown_text_part_two(Some(new_code)) 199 + .map(|opt| opt.map(|s| s.to_string())) 200 + .unwrap_or(None) 201 + } else { 202 + None 203 + } 204 + } 205 + Some(current_challenge) => { 206 + // If there is no code yet for part two, start it; otherwise use the existing code 207 + if challenge.has_part_two() { 208 + match current_challenge.verification_code_two { 209 + None => { 210 + let new_code = challenge 211 + .start_challenge(users_did.to_string(), AdventPart::Two) 212 + .await 213 + .unwrap(); 214 + challenge 215 + .markdown_text_part_two(Some(new_code)) 216 + .map(|opt| opt.map(|s| s.to_string())) 217 + .unwrap_or(None) 218 + } 219 + Some(code) => challenge 220 + .markdown_text_part_two(Some(code)) 221 + .map(|opt| opt.map(|s| s.to_string())) 222 + .unwrap_or(None), 223 + } 224 + } else { 225 + let day = current_challenge.day; 226 + log::warn!( 227 + "There is no part two for day: {day}. Developer may of forgotten to set the has_part_two flag to true." 228 + ); 229 + None 230 + } 231 + } 232 + }, 233 + Err(err) => { 234 + log::error!("Error loading today's challenge for the user: {users_did} \n {err}"); 235 + None 236 + } 237 + }, 238 + }; 239 + part_two_text 240 + } 241 + 242 + /// This can be used to verify the day's challenge. Empty if it's up to the backend to grab the verification code 243 + /// from somewhere like a lexicon record 244 + #[derive(Debug, serde::Deserialize, Clone)] 245 + pub struct PostDayForm { 246 + #[serde(default)] 247 + pub verification_code_one: Option<String>, 248 + #[serde(default)] 249 + pub verification_code_two: Option<String>, 250 + } 251 + 252 + ///This is the endpoint to verify the day's challenge 253 + pub async fn post_day_handler( 254 + Path(day): Path<u8>, 255 + State(pool): State<PgPool>, 256 + State(oauth_client): State<OAuthClientType>, 257 + mut session: AxumSessionStore, 258 + Form(form): Form<PostDayForm>, 259 + ) -> Result<impl IntoResponse, Response> { 260 + match &session.get_did() { 261 + None => Err(error_response( 262 + StatusCode::FORBIDDEN, 263 + "You need to be logged in to submit an answer", 264 + )), 265 + Some(did) => { 266 + let did_as_string = did.clone(); 267 + let did = Did::new(did.to_string()) 268 + .map_err(log_and_respond(StatusCode::BAD_REQUEST, "Invalid DID"))?; 269 + 270 + let client = oauth_client.restore(&did).await.map_err(log_and_respond( 271 + StatusCode::INTERNAL_SERVER_ERROR, 272 + "There was an error restoring the oauth client", 273 + ))?; 274 + 275 + let agent = Agent::new(client); 276 + let day = Day::from(day); 277 + 278 + let challenge = pick_day(day, pool, Some(agent)).map_err(log_and_respond( 279 + StatusCode::INTERNAL_SERVER_ERROR, 280 + "Error picking the day", 281 + ))?; 282 + 283 + let status = challenge 284 + .get_completed_status(Some(did_as_string.clone())) 285 + .await 286 + .map_err(log_and_respond( 287 + StatusCode::INTERNAL_SERVER_ERROR, 288 + "Error getting the completed status", 289 + ))?; 290 + 291 + match status { 292 + CompletionStatus::None => { 293 + let result = challenge 294 + .check_part_one(did_as_string.clone(), form.verification_code_one) 295 + .await 296 + .map_err(log_and_respond( 297 + StatusCode::INTERNAL_SERVER_ERROR, 298 + "Error checking part one", 299 + ))?; 300 + match result { 301 + ChallengeCheckResponse::Correct => { 302 + challenge.complete_part_one(did_as_string).await.map_err( 303 + log_and_respond( 304 + StatusCode::INTERNAL_SERVER_ERROR, 305 + "Error completing part one", 306 + ), 307 + )?; 308 + set_flash_message( 309 + &mut session, 310 + "part_one_result", 311 + FlashMessage::Success( 312 + "Good job, you've completed Part 1".to_string(), 313 + ), 314 + ) 315 + .await?; 316 + Ok(Redirect::to(format!("/day/{}", day as u8).as_str())) 317 + } 318 + ChallengeCheckResponse::Incorrect(message) => { 319 + set_flash_message( 320 + &mut session, 321 + "part_one_result", 322 + FlashMessage::Error(message), 323 + ) 324 + .await?; 325 + Ok(Redirect::to(format!("/day/{}", day as u8).as_str())) 326 + } 327 + } 328 + } 329 + CompletionStatus::PartOne => { 330 + if !challenge.has_part_two() { 331 + log::info!( 332 + "Someone tried to check for part two on day:{day}, when there was not one" 333 + ); 334 + return Ok(Redirect::to(format!("/day/{}", day as u8).as_str())); 335 + } 336 + 337 + let result = challenge 338 + .check_part_two(did_as_string.clone(), form.verification_code_two) 339 + .await 340 + .map_err(log_and_respond( 341 + StatusCode::INTERNAL_SERVER_ERROR, 342 + "Error checking part two", 343 + ))?; 344 + 345 + match result { 346 + ChallengeCheckResponse::Correct => { 347 + challenge.complete_part_two(did_as_string).await.map_err( 348 + log_and_respond( 349 + StatusCode::INTERNAL_SERVER_ERROR, 350 + "Error completing part two", 351 + ), 352 + )?; 353 + set_flash_message( 354 + &mut session, 355 + "part_two_result", 356 + FlashMessage::Success( 357 + "Good job, you've completed Part 2".to_string(), 358 + ), 359 + ) 360 + .await?; 361 + Ok(Redirect::to(format!("/day/{}", day as u8).as_str())) 362 + } 363 + ChallengeCheckResponse::Incorrect(message) => { 364 + set_flash_message( 365 + &mut session, 366 + "part_two_result", 367 + FlashMessage::Error(message), 368 + ) 369 + .await?; 370 + Ok(Redirect::to(format!("/day/{}", day as u8).as_str())) 371 + } 372 + } 373 + } 374 + CompletionStatus::Both => Ok(Redirect::to(format!("/day/{}", day as u8).as_str())), 375 + } 376 + } 377 + } 378 + }
+2
web/src/handlers/mod.rs
··· 1 + pub mod day; 2 + pub mod auth;
+100 -173
web/src/main.rs
··· 1 - use atrium_api::agent::Agent; 2 - use atrium_api::types::string::Did; 3 - use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}; 4 - use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}; 1 + use crate::{ 2 + templates::HtmlTemplate, templates::error::ErrorTemplate, templates::home::HomeTemplate, 3 + }; 4 + use atrium_identity::{ 5 + did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL}, 6 + handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig}, 7 + }; 5 8 use atrium_oauth::{ 6 - AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient, 7 - KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 9 + AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClient, OAuthClientConfig, 10 + OAuthResolverConfig, Scope, 11 + }; 12 + use axum::{ 13 + Router, 14 + http::StatusCode, 15 + middleware, 16 + response::IntoResponse, 17 + response::Response, 18 + routing::{get, post}, 8 19 }; 9 - use axum::extract::{Query, State}; 10 - use axum::http::StatusCode; 11 - use axum::{Json, Router, extract::Path, middleware, routing::get}; 12 20 use bb8_redis::RedisConnectionManager; 21 + use chrono::Datelike; 13 22 use dotenv::dotenv; 14 23 use redis::AsyncCommands; 15 - use shared::atrium::dns_resolver::HickoryDnsTxtResolver; 16 - use shared::atrium::stores::{AtriumSessionStore, AtriumStateStore}; 17 - use shared::cache::CacheConnection; 18 - use shared::models::db_models::TestModel; 19 - use sqlx::PgPool; 20 - use sqlx::postgres::PgPoolOptions; 21 - use std::sync::Arc; 24 + use shared::{ 25 + HandleResolver, OAuthClientType, atrium::dns_resolver::HickoryDnsTxtResolver, 26 + atrium::stores::AtriumSessionStore, atrium::stores::AtriumStateStore, 27 + }; 28 + use sqlx::{PgPool, postgres::PgPoolOptions}; 22 29 use std::{ 23 30 env, 24 31 net::{IpAddr, Ipv4Addr, SocketAddr}, 32 + sync::Arc, 25 33 time, 26 34 }; 27 35 use time::Duration; 28 36 use tower_http::trace::TraceLayer; 29 - use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; 37 + use tower_sessions::{SessionManagerLayer, cookie::SameSite}; 30 38 use tracing_subscriber::EnvFilter; 31 39 40 + mod handlers; 41 + 32 42 extern crate dotenv; 33 43 34 44 mod extractors; 45 + mod redis_session_store; 46 + mod session; 47 + mod templates; 35 48 mod unlock; 36 49 37 50 #[derive(Clone)] ··· 39 52 postgres_pool: PgPool, 40 53 redis_pool: bb8::Pool<RedisConnectionManager>, 41 54 oauth_client: OAuthClientType, 42 - //Used to get did to handle leaving cause I figured we'd need it 55 + //Used to get did to handle leaving because I figured we'd need it 43 56 _handle_resolver: HandleResolver, 44 57 } 45 58 46 - /// OAuthClientType to make it easier to access the OAuthClient in web requests 47 - type OAuthClientType = Arc< 48 - OAuthClient< 49 - AtriumStateStore, 50 - AtriumSessionStore, 51 - CommonDidResolver<DefaultHttpClient>, 52 - AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>, 53 - >, 54 - >; 59 + fn oauth_scopes() -> Vec<Scope> { 60 + vec![ 61 + Scope::Known(KnownScope::Atproto), 62 + //Gives full CRUD to the codes.advent.* collection 63 + Scope::Unknown("repo:codes.advent.*".to_string()), 64 + ] 65 + } 55 66 56 - /// HandleResolver type to make it easier to access the resolver in web requests 57 - type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 67 + fn error_response(status: StatusCode, message: &str) -> Response { 68 + IntoResponse::into_response(( 69 + status, 70 + HtmlTemplate(ErrorTemplate { 71 + title: "at://advent - Error", 72 + message, 73 + }), 74 + )) 75 + } 58 76 59 77 #[tokio::main] 60 78 async fn main() -> Result<(), Box<dyn std::error::Error>> { ··· 74 92 let port = addr.port(); 75 93 let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 76 94 77 - let now = time::Instant::now(); 78 - let daily = time::Duration::from_secs(24 * 60 * 60); 79 - let state = unlock::Unlock::new(now, daily); 80 - 81 95 //sqlx pool 82 96 let database_url = 83 97 env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment or .env"); ··· 123 137 //This must match the endpoint you use the callback function 124 138 "http://{host}:{port}/oauth/callback" 125 139 ))]), 126 - scopes: Some(vec![ 127 - Scope::Known(KnownScope::Atproto), 128 - Scope::Known(KnownScope::TransitionGeneric), 129 - ]), 140 + scopes: Some(oauth_scopes()), 130 141 }, 131 142 keys: None, 132 143 resolver: OAuthResolverConfig { ··· 146 157 }; 147 158 let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")); 148 159 149 - //tower sessions setup. Using in memory for now, something is off about the redis one will implement our own via the trait using bb8 pool 150 - // https://docs.rs/tower-sessions/latest/tower_sessions/trait.SessionStore.html 151 - let session_store = MemoryStore::default(); 152 - let session_layer = SessionManagerLayer::new(session_store).with_secure(false); 160 + let session_store = redis_session_store::RedisSessionStore::new(redis_pool.clone()); 161 + let session_layer = SessionManagerLayer::new(session_store) 162 + //Set to lax so session id cookie can be set on redirect 163 + .with_same_site(SameSite::Lax) 164 + .with_secure(false); 153 165 154 166 let app_state = AppState { 155 167 postgres_pool, ··· 157 169 oauth_client: client, 158 170 _handle_resolver: handle_resolver, 159 171 }; 160 - 172 + //HACK Yeah I don't like it either - bt 173 + let prod: bool = env::var("PROD") 174 + .map(|val| val == "true") 175 + .unwrap_or_else(|_| true); 161 176 log::info!("listening on http://{}", addr); 162 177 let app = Router::new() 178 + .route("/", get(home_handler)) 179 + .route( 180 + "/day/{id}", 181 + match prod { 182 + true => get(handlers::day::view_day_handler) 183 + .route_layer(middleware::from_fn(unlock::unlock)), 184 + false => get(handlers::day::view_day_handler), 185 + }, 186 + ) 163 187 .route( 164 188 "/day/{id}", 165 - get(handler).route_layer(middleware::from_fn_with_state( 166 - state.clone(), 167 - unlock::unlock, 168 - )), 189 + match prod { 190 + true => post(handlers::day::post_day_handler) 191 + .route_layer(middleware::from_fn(unlock::unlock)), 192 + false => post(handlers::day::post_day_handler), 193 + }, 194 + ) 195 + .route("/login", get(handlers::auth::login_page_handler)) 196 + .route("/handle", get(handlers::auth::handle_root_handler)) 197 + .route("/login/{handle}", get(handlers::auth::login_handle)) 198 + .route( 199 + "/oauth/callback", 200 + get(handlers::auth::oauth_callback_handler), 169 201 ) 170 - .route("/sql-test", get(sql_test_handler)) 171 - .route("/redis-test", get(redis_test_handler)) 172 - .route("/login/{handle}", get(login_test_handler)) 173 - .route("/oauth/callback", get(oauth_callback_handler)) 174 - .route("/logged-in", get(logged_in_test_handler)) 175 202 .layer(session_layer) 176 203 .with_state(app_state) 177 204 .layer(TraceLayer::new_for_http()); ··· 179 206 Ok(()) 180 207 } 181 208 182 - async fn handler(Path(id): Path<u32>) -> String { 183 - format!("hello day {id}") 184 - } 185 - 186 - async fn sql_test_handler(State(pool): State<PgPool>) -> Json<Vec<TestModel>> { 187 - Json( 188 - sqlx::query_as::<_, TestModel>("SELECT id, test FROM test_table") 189 - .fetch_all(&pool) 190 - .await 191 - .unwrap(), 192 - ) 193 - } 194 - 195 - /// Pass in your handle like /login/baileytownsend.dev 196 - async fn login_test_handler( 197 - Path(handle): Path<String>, 198 - State(oauth_client): State<OAuthClientType>, 199 - ) -> String { 200 - match atrium_api::types::string::Handle::new(handle) { 201 - Ok(handle) => { 202 - //Creates the oauth url to redirect to for the user to log in with their credentials 203 - let oauth_url = oauth_client 204 - .authorize( 205 - &handle, 206 - AuthorizeOptions { 207 - scopes: vec![ 208 - Scope::Known(KnownScope::Atproto), 209 - Scope::Known(KnownScope::TransitionGeneric), 210 - ], 211 - ..Default::default() 212 - }, 213 - ) 214 - .await; 215 - oauth_url.unwrap_or_else(|err| { 216 - log::error!("Error: {err}"); 217 - err.to_string() 218 - }) 219 - } 220 - Err(err) => err.to_string(), 221 - } 222 - } 223 - 224 - ///End point that takes back the OAuth call back and creates a session 225 - async fn oauth_callback_handler( 226 - params: Query<CallbackParams>, 227 - State(oauth_client): State<OAuthClientType>, 228 - session: Session, 229 - ) -> String { 230 - //HACK, yeah I gave up... hoping someone has a better solution 231 - let call_back_params = CallbackParams { 232 - code: params.code.clone(), 233 - state: params.state.clone(), 234 - iss: params.iss.clone(), 235 - }; 236 - match oauth_client.callback(call_back_params).await { 237 - Ok((bsky_session, _)) => { 238 - let agent = Agent::new(bsky_session); 239 - match agent.did().await { 240 - Some(did) => { 241 - session.insert("did", did.clone()).await.unwrap(); 242 - format!("Session created for {}", did.to_string()) 243 - // did.to_string() 244 - // session.insert("did", did).unwrap(); 245 - // Redirect::to("/") 246 - // .see_other() 247 - // .respond_to(&request) 248 - // .map_into_boxed_body() 249 - } 250 - None => String::from("No DID found"), 209 + /// Landing page showing currently unlocked days and a login button 210 + async fn home_handler() -> impl IntoResponse { 211 + //TODO make a helper function for this since it is similar to the middleware 212 + let now = chrono::Utc::now(); 213 + let mut unlocked: Vec<u8> = Vec::new(); 214 + 215 + //HACK Yeah I don't like it either - bt 216 + let prod: bool = env::var("PROD") 217 + .map(|val| val == "true") 218 + .unwrap_or_else(|_| true); 219 + if prod { 220 + if now.month() == 12 { 221 + let today = now.day().min(25); 222 + for d in 1..=today { 223 + unlocked.push(d as u8); 251 224 } 252 225 } 253 - Err(err) => { 254 - log::error!("Error: {err}"); 255 - err.to_string() 226 + } else { 227 + for d in 1..=25 { 228 + unlocked.push(d as u8); 256 229 } 257 230 } 258 - } 259 231 260 - async fn logged_in_test_handler( 261 - State(oauth_client): State<OAuthClientType>, 262 - session: Session, 263 - ) -> String { 264 - let session_did = session.get::<String>("did").await.unwrap().unwrap(); 265 - let did = Did::new(session_did).expect("failed to parse did"); 266 - let client = oauth_client.restore(&did).await.unwrap(); 267 - let agent = Agent::new(client); 268 - let notifications = agent 269 - .api 270 - .app 271 - .bsky 272 - .notification 273 - .list_notifications( 274 - atrium_api::app::bsky::notification::list_notifications::ParametersData { 275 - cursor: None, 276 - limit: None, 277 - priority: None, 278 - reasons: None, 279 - seen_at: None, 280 - } 281 - .into(), 282 - ) 283 - .await 284 - .unwrap(); 285 - 286 - notifications 287 - .notifications 288 - .iter() 289 - .map(|n| { 290 - format!( 291 - "Author: {} Reason: {}, URI: {}", 292 - n.author.handle.as_str(), 293 - n.reason, 294 - n.uri 295 - ) 296 - }) 297 - .collect::<Vec<String>>() 298 - .join("\n") 299 - } 300 - 301 - async fn redis_test_handler( 302 - CacheConnection(mut conn): CacheConnection<'_>, 303 - ) -> Result<String, (StatusCode, String)> { 304 - let result: String = conn 305 - .fetch_redis("foo") 306 - .await 307 - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; 308 - Ok(result) 232 + HtmlTemplate(HomeTemplate { 233 + title: "at://advent", 234 + unlocked_days: unlocked, 235 + }) 309 236 }
+111
web/src/redis_session_store.rs
··· 1 + use async_trait::async_trait; 2 + use bb8::Pool; 3 + use bb8_redis::{RedisConnectionManager, redis::cmd}; 4 + use shared::cache::{Cache, TOWER_SESSION_KEY, create_prefixed_key}; 5 + use std::fmt::Display; 6 + use std::fmt::{Debug, Formatter}; 7 + use tower_sessions::SessionStore; 8 + use tower_sessions::session::{Id, Record}; 9 + use tower_sessions::session_store::{Error, Result as StoreResult}; 10 + 11 + #[derive(Clone)] 12 + pub struct RedisSessionStore { 13 + cache_pool: Pool<RedisConnectionManager>, 14 + } 15 + 16 + impl RedisSessionStore { 17 + pub fn new(cache_pool: Pool<RedisConnectionManager>) -> Self { 18 + Self { cache_pool } 19 + } 20 + } 21 + 22 + impl Debug for RedisSessionStore { 23 + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 24 + f.debug_struct("RedisSessionStore").finish() 25 + } 26 + } 27 + 28 + // Small helper to convert any error into tower-sessions Backend error consistently 29 + fn backend_map<E: Display>(context: &'static str) -> impl FnOnce(E) -> Error { 30 + move |err| { 31 + log::error!("{}: {}", context, err); 32 + Error::Backend(err.to_string()) 33 + } 34 + } 35 + 36 + #[async_trait] 37 + impl SessionStore for RedisSessionStore { 38 + async fn create(&self, session_record: &mut Record) -> StoreResult<()> { 39 + //TODO i don't think there is an issue with overwriting the session here since it's redis and should be no collision 40 + //The default create throws a warning about this so, added this to get rid of it and adding a note in case it does cause a problem 41 + self.save(session_record).await 42 + } 43 + 44 + async fn save(&self, session_record: &Record) -> StoreResult<()> { 45 + let id_as_str: String = session_record.id.0.to_string(); 46 + let key = create_prefixed_key(TOWER_SESSION_KEY, id_as_str.as_str()); 47 + 48 + // Get a redis connection 49 + let conn = self 50 + .cache_pool 51 + .get() 52 + .await 53 + .map_err(backend_map("There was an error connecting to the cache"))?; 54 + 55 + // Set value with TTL based on expiry_date 56 + let expiry = session_record.expiry_date; 57 + let now = std::time::SystemTime::now() 58 + .duration_since(std::time::UNIX_EPOCH) 59 + .unwrap_or_default() 60 + .as_secs() as i64; 61 + let ttl_secs = expiry.unix_timestamp().saturating_sub(now).max(0) as usize; 62 + 63 + //Helper for some cache functions 64 + let mut cache = Cache { redis_pool: conn }; 65 + cache 66 + .write_to_cache_with_seconds(&key, &session_record, ttl_secs as u64) 67 + .await 68 + .map_err(backend_map("There was an error saving the session"))?; 69 + 70 + Ok(()) 71 + } 72 + 73 + async fn load(&self, session_id: &Id) -> StoreResult<Option<Record>> { 74 + let id_as_str: String = session_id.0.to_string(); 75 + let key = create_prefixed_key(TOWER_SESSION_KEY, id_as_str.as_str()); 76 + 77 + let conn = self 78 + .cache_pool 79 + .get() 80 + .await 81 + .map_err(backend_map("There was an error connecting to the cache"))?; 82 + let mut cache = Cache { redis_pool: conn }; 83 + 84 + let val = match cache.fetch_redis_json_object::<Option<Record>>(&key).await { 85 + Ok(Some(record)) => Ok(record), 86 + Ok(None) => Ok(None), 87 + Err(err) => Err(err), 88 + } 89 + .map_err(backend_map("There was an error loading the session"))?; 90 + Ok(val) 91 + } 92 + 93 + async fn delete(&self, session_id: &Id) -> StoreResult<()> { 94 + let id_as_str: String = session_id.0.to_string(); 95 + let key = create_prefixed_key(TOWER_SESSION_KEY, id_as_str.as_str()); 96 + 97 + let mut conn = self 98 + .cache_pool 99 + .get() 100 + .await 101 + .map_err(backend_map("There was an error connecting to the cache"))?; 102 + 103 + let _: usize = cmd("DEL") 104 + .arg(&key) 105 + .query_async::<usize>(&mut *conn) 106 + .await 107 + .map_err(backend_map("There was an error deleting the session"))?; 108 + 109 + Ok(()) 110 + } 111 + }
+148
web/src/session.rs
··· 1 + /// A bunch of syntax sugar too make strongly typed sessions for Axum's sessions store 2 + use crate::error_response; 3 + use axum::extract::FromRequestParts; 4 + use axum::http::StatusCode; 5 + use axum::http::request::Parts; 6 + use axum::response::Response; 7 + use serde::{Deserialize, Serialize}; 8 + use std::collections::HashMap; 9 + use std::fmt; 10 + use tower_sessions::Session; 11 + 12 + #[derive(Debug, Deserialize, Serialize, Clone)] 13 + pub enum FlashMessage { 14 + Success(String), 15 + Error(String), 16 + } 17 + 18 + /// THis is the actual session store for axum sessions 19 + #[derive(Debug, Deserialize, Serialize)] 20 + struct SessionData { 21 + did: Option<String>, 22 + 23 + flash_message: HashMap<String, FlashMessage>, 24 + } 25 + 26 + impl Default for SessionData { 27 + fn default() -> Self { 28 + Self { 29 + did: None, 30 + flash_message: HashMap::new(), 31 + } 32 + } 33 + } 34 + 35 + pub struct AxumSessionStore { 36 + session: Session, 37 + data: SessionData, 38 + } 39 + 40 + /// How you actually interact with the session store 41 + impl AxumSessionStore { 42 + const SESSION_DATA_KEY: &'static str = "session.data"; 43 + 44 + pub fn _logged_in(&self) -> bool { 45 + self.data.did.is_some() 46 + } 47 + 48 + pub async fn set_did(&mut self, did: String) -> Result<(), tower_sessions::session::Error> { 49 + self.data.did = Some(did); 50 + Self::update_session(&self.session, &self.data).await 51 + } 52 + 53 + pub fn get_did(&self) -> Option<String> { 54 + self.data.did.clone() 55 + } 56 + 57 + ///Gets the message as well as removes it from the session 58 + pub async fn get_flash_message( 59 + &mut self, 60 + key: &str, 61 + ) -> Result<Option<FlashMessage>, tower_sessions::session::Error> { 62 + let message = self.data.flash_message.get(key).cloned(); 63 + if message.is_some() { 64 + self.data.flash_message.remove(key); 65 + Self::update_session(&self.session, &self.data).await? 66 + } 67 + Ok(message) 68 + } 69 + 70 + pub async fn set_flash_message( 71 + &mut self, 72 + key: &str, 73 + message: FlashMessage, 74 + ) -> Result<(), tower_sessions::session::Error> { 75 + self.data.flash_message.insert(key.to_string(), message); 76 + Self::update_session(&self.session, &self.data).await 77 + } 78 + 79 + /// Make sure to call this or your session won't actually be saved 80 + async fn update_session( 81 + session: &Session, 82 + session_data: &SessionData, 83 + ) -> Result<(), tower_sessions::session::Error> { 84 + session.insert(Self::SESSION_DATA_KEY, session_data).await 85 + } 86 + } 87 + 88 + impl fmt::Display for AxumSessionStore { 89 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 90 + f.debug_struct("SessionStore") 91 + .field("did", &self.data.did) 92 + .finish() 93 + } 94 + } 95 + 96 + impl<S> FromRequestParts<S> for AxumSessionStore 97 + where 98 + S: Send + Sync, 99 + { 100 + type Rejection = (StatusCode, &'static str); 101 + 102 + async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 103 + let session = Session::from_request_parts(req, state).await?; 104 + 105 + let data: SessionData = session 106 + .get(Self::SESSION_DATA_KEY) 107 + .await 108 + .unwrap() 109 + .unwrap_or_default(); 110 + 111 + Ok(Self { session, data }) 112 + } 113 + } 114 + 115 + /// Helper wrapper for handling http responses if theres an error 116 + pub async fn set_flash_message( 117 + session: &mut AxumSessionStore, 118 + key: &str, 119 + flash_message: FlashMessage, 120 + ) -> Result<(), Response> { 121 + session 122 + .set_flash_message(key, flash_message) 123 + .await 124 + .map_err(|err| { 125 + log::error!("Error setting flash message: {err}"); 126 + error_response( 127 + StatusCode::INTERNAL_SERVER_ERROR, 128 + "Error setting flash message", 129 + ) 130 + }) 131 + } 132 + 133 + /// Helper wrapper for handling http responses if theres an error 134 + pub async fn get_flash_message( 135 + session: &mut AxumSessionStore, 136 + key: &str, 137 + ) -> Result<Option<FlashMessage>, Response> { 138 + match session.get_flash_message(key).await { 139 + Ok(message) => Ok(message), 140 + Err(err) => { 141 + log::error!("Error getting flash message: {err}"); 142 + Err(error_response( 143 + StatusCode::INTERNAL_SERVER_ERROR, 144 + "Error getting flash message", 145 + )) 146 + } 147 + } 148 + }
+17
web/src/templates/day.rs
··· 1 + use crate::session::FlashMessage; 2 + use askama::Template; 3 + 4 + #[derive(Template)] 5 + #[template(path = "day.askama.html")] 6 + pub struct DayTemplate { 7 + pub title: String, 8 + pub day: u8, 9 + pub challenge_one_text: String, 10 + pub challenge_one_completed: bool, 11 + pub challenge_two_text: Option<String>, 12 + pub challenge_two_completed: bool, 13 + 14 + //If these are set than it was a redirect from checking the challenge. 15 + pub part_one_submit_message: Option<FlashMessage>, 16 + pub part_two_submit_message: Option<FlashMessage>, 17 + }
+8
web/src/templates/error.rs
··· 1 + use askama::Template; 2 + 3 + #[derive(Template)] 4 + #[template(path = "error.askama.html")] 5 + pub struct ErrorTemplate<'a> { 6 + pub title: &'a str, 7 + pub message: &'a str, 8 + }
+8
web/src/templates/home.rs
··· 1 + use askama::Template; 2 + 3 + #[derive(Template)] 4 + #[template(path = "index.askama.html")] 5 + pub struct HomeTemplate { 6 + pub title: &'static str, 7 + pub unlocked_days: Vec<u8>, 8 + }
+8
web/src/templates/login.rs
··· 1 + use askama::Template; 2 + 3 + #[derive(Template)] 4 + #[template(path = "login.askama.html")] 5 + pub struct LoginTemplate<'a> { 6 + pub title: &'a str, 7 + pub error: Option<String>, 8 + }
+33
web/src/templates/mod.rs
··· 1 + use askama::Template; 2 + use axum::http::StatusCode; 3 + use axum::response::{Html, IntoResponse, Response}; 4 + 5 + pub mod day; 6 + pub mod error; 7 + pub mod login; 8 + pub mod home; 9 + 10 + pub struct HtmlTemplate<T>(pub T); 11 + 12 + /// Allows us to convert Askama HTML templates into valid HTML 13 + /// for axum to serve in the response. 14 + impl<T> IntoResponse for HtmlTemplate<T> 15 + where 16 + T: Template, 17 + { 18 + fn into_response(self) -> Response { 19 + // Attempt to render the template with askama 20 + match self.0.render() { 21 + // If we're able to successfully parse and aggregate the template, serve it 22 + Ok(html) => Html(html).into_response(), 23 + // If we're not, return an error or some bit of fallback HTML 24 + Err(err) => { 25 + log::error!("Failed to render template: {}", err); 26 + IntoResponse::into_response(( 27 + StatusCode::INTERNAL_SERVER_ERROR, 28 + "Failed to render the HTML Template", 29 + )) 30 + } 31 + } 32 + } 33 + }
+54 -25
web/src/unlock.rs
··· 1 - use axum::extract::{Path, Request, State}; 1 + use axum::extract::{Path, Request}; 2 2 use axum::http; 3 3 use axum::{ 4 4 middleware, 5 5 response::{self, IntoResponse}, 6 6 }; 7 - use std::time; 8 - 9 - #[derive(Clone)] 10 - pub struct Unlock { 11 - start: time::Instant, 12 - interval: time::Duration, 13 - } 14 - 15 - impl Unlock { 16 - pub fn new(start: time::Instant, interval: time::Duration) -> Self { 17 - Self { start, interval } 18 - } 19 - } 7 + use chrono::Datelike; 20 8 21 9 pub async fn unlock( 22 - Path(day): Path<u32>, 23 - State(unlocker): State<Unlock>, 10 + Path(mut day): Path<u8>, 24 11 request: Request, 25 12 next: middleware::Next, 26 13 ) -> response::Response { 27 - let deadline = unlocker.start + unlocker.interval * day; 28 - let now = time::Instant::now(); 29 - if now >= deadline { 14 + if day == 0 { 15 + day = 1; 16 + } 17 + 18 + if day == 69 { 19 + return (http::StatusCode::FORBIDDEN, "Really?").into_response(); 20 + } 21 + 22 + if day == 42 { 23 + return ( 24 + http::StatusCode::FORBIDDEN, 25 + "Oh, you have all the answers, huh?", 26 + ) 27 + .into_response(); 28 + } 29 + 30 + if day > 25 || day < 1 { 31 + return ( 32 + http::StatusCode::FORBIDDEN, 33 + "This isn't even a day in the advent calendar????", 34 + ) 35 + .into_response(); 36 + } 37 + 38 + let now = chrono::Utc::now(); 39 + let current_day = now.day(); 40 + let month = now.month(); 41 + 42 + if month != 12 { 43 + return ( 44 + http::StatusCode::FORBIDDEN, 45 + "It's not December yet! NO PEAKING", 46 + ) 47 + .into_response(); 48 + } 49 + 50 + //Show any day previous to the current day and current day 51 + if day as u32 <= current_day { 30 52 return next.run(request).await; 31 53 } 32 - let time_remaining = deadline.saturating_duration_since(now); 33 - let error_response = axum::Json(serde_json::json!({ 34 - "error": "Route Locked", 35 - "time_remaining_seconds": time_remaining.as_secs(), 36 - })); 37 54 38 - (http::StatusCode::FORBIDDEN, error_response).into_response() 55 + ( 56 + http::StatusCode::FORBIDDEN, 57 + "Now just hold on a minute. It ain't time yet.", 58 + ) 59 + .into_response() 60 + 61 + // Just commenting out for now if we do want a json endpoint and i forgot easiest way to return it 62 + // let error_response = axum::Json(serde_json::json!({ 63 + // "error": "Route Locked", 64 + // "time_remaining_seconds": time_remaining.as_secs(), 65 + // })); 66 + 67 + // (http::StatusCode::FORBIDDEN, error_response).into_response() 39 68 }
+55
web/templates/day.askama.html
··· 1 + {% extends "layout.askama.html" %} 2 + 3 + {% block content %} 4 + <h2 class="text-xl">Day {{ day }}</h2> 5 + <p>Part 1:</p> 6 + <article class="prose">{{ challenge_one_text | safe }}</article> 7 + <br/> 8 + {% if let Some(msg) = part_one_submit_message %} 9 + {% match msg %} 10 + {% when FlashMessage::Success with (success) %} 11 + <span class="text-success">{{success}}</span> 12 + {% when FlashMessage::Error with (error) %} 13 + <div class="alert alert-error mb-2">{{error}}</div> 14 + {% endmatch %} 15 + {% endif %} 16 + 17 + {% if !challenge_one_completed %} 18 + <form method="post" action="/day/{{ day }}"> 19 + <!-- TODO will be optional prob load from a markdown variable? --> 20 + <!-- <input type="text" name="verification_code_one" placeholder="Enter Part 1 code" class="input input-bordered mr-2"/>--> 21 + <button class="btn" type="submit">Check answer</button> 22 + </form> 23 + {% else %} 24 + <span class="text-success">Great work, you've completed Part 1</span> 25 + {% endif %} 26 + 27 + 28 + {% if let Some(challenge_two_text) = challenge_two_text %} 29 + <hr class="my-4"/> 30 + <p>Part 2:</p> 31 + <article class="prose">{{ challenge_two_text | safe }}</article> 32 + {% if let Some(msg) = part_two_submit_message %} 33 + {% match msg %} 34 + {% when FlashMessage::Success with (success) %} 35 + <span class="text-success">{{success}}</span> 36 + {% when FlashMessage::Error with (error) %} 37 + <div class="alert alert-error mb-2">{{error}}</div> 38 + {% endmatch %} 39 + {% endif %} 40 + {% if !challenge_two_completed %} 41 + <form method="post" action="/day/{{ day }}"> 42 + <!-- TODO will be optional prob load from a markdown variable? --> 43 + <!-- <input type="text" name="verification_code_two" placeholder="Enter Part 2 code" class="input input-bordered mr-2"/>--> 44 + <button class="btn" type="submit">Check answer</button> 45 + </form> 46 + {% endif %} 47 + 48 + {% endif %} 49 + 50 + {% if challenge_one_completed && challenge_two_completed %} 51 + <br> 52 + <span class="text-success">Great work, you've completed all the challenges for today! Come back tomorrow for more at 00:00 UTC</span> 53 + {% endif %} 54 + 55 + {% endblock %}
+7
web/templates/error.askama.html
··· 1 + {% extends "layout.askama.html" %} 2 + 3 + {% block content %} 4 + <h2 class="text-xl">An error occurred</h2> 5 + <p class="mt-2">{{ message }}</p> 6 + <p class="mt-4"><a class="link" href="?">Return</a></p> 7 + {% endblock %}
+19
web/templates/index.askama.html
··· 1 + {% extends "layout.askama.html" %} 2 + 3 + {% block content %} 4 + <div class="flex items-center justify-between mb-6"> 5 + <h2 class="text-xl font-semibold">Welcome</h2> 6 + <a class="btn" href="/login">Login</a> 7 + </div> 8 + 9 + <p class="mb-3">Unlocked days:</p> 10 + {% if unlocked_days.len() == 0 %} 11 + <div class="alert">No days are unlocked yet. Please check back in December!</div> 12 + {% else %} 13 + <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2"> 14 + {% for d in unlocked_days %} 15 + <a class="btn" href="/day/{{ d }}">Day {{ d }}</a> 16 + {% endfor %} 17 + </div> 18 + {% endif %} 19 + {% endblock %}
+25
web/templates/layout.askama.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>{{ title }}</title> 7 + <link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" /> 8 + <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script> 9 + 10 + <style> 11 + body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji"; margin: 0; color: #111; } 12 + header { background: #0d47a1; color: #fff; padding: 1rem; } 13 + main { padding: 1rem; max-width: 900px; margin: 0 auto; } 14 + 15 + </style> 16 + </head> 17 + <body> 18 + <header> 19 + <h1>at://advent</h1> 20 + </header> 21 + <main> 22 + {% block content %}{% endblock %} 23 + </main> 24 + </body> 25 + </html>
+27
web/templates/login.askama.html
··· 1 + {% extends "layout.askama.html" %} 2 + 3 + {% block content %} 4 + <h2 class="text-xl mb-4">Login</h2> 5 + <p class="mb-4">Enter your Bluesky handle to continue.</p> 6 + {% if let Some(err) = error %} 7 + <div class="alert alert-error mb-4">{{ err }}</div> 8 + {% endif %} 9 + <form id="handle-form" class="flex gap-2" onsubmit="return goToHandle(event)"> 10 + <input id="handle-input" type="text" name="handle" placeholder="you.bsky.social" class="input input-bordered" 11 + required/> 12 + <button class="btn" type="submit">Continue</button> 13 + </form> 14 + <script> 15 + 16 + function goToHandle(e) { 17 + e.preventDefault(); 18 + const input = document.getElementById('handle-input'); 19 + const handle = (input.value || '').trim(); 20 + if (!handle) return false; 21 + const encoded = encodeURIComponent(handle); 22 + // Redirect to /handle/{handle} 23 + window.location.href = `/login/${encoded}`; 24 + return false; 25 + } 26 + </script> 27 + {% endblock %}