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 DB_NAME=advent 4 DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}" 5 DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:5432/${DB_NAME}" 6 - REDIS_URL=redis://127.0.0.1:6379/
··· 3 DB_NAME=advent 4 DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}" 5 DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:5432/${DB_NAME}" 6 + REDIS_URL=redis://127.0.0.1:6379/ 7 + PROD=true
+2
.gitignore
··· 4 at-advent.db 5 at-advent.db-shm 6 at-advent.db-wal
··· 4 at-advent.db 5 at-advent.db-shm 6 at-advent.db-wal 7 + 8 + .DS_Store
+426 -16
Cargo.lock
··· 47 "libc", 48 ] 49 50 [[package]] 51 name = "async-compression" 52 version = "0.4.27" ··· 161 "jose-jwa", 162 "jose-jwk", 163 "p256", 164 - "rand", 165 "reqwest", 166 "serde", 167 "serde_html_form", ··· 285 source = "registry+https://github.com/rust-lang/crates.io-index" 286 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 287 288 [[package]] 289 name = "bb8" 290 version = "0.9.0" ··· 324 "generic-array", 325 ] 326 327 [[package]] 328 name = "bumpalo" 329 version = "3.17.0" ··· 524 checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 525 dependencies = [ 526 "generic-array", 527 - "rand_core", 528 "subtle", 529 "zeroize", 530 ] ··· 539 "typenum", 540 ] 541 542 [[package]] 543 name = "dashmap" 544 version = "6.1.0" ··· 600 "serde", 601 ] 602 603 [[package]] 604 name = "digest" 605 version = "0.10.7" ··· 669 "ff", 670 "generic-array", 671 "group", 672 - "rand_core", 673 "sec1", 674 "subtle", 675 "zeroize", ··· 747 source = "registry+https://github.com/rust-lang/crates.io-index" 748 checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 749 dependencies = [ 750 - "rand_core", 751 "subtle", 752 ] 753 ··· 960 source = "registry+https://github.com/rust-lang/crates.io-index" 961 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 962 963 [[package]] 964 name = "group" 965 version = "0.13.0" ··· 967 checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 968 dependencies = [ 969 "ff", 970 - "rand_core", 971 "subtle", 972 ] 973 974 [[package]] 975 name = "hashbrown" 976 version = "0.14.5" ··· 1025 "idna", 1026 "ipnet", 1027 "once_cell", 1028 - "rand", 1029 "thiserror 1.0.69", 1030 "tinyvec", 1031 "tokio", ··· 1046 "lru-cache", 1047 "once_cell", 1048 "parking_lot", 1049 - "rand", 1050 "resolv-conf", 1051 "smallvec", 1052 "thiserror 1.0.69", ··· 1081 "windows-sys 0.59.0", 1082 ] 1083 1084 [[package]] 1085 name = "http" 1086 version = "1.3.1" ··· 1187 "tracing", 1188 ] 1189 1190 [[package]] 1191 name = "iana-time-zone" 1192 version = "0.1.63" ··· 1297 "zerovec", 1298 ] 1299 1300 [[package]] 1301 name = "idna" 1302 version = "1.0.3" ··· 1538 "linked-hash-map", 1539 ] 1540 1541 [[package]] 1542 name = "matchers" 1543 version = "0.1.0" ··· 1688 "num-integer", 1689 "num-iter", 1690 "num-traits", 1691 - "rand", 1692 "smallvec", 1693 "zeroize", 1694 ] ··· 1719 "num-traits", 1720 ] 1721 1722 [[package]] 1723 name = "num-traits" 1724 version = "0.2.19" ··· 1850 source = "registry+https://github.com/rust-lang/crates.io-index" 1851 checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1852 1853 [[package]] 1854 name = "pin-project-lite" 1855 version = "0.2.16" ··· 1889 source = "registry+https://github.com/rust-lang/crates.io-index" 1890 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 1891 1892 [[package]] 1893 name = "portable-atomic" 1894 version = "1.11.1" ··· 1959 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1960 dependencies = [ 1961 "libc", 1962 - "rand_chacha", 1963 - "rand_core", 1964 ] 1965 1966 [[package]] ··· 1970 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1971 dependencies = [ 1972 "ppv-lite86", 1973 - "rand_core", 1974 ] 1975 1976 [[package]] ··· 1982 "getrandom 0.2.16", 1983 ] 1984 1985 [[package]] 1986 name = "redis" 1987 version = "0.32.4" ··· 2125 "num-traits", 2126 "pkcs1", 2127 "pkcs8", 2128 - "rand_core", 2129 "signature", 2130 "spki", 2131 "subtle", 2132 "zeroize", 2133 ] 2134 2135 [[package]] 2136 name = "rustc-demangle" 2137 version = "0.1.25" 2138 source = "registry+https://github.com/rust-lang/crates.io-index" 2139 checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" 2140 2141 [[package]] 2142 name = "rustc_version" 2143 version = "0.4.1" ··· 2181 source = "registry+https://github.com/rust-lang/crates.io-index" 2182 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 2183 2184 [[package]] 2185 name = "schannel" 2186 version = "0.1.27" ··· 2361 name = "shared" 2362 version = "0.1.0" 2363 dependencies = [ 2364 "atrium-api", 2365 "atrium-common", 2366 "atrium-identity", ··· 2368 "axum", 2369 "bb8", 2370 "bb8-redis", 2371 "hickory-resolver", 2372 "log", 2373 "serde", 2374 "serde_json", 2375 "sqlx", ··· 2398 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2399 dependencies = [ 2400 "digest", 2401 - "rand_core", 2402 ] 2403 2404 [[package]] ··· 2571 "memchr", 2572 "once_cell", 2573 "percent-encoding", 2574 - "rand", 2575 "rsa", 2576 "serde", 2577 "sha1", ··· 2610 "md-5", 2611 "memchr", 2612 "once_cell", 2613 - "rand", 2614 "serde", 2615 "serde_json", 2616 "sha2", ··· 2664 "unicode-properties", 2665 ] 2666 2667 [[package]] 2668 name = "subtle" 2669 version = "2.6.1" ··· 2983 "futures", 2984 "http", 2985 "parking_lot", 2986 - "rand", 2987 "serde", 2988 "serde_json", 2989 "thiserror 2.0.12", ··· 3089 source = "registry+https://github.com/rust-lang/crates.io-index" 3090 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 3091 3092 [[package]] 3093 name = "unicode-bidi" 3094 version = "0.3.18" 3095 source = "registry+https://github.com/rust-lang/crates.io-index" 3096 checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" 3097 3098 [[package]] 3099 name = "unicode-ident" 3100 version = "1.0.18" ··· 3133 "percent-encoding", 3134 ] 3135 3136 [[package]] 3137 name = "utf8_iter" 3138 version = "1.0.4" ··· 3168 source = "registry+https://github.com/rust-lang/crates.io-index" 3169 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 3170 3171 [[package]] 3172 name = "want" 3173 version = "0.3.1" ··· 3273 name = "web" 3274 version = "0.1.0" 3275 dependencies = [ 3276 "atrium-api", 3277 "atrium-common", 3278 "atrium-identity", ··· 3280 "axum", 3281 "bb8", 3282 "bb8-redis", 3283 "dotenv", 3284 "log", 3285 "redis", 3286 "serde", 3287 "serde_json", ··· 3346 source = "registry+https://github.com/rust-lang/crates.io-index" 3347 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 3348 3349 [[package]] 3350 name = "winapi-x86_64-pc-windows-gnu" 3351 version = "0.4.0" ··· 3611 source = "registry+https://github.com/rust-lang/crates.io-index" 3612 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 3613 3614 [[package]] 3615 name = "winreg" 3616 version = "0.50.0"
··· 47 "libc", 48 ] 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 + 92 [[package]] 93 name = "async-compression" 94 version = "0.4.27" ··· 203 "jose-jwa", 204 "jose-jwk", 205 "p256", 206 + "rand 0.8.5", 207 "reqwest", 208 "serde", 209 "serde_html_form", ··· 327 source = "registry+https://github.com/rust-lang/crates.io-index" 328 checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" 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 + 339 [[package]] 340 name = "bb8" 341 version = "0.9.0" ··· 375 "generic-array", 376 ] 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 + 388 [[package]] 389 name = "bumpalo" 390 version = "3.17.0" ··· 585 checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" 586 dependencies = [ 587 "generic-array", 588 + "rand_core 0.6.4", 589 "subtle", 590 "zeroize", 591 ] ··· 600 "typenum", 601 ] 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 + 638 [[package]] 639 name = "dashmap" 640 version = "6.1.0" ··· 696 "serde", 697 ] 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 + 730 [[package]] 731 name = "digest" 732 version = "0.10.7" ··· 796 "ff", 797 "generic-array", 798 "group", 799 + "rand_core 0.6.4", 800 "sec1", 801 "subtle", 802 "zeroize", ··· 874 source = "registry+https://github.com/rust-lang/crates.io-index" 875 checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" 876 dependencies = [ 877 + "rand_core 0.6.4", 878 "subtle", 879 ] 880 ··· 1087 source = "registry+https://github.com/rust-lang/crates.io-index" 1088 checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 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 + 1103 [[package]] 1104 name = "group" 1105 version = "0.13.0" ··· 1107 checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" 1108 dependencies = [ 1109 "ff", 1110 + "rand_core 0.6.4", 1111 "subtle", 1112 ] 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 + 1130 [[package]] 1131 name = "hashbrown" 1132 version = "0.14.5" ··· 1181 "idna", 1182 "ipnet", 1183 "once_cell", 1184 + "rand 0.8.5", 1185 "thiserror 1.0.69", 1186 "tinyvec", 1187 "tokio", ··· 1202 "lru-cache", 1203 "once_cell", 1204 "parking_lot", 1205 + "rand 0.8.5", 1206 "resolv-conf", 1207 "smallvec", 1208 "thiserror 1.0.69", ··· 1237 "windows-sys 0.59.0", 1238 ] 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 + 1249 [[package]] 1250 name = "http" 1251 version = "1.3.1" ··· 1352 "tracing", 1353 ] 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 + 1379 [[package]] 1380 name = "iana-time-zone" 1381 version = "0.1.63" ··· 1486 "zerovec", 1487 ] 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 + 1495 [[package]] 1496 name = "idna" 1497 version = "1.0.3" ··· 1733 "linked-hash-map", 1734 ] 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 + 1745 [[package]] 1746 name = "matchers" 1747 version = "0.1.0" ··· 1892 "num-integer", 1893 "num-iter", 1894 "num-traits", 1895 + "rand 0.8.5", 1896 "smallvec", 1897 "zeroize", 1898 ] ··· 1923 "num-traits", 1924 ] 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 + 1941 [[package]] 1942 name = "num-traits" 1943 version = "0.2.19" ··· 2069 source = "registry+https://github.com/rust-lang/crates.io-index" 2070 checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 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 + 2116 [[package]] 2117 name = "pin-project-lite" 2118 version = "0.2.16" ··· 2152 source = "registry+https://github.com/rust-lang/crates.io-index" 2153 checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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 + 2161 [[package]] 2162 name = "portable-atomic" 2163 version = "1.11.1" ··· 2228 checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 2229 dependencies = [ 2230 "libc", 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", 2243 ] 2244 2245 [[package]] ··· 2249 checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 2250 dependencies = [ 2251 "ppv-lite86", 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", 2263 ] 2264 2265 [[package]] ··· 2271 "getrandom 0.2.16", 2272 ] 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 + 2283 [[package]] 2284 name = "redis" 2285 version = "0.32.4" ··· 2423 "num-traits", 2424 "pkcs1", 2425 "pkcs8", 2426 + "rand_core 0.6.4", 2427 "signature", 2428 "spki", 2429 "subtle", 2430 "zeroize", 2431 ] 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 + 2468 [[package]] 2469 name = "rustc-demangle" 2470 version = "0.1.25" 2471 source = "registry+https://github.com/rust-lang/crates.io-index" 2472 checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" 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 + 2480 [[package]] 2481 name = "rustc_version" 2482 version = "0.4.1" ··· 2520 source = "registry+https://github.com/rust-lang/crates.io-index" 2521 checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 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 + 2532 [[package]] 2533 name = "schannel" 2534 version = "0.1.27" ··· 2709 name = "shared" 2710 version = "0.1.0" 2711 dependencies = [ 2712 + "async-trait", 2713 "atrium-api", 2714 "atrium-common", 2715 "atrium-identity", ··· 2717 "axum", 2718 "bb8", 2719 "bb8-redis", 2720 + "handlebars", 2721 "hickory-resolver", 2722 "log", 2723 + "markdown", 2724 + "rand 0.9.2", 2725 + "rust-embed", 2726 "serde", 2727 "serde_json", 2728 "sqlx", ··· 2751 checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" 2752 dependencies = [ 2753 "digest", 2754 + "rand_core 0.6.4", 2755 ] 2756 2757 [[package]] ··· 2924 "memchr", 2925 "once_cell", 2926 "percent-encoding", 2927 + "rand 0.8.5", 2928 "rsa", 2929 "serde", 2930 "sha1", ··· 2963 "md-5", 2964 "memchr", 2965 "once_cell", 2966 + "rand 0.8.5", 2967 "serde", 2968 "serde_json", 2969 "sha2", ··· 3017 "unicode-properties", 3018 ] 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 + 3026 [[package]] 3027 name = "subtle" 3028 version = "2.6.1" ··· 3342 "futures", 3343 "http", 3344 "parking_lot", 3345 + "rand 0.8.5", 3346 "serde", 3347 "serde_json", 3348 "thiserror 2.0.12", ··· 3448 source = "registry+https://github.com/rust-lang/crates.io-index" 3449 checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" 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 + 3457 [[package]] 3458 name = "unicode-bidi" 3459 version = "0.3.18" 3460 source = "registry+https://github.com/rust-lang/crates.io-index" 3461 checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" 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 + 3469 [[package]] 3470 name = "unicode-ident" 3471 version = "1.0.18" ··· 3504 "percent-encoding", 3505 ] 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 + 3513 [[package]] 3514 name = "utf8_iter" 3515 version = "1.0.4" ··· 3545 source = "registry+https://github.com/rust-lang/crates.io-index" 3546 checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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 + 3558 [[package]] 3559 name = "want" 3560 version = "0.3.1" ··· 3660 name = "web" 3661 version = "0.1.0" 3662 dependencies = [ 3663 + "askama", 3664 + "async-trait", 3665 "atrium-api", 3666 "atrium-common", 3667 "atrium-identity", ··· 3669 "axum", 3670 "bb8", 3671 "bb8-redis", 3672 + "chrono", 3673 "dotenv", 3674 + "hypertext", 3675 "log", 3676 + "pool", 3677 "redis", 3678 "serde", 3679 "serde_json", ··· 3738 source = "registry+https://github.com/rust-lang/crates.io-index" 3739 checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 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 + 3750 [[package]] 3751 name = "winapi-x86_64-pc-windows-gnu" 3752 version = "0.4.0" ··· 4012 source = "registry+https://github.com/rust-lang/crates.io-index" 4013 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 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 + 4024 [[package]] 4025 name = "winreg" 4026 version = "0.50.0"
+5 -1
Cargo.toml
··· 9 atrium-api = "0.25.4" 10 atrium-identity = "0.1.5" 11 atrium-oauth = "0.1.3" 12 hickory-resolver = "0.24.1" 13 dotenv = "0.15.0" 14 log = "0.4.24" ··· 20 tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 21 bb8 = "0.9.0" 22 bb8-redis = "0.24.0" 23 - redis = "0.32.4"
··· 9 atrium-api = "0.25.4" 10 atrium-identity = "0.1.5" 11 atrium-oauth = "0.1.3" 12 + chrono = { version = "0.4", features = ["serde", "now"] } 13 hickory-resolver = "0.24.1" 14 dotenv = "0.15.0" 15 log = "0.4.24" ··· 21 tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } 22 bb8 = "0.9.0" 23 bb8-redis = "0.24.0" 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 services: 2 postgres: 3 image: postgres:latest 4 environment: 5 POSTGRES_USER: ${DB_USER} 6 POSTGRES_PASSWORD: ${DB_PASSWORD} ··· 15 - advent-network 16 redis: 17 image: 'redis:alpine' 18 - restart: always 19 ports: 20 - '${FORWARD_REDIS_PORT:-6379}:6379' 21 volumes:
··· 1 services: 2 postgres: 3 image: postgres:latest 4 + restart: unless-stopped 5 environment: 6 POSTGRES_USER: ${DB_USER} 7 POSTGRES_PASSWORD: ${DB_PASSWORD} ··· 16 - advent-network 17 redis: 18 image: 'redis:alpine' 19 + restart: unless-stopped 20 ports: 21 - '${FORWARD_REDIS_PORT:-6379}:6379' 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 thiserror = "1.0.69" 18 serde_json.workspace = true 19 log.workspace = true
··· 17 thiserror = "1.0.69" 18 serde_json.workspace = true 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 pub mod dns_resolver; 2 pub mod stores;
··· 1 + use atrium_api::types::Unknown; 2 + use serde::de; 3 + 4 pub mod dns_resolver; 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 /// Storage impls to persis OAuth sessions if you are not using the memory stores 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}; 4 use atrium_api::types::string::Did; 5 use atrium_common::store::Store; 6 use atrium_oauth::store::session::SessionStore; ··· 118 119 async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 120 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?; 124 Ok(()) 125 } 126
··· 1 /// Storage impls to persis OAuth sessions if you are not using the memory stores 2 /// https://github.com/bluesky-social/statusphere-example-app/blob/main/src/auth/storage.ts 3 + use crate::cache::{ 4 + ATRIUM_SESSION_STORE_PREFIX, ATRIUM_STATE_STORE_KEY, Cache, create_prefixed_key, 5 + }; 6 use atrium_api::types::string::Did; 7 use atrium_common::store::Store; 8 use atrium_oauth::store::session::SessionStore; ··· 120 121 async fn set(&self, key: K, value: V) -> Result<(), Self::Error> { 122 let cache_key = create_prefixed_key(ATRIUM_STATE_STORE_KEY, key.as_ref()); 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?; 127 Ok(()) 128 } 129
+3 -1
shared/src/cache.rs
··· 11 pub const ATRIUM_SESSION_STORE_PREFIX: &str = "atrium_session:"; 12 pub const ATRIUM_STATE_STORE_KEY: &str = "atrium_state:"; 13 14 pub fn create_prefixed_key(prefix: &str, key: &str) -> String { 15 format!("{}{}", prefix, key) 16 } 17 18 pub struct Cache<'a> { 19 - redis_pool: PooledConnection<'a, RedisConnectionManager>, 20 } 21 22 #[derive(Debug, Error)]
··· 11 pub const ATRIUM_SESSION_STORE_PREFIX: &str = "atrium_session:"; 12 pub const ATRIUM_STATE_STORE_KEY: &str = "atrium_state:"; 13 14 + pub const TOWER_SESSION_KEY: &str = "tower_session:"; 15 + 16 pub fn create_prefixed_key(prefix: &str, key: &str) -> String { 17 format!("{}{}", prefix, key) 18 } 19 20 pub struct Cache<'a> { 21 + pub redis_pool: PooledConnection<'a, RedisConnectionManager>, 22 } 23 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 pub mod atrium; 2 pub mod cache; 3 pub mod db; 4 pub mod models; 5 pub mod web_helpers;
··· 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; 13 pub mod atrium; 14 pub mod cache; 15 pub mod db; 16 pub mod models; 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 use serde::{Deserialize, Serialize}; 2 use sqlx::FromRow; 3 4 #[derive(FromRow, Serialize, Deserialize, Debug, Default)] 5 pub struct TestModel { 6 pub id: i64, 7 pub test: String, 8 }
··· 1 use serde::{Deserialize, Serialize}; 2 use sqlx::FromRow; 3 + use sqlx::types::chrono::{DateTime, Utc}; 4 5 #[derive(FromRow, Serialize, Deserialize, Debug, Default)] 6 pub struct TestModel { 7 pub id: i64, 8 pub test: String, 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 atrium-identity.workspace = true 10 atrium-oauth.workspace = true 11 axum.workspace = true 12 bb8.workspace = true 13 bb8-redis.workspace = true 14 dotenv.workspace = true ··· 17 serde_json.workspace = true 18 shared.workspace = true 19 sqlx.workspace = true 20 - tokio = { version = "1.46.1", features = ["full"] } 21 tower-http = { version = "0.6.6", features = ["trace"] } 22 tower-sessions = "0.14.0" 23 tracing.workspace = true 24 tracing-subscriber.workspace = true 25 - serde = { version = "1.0.219", features = ["derive"] }
··· 9 atrium-identity.workspace = true 10 atrium-oauth.workspace = true 11 axum.workspace = true 12 + chrono.workspace = true 13 bb8.workspace = true 14 bb8-redis.workspace = true 15 dotenv.workspace = true ··· 18 serde_json.workspace = true 19 shared.workspace = true 20 sqlx.workspace = true 21 + tokio.workspace = true 22 tower-http = { version = "0.6.6", features = ["trace"] } 23 tower-sessions = "0.14.0" 24 tracing.workspace = true 25 tracing-subscriber.workspace = true 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}; 5 use atrium_oauth::{ 6 - AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient, 7 - KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope, 8 }; 9 - use axum::extract::{Query, State}; 10 - use axum::http::StatusCode; 11 - use axum::{Json, Router, extract::Path, middleware, routing::get}; 12 use bb8_redis::RedisConnectionManager; 13 use dotenv::dotenv; 14 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; 22 use std::{ 23 env, 24 net::{IpAddr, Ipv4Addr, SocketAddr}, 25 time, 26 }; 27 use time::Duration; 28 use tower_http::trace::TraceLayer; 29 - use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; 30 use tracing_subscriber::EnvFilter; 31 32 extern crate dotenv; 33 34 mod extractors; 35 mod unlock; 36 37 #[derive(Clone)] ··· 39 postgres_pool: PgPool, 40 redis_pool: bb8::Pool<RedisConnectionManager>, 41 oauth_client: OAuthClientType, 42 - //Used to get did to handle leaving cause I figured we'd need it 43 _handle_resolver: HandleResolver, 44 } 45 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 - >; 55 56 - /// HandleResolver type to make it easier to access the resolver in web requests 57 - type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>; 58 59 #[tokio::main] 60 async fn main() -> Result<(), Box<dyn std::error::Error>> { ··· 74 let port = addr.port(); 75 let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 76 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 //sqlx pool 82 let database_url = 83 env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment or .env"); ··· 123 //This must match the endpoint you use the callback function 124 "http://{host}:{port}/oauth/callback" 125 ))]), 126 - scopes: Some(vec![ 127 - Scope::Known(KnownScope::Atproto), 128 - Scope::Known(KnownScope::TransitionGeneric), 129 - ]), 130 }, 131 keys: None, 132 resolver: OAuthResolverConfig { ··· 146 }; 147 let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")); 148 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); 153 154 let app_state = AppState { 155 postgres_pool, ··· 157 oauth_client: client, 158 _handle_resolver: handle_resolver, 159 }; 160 - 161 log::info!("listening on http://{}", addr); 162 let app = Router::new() 163 .route( 164 "/day/{id}", 165 - get(handler).route_layer(middleware::from_fn_with_state( 166 - state.clone(), 167 - unlock::unlock, 168 - )), 169 ) 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 .layer(session_layer) 176 .with_state(app_state) 177 .layer(TraceLayer::new_for_http()); ··· 179 Ok(()) 180 } 181 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"), 251 } 252 } 253 - Err(err) => { 254 - log::error!("Error: {err}"); 255 - err.to_string() 256 } 257 } 258 - } 259 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) 309 }
··· 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 + }; 8 use atrium_oauth::{ 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}, 19 }; 20 use bb8_redis::RedisConnectionManager; 21 + use chrono::Datelike; 22 use dotenv::dotenv; 23 use redis::AsyncCommands; 24 + use shared::{ 25 + HandleResolver, OAuthClientType, atrium::dns_resolver::HickoryDnsTxtResolver, 26 + atrium::stores::AtriumSessionStore, atrium::stores::AtriumStateStore, 27 + }; 28 + use sqlx::{PgPool, postgres::PgPoolOptions}; 29 use std::{ 30 env, 31 net::{IpAddr, Ipv4Addr, SocketAddr}, 32 + sync::Arc, 33 time, 34 }; 35 use time::Duration; 36 use tower_http::trace::TraceLayer; 37 + use tower_sessions::{SessionManagerLayer, cookie::SameSite}; 38 use tracing_subscriber::EnvFilter; 39 40 + mod handlers; 41 + 42 extern crate dotenv; 43 44 mod extractors; 45 + mod redis_session_store; 46 + mod session; 47 + mod templates; 48 mod unlock; 49 50 #[derive(Clone)] ··· 52 postgres_pool: PgPool, 53 redis_pool: bb8::Pool<RedisConnectionManager>, 54 oauth_client: OAuthClientType, 55 + //Used to get did to handle leaving because I figured we'd need it 56 _handle_resolver: HandleResolver, 57 } 58 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 + } 66 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 + } 76 77 #[tokio::main] 78 async fn main() -> Result<(), Box<dyn std::error::Error>> { ··· 92 let port = addr.port(); 93 let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); 94 95 //sqlx pool 96 let database_url = 97 env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment or .env"); ··· 137 //This must match the endpoint you use the callback function 138 "http://{host}:{port}/oauth/callback" 139 ))]), 140 + scopes: Some(oauth_scopes()), 141 }, 142 keys: None, 143 resolver: OAuthResolverConfig { ··· 157 }; 158 let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client")); 159 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); 165 166 let app_state = AppState { 167 postgres_pool, ··· 169 oauth_client: client, 170 _handle_resolver: handle_resolver, 171 }; 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); 176 log::info!("listening on http://{}", addr); 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 + ) 187 .route( 188 "/day/{id}", 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), 201 ) 202 .layer(session_layer) 203 .with_state(app_state) 204 .layer(TraceLayer::new_for_http()); ··· 206 Ok(()) 207 } 208 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); 224 } 225 } 226 + } else { 227 + for d in 1..=25 { 228 + unlocked.push(d as u8); 229 } 230 } 231 232 + HtmlTemplate(HomeTemplate { 233 + title: "at://advent", 234 + unlocked_days: unlocked, 235 + }) 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}; 2 use axum::http; 3 use axum::{ 4 middleware, 5 response::{self, IntoResponse}, 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 - } 20 21 pub async fn unlock( 22 - Path(day): Path<u32>, 23 - State(unlocker): State<Unlock>, 24 request: Request, 25 next: middleware::Next, 26 ) -> response::Response { 27 - let deadline = unlocker.start + unlocker.interval * day; 28 - let now = time::Instant::now(); 29 - if now >= deadline { 30 return next.run(request).await; 31 } 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 38 - (http::StatusCode::FORBIDDEN, error_response).into_response() 39 }
··· 1 + use axum::extract::{Path, Request}; 2 use axum::http; 3 use axum::{ 4 middleware, 5 response::{self, IntoResponse}, 6 }; 7 + use chrono::Datelike; 8 9 pub async fn unlock( 10 + Path(mut day): Path<u8>, 11 request: Request, 12 next: middleware::Next, 13 ) -> response::Response { 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 { 52 return next.run(request).await; 53 } 54 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() 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 %}