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
···
DB_NAME=advent
DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@localhost:5432/${DB_NAME}"
DOCKER_DB_URL="postgresql://${DB_USER}:${DB_PASSWORD}@host.docker.internal:5432/${DB_NAME}"
-
REDIS_URL=redis://127.0.0.1:6379/
+
REDIS_URL=redis://127.0.0.1:6379/
+
PROD=true
+2
.gitignore
···
at-advent.db
at-advent.db-shm
at-advent.db-wal
+
+
.DS_Store
+426 -16
Cargo.lock
···
"libc",
]
+
[[package]]
+
name = "askama"
+
version = "0.14.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
+
dependencies = [
+
"askama_derive",
+
"itoa",
+
"percent-encoding",
+
"serde",
+
"serde_json",
+
]
+
+
[[package]]
+
name = "askama_derive"
+
version = "0.14.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
+
dependencies = [
+
"askama_parser",
+
"basic-toml",
+
"memchr",
+
"proc-macro2",
+
"quote",
+
"rustc-hash",
+
"serde",
+
"serde_derive",
+
"syn",
+
]
+
+
[[package]]
+
name = "askama_parser"
+
version = "0.14.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
+
dependencies = [
+
"memchr",
+
"serde",
+
"serde_derive",
+
"winnow",
+
]
+
[[package]]
name = "async-compression"
version = "0.4.27"
···
"jose-jwa",
"jose-jwk",
"p256",
-
"rand",
+
"rand 0.8.5",
"reqwest",
"serde",
"serde_html_form",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
+
[[package]]
+
name = "basic-toml"
+
version = "0.1.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+
dependencies = [
+
"serde",
+
]
+
[[package]]
name = "bb8"
version = "0.9.0"
···
"generic-array",
]
+
[[package]]
+
name = "bstr"
+
version = "1.12.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
+
dependencies = [
+
"memchr",
+
"serde",
+
]
+
[[package]]
name = "bumpalo"
version = "3.17.0"
···
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
-
"rand_core",
+
"rand_core 0.6.4",
"subtle",
"zeroize",
]
···
"typenum",
]
+
[[package]]
+
name = "darling"
+
version = "0.20.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+
dependencies = [
+
"darling_core",
+
"darling_macro",
+
]
+
+
[[package]]
+
name = "darling_core"
+
version = "0.20.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+
dependencies = [
+
"fnv",
+
"ident_case",
+
"proc-macro2",
+
"quote",
+
"strsim",
+
"syn",
+
]
+
+
[[package]]
+
name = "darling_macro"
+
version = "0.20.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+
dependencies = [
+
"darling_core",
+
"quote",
+
"syn",
+
]
+
[[package]]
name = "dashmap"
version = "6.1.0"
···
"serde",
]
+
[[package]]
+
name = "derive_builder"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+
dependencies = [
+
"derive_builder_macro",
+
]
+
+
[[package]]
+
name = "derive_builder_core"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+
dependencies = [
+
"darling",
+
"proc-macro2",
+
"quote",
+
"syn",
+
]
+
+
[[package]]
+
name = "derive_builder_macro"
+
version = "0.20.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+
dependencies = [
+
"derive_builder_core",
+
"syn",
+
]
+
[[package]]
name = "digest"
version = "0.10.7"
···
"ff",
"generic-array",
"group",
-
"rand_core",
+
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
-
"rand_core",
+
"rand_core 0.6.4",
"subtle",
]
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+
[[package]]
+
name = "globset"
+
version = "0.4.16"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
+
dependencies = [
+
"aho-corasick",
+
"bstr",
+
"log",
+
"regex-automata 0.4.9",
+
"regex-syntax 0.8.5",
+
]
+
[[package]]
name = "group"
version = "0.13.0"
···
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
-
"rand_core",
+
"rand_core 0.6.4",
"subtle",
]
+
[[package]]
+
name = "handlebars"
+
version = "6.3.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098"
+
dependencies = [
+
"derive_builder",
+
"log",
+
"num-order",
+
"pest",
+
"pest_derive",
+
"serde",
+
"serde_json",
+
"thiserror 2.0.12",
+
]
+
[[package]]
name = "hashbrown"
version = "0.14.5"
···
"idna",
"ipnet",
"once_cell",
-
"rand",
+
"rand 0.8.5",
"thiserror 1.0.69",
"tinyvec",
"tokio",
···
"lru-cache",
"once_cell",
"parking_lot",
-
"rand",
+
"rand 0.8.5",
"resolv-conf",
"smallvec",
"thiserror 1.0.69",
···
"windows-sys 0.59.0",
+
[[package]]
+
name = "html-escape"
+
version = "0.2.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
+
dependencies = [
+
"utf8-width",
+
]
+
[[package]]
name = "http"
version = "1.3.1"
···
"tracing",
+
[[package]]
+
name = "hypertext"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "eb73b82c6a76434fd87a0668ef3ff1a8182512dfb610eef9138169a7e2d3a0ed"
+
dependencies = [
+
"html-escape",
+
"hypertext-macros",
+
"itoa",
+
"ryu",
+
]
+
+
[[package]]
+
name = "hypertext-macros"
+
version = "0.12.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c120534b9d41bd317a5b111aacc38a34071d15df9462c0e21f6093ade3a03660"
+
dependencies = [
+
"html-escape",
+
"proc-macro2",
+
"quote",
+
"syn",
+
]
+
[[package]]
name = "iana-time-zone"
version = "0.1.63"
···
"zerovec",
+
[[package]]
+
name = "ident_case"
+
version = "1.0.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
[[package]]
name = "idna"
version = "1.0.3"
···
"linked-hash-map",
+
[[package]]
+
name = "markdown"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb"
+
dependencies = [
+
"unicode-id",
+
]
+
[[package]]
name = "matchers"
version = "0.1.0"
···
"num-integer",
"num-iter",
"num-traits",
-
"rand",
+
"rand 0.8.5",
"smallvec",
"zeroize",
···
"num-traits",
+
[[package]]
+
name = "num-modular"
+
version = "0.6.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f"
+
+
[[package]]
+
name = "num-order"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6"
+
dependencies = [
+
"num-modular",
+
]
+
[[package]]
name = "num-traits"
version = "0.2.19"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
[[package]]
+
name = "pest"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323"
+
dependencies = [
+
"memchr",
+
"thiserror 2.0.12",
+
"ucd-trie",
+
]
+
+
[[package]]
+
name = "pest_derive"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc"
+
dependencies = [
+
"pest",
+
"pest_generator",
+
]
+
+
[[package]]
+
name = "pest_generator"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966"
+
dependencies = [
+
"pest",
+
"pest_meta",
+
"proc-macro2",
+
"quote",
+
"syn",
+
]
+
+
[[package]]
+
name = "pest_meta"
+
version = "2.8.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5"
+
dependencies = [
+
"pest",
+
"sha2",
+
]
+
[[package]]
name = "pin-project-lite"
version = "0.2.16"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
[[package]]
+
name = "pool"
+
version = "0.1.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c7ac1531a0016945992b4e816e81538dfad0b9f00d280bcb707d711839f1536d"
+
[[package]]
name = "portable-atomic"
version = "1.11.1"
···
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
-
"rand_chacha",
-
"rand_core",
+
"rand_chacha 0.3.1",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand"
+
version = "0.9.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
+
dependencies = [
+
"rand_chacha 0.9.0",
+
"rand_core 0.9.3",
[[package]]
···
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
-
"rand_core",
+
"rand_core 0.6.4",
+
]
+
+
[[package]]
+
name = "rand_chacha"
+
version = "0.9.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+
dependencies = [
+
"ppv-lite86",
+
"rand_core 0.9.3",
[[package]]
···
"getrandom 0.2.16",
+
[[package]]
+
name = "rand_core"
+
version = "0.9.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+
dependencies = [
+
"getrandom 0.3.3",
+
]
+
[[package]]
name = "redis"
version = "0.32.4"
···
"num-traits",
"pkcs1",
"pkcs8",
-
"rand_core",
+
"rand_core 0.6.4",
"signature",
"spki",
"subtle",
"zeroize",
+
[[package]]
+
name = "rust-embed"
+
version = "8.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a"
+
dependencies = [
+
"rust-embed-impl",
+
"rust-embed-utils",
+
"walkdir",
+
]
+
+
[[package]]
+
name = "rust-embed-impl"
+
version = "8.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c"
+
dependencies = [
+
"proc-macro2",
+
"quote",
+
"rust-embed-utils",
+
"syn",
+
"walkdir",
+
]
+
+
[[package]]
+
name = "rust-embed-utils"
+
version = "8.7.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594"
+
dependencies = [
+
"globset",
+
"sha2",
+
"walkdir",
+
]
+
[[package]]
name = "rustc-demangle"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
+
[[package]]
+
name = "rustc-hash"
+
version = "2.1.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
[[package]]
name = "rustc_version"
version = "0.4.1"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
[[package]]
+
name = "same-file"
+
version = "1.0.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+
dependencies = [
+
"winapi-util",
+
]
+
[[package]]
name = "schannel"
version = "0.1.27"
···
name = "shared"
version = "0.1.0"
dependencies = [
+
"async-trait",
"atrium-api",
"atrium-common",
"atrium-identity",
···
"axum",
"bb8",
"bb8-redis",
+
"handlebars",
"hickory-resolver",
"log",
+
"markdown",
+
"rand 0.9.2",
+
"rust-embed",
"serde",
"serde_json",
"sqlx",
···
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest",
-
"rand_core",
+
"rand_core 0.6.4",
[[package]]
···
"memchr",
"once_cell",
"percent-encoding",
-
"rand",
+
"rand 0.8.5",
"rsa",
"serde",
"sha1",
···
"md-5",
"memchr",
"once_cell",
-
"rand",
+
"rand 0.8.5",
"serde",
"serde_json",
"sha2",
···
"unicode-properties",
+
[[package]]
+
name = "strsim"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
[[package]]
name = "subtle"
version = "2.6.1"
···
"futures",
"http",
"parking_lot",
-
"rand",
+
"rand 0.8.5",
"serde",
"serde_json",
"thiserror 2.0.12",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
+
[[package]]
+
name = "ucd-trie"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
[[package]]
+
name = "unicode-id"
+
version = "0.3.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561"
+
[[package]]
name = "unicode-ident"
version = "1.0.18"
···
"percent-encoding",
+
[[package]]
+
name = "utf8-width"
+
version = "0.1.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
+
[[package]]
name = "utf8_iter"
version = "1.0.4"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
[[package]]
+
name = "walkdir"
+
version = "2.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+
dependencies = [
+
"same-file",
+
"winapi-util",
+
]
+
[[package]]
name = "want"
version = "0.3.1"
···
name = "web"
version = "0.1.0"
dependencies = [
+
"askama",
+
"async-trait",
"atrium-api",
"atrium-common",
"atrium-identity",
···
"axum",
"bb8",
"bb8-redis",
+
"chrono",
"dotenv",
+
"hypertext",
"log",
+
"pool",
"redis",
"serde",
"serde_json",
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
[[package]]
+
name = "winapi-util"
+
version = "0.1.10"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22"
+
dependencies = [
+
"windows-sys 0.59.0",
+
]
+
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
···
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
[[package]]
+
name = "winnow"
+
version = "0.7.13"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
+
dependencies = [
+
"memchr",
+
]
+
[[package]]
name = "winreg"
version = "0.50.0"
+5 -1
Cargo.toml
···
atrium-api = "0.25.4"
atrium-identity = "0.1.5"
atrium-oauth = "0.1.3"
+
chrono = { version = "0.4", features = ["serde", "now"] }
hickory-resolver = "0.24.1"
dotenv = "0.15.0"
log = "0.4.24"
···
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
bb8 = "0.9.0"
bb8-redis = "0.24.0"
-
redis = "0.32.4"
+
redis = "0.32.4"
+
tokio = { version = "1.46.1", features = ["full"] }
+
markdown = "1.0.0"
+
rust-embed = { version = "8.7.2", features = ["include-exclude"] }
+2 -1
compose.dev.yml
···
services:
postgres:
image: postgres:latest
+
restart: unless-stopped
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
···
- advent-network
redis:
image: 'redis:alpine'
-
restart: always
+
restart: unless-stopped
ports:
- '${FORWARD_REDIS_PORT:-6379}:6379'
volumes:
+17
migrations/20250904073900_create_challenges.sql
···
+
-- Advent challenges table
+
CREATE TABLE IF NOT EXISTS challenges (
+
id BIGSERIAL PRIMARY KEY,
+
user_did TEXT NOT NULL,
+
day INT NOT NULL,
+
time_started TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
time_challenge_one_completed TIMESTAMPTZ NULL,
+
time_challenge_two_completed TIMESTAMPTZ NULL,
+
verification_code_one TEXT NULL,
+
verification_code_two TEXT NULL,
+
CONSTRAINT challenges_user_day_unique UNIQUE(user_did, day),
+
CONSTRAINT challenges_day_range CHECK (day >= 1 AND day <= 25)
+
);
+
+
-- Indexes to speed up common lookups
+
CREATE INDEX IF NOT EXISTS idx_challenges_user_did ON challenges(user_did);
+
CREATE INDEX IF NOT EXISTS idx_challenges_day ON challenges(day);
+5
shared/Cargo.toml
···
thiserror = "1.0.69"
serde_json.workspace = true
log.workspace = true
+
rust-embed.workspace = true
+
markdown.workspace = true
+
rand = "0.9.2"
+
handlebars = { version = "6.3.2" }
+
async-trait = "0.1.88"
+15
shared/challenges_markdown/one/part_one.md
···
+
Hey! Welcome to at://advent! A 25 day challenge to learn atproto with a new set of challenges every day.
+
+
(Pretend this is going into more details explaining everything)
+
+
Starting out simple, create a record at the collection `codes.advent.challenge.day`
+
with the record key `1` and put this as the record.
+
+
```json
+
{
+
"$type": "codes.advent.challenge.day",
+
"partOne": "{{code}}"
+
}
+
```
+
+
[//]: # (<input type="file" id="part_one_input" placeholder="Enter your code here" />)
+7
shared/challenges_markdown/one/part_two.md
···
+
Great job beating Part 1! Now onto Part 2.
+
+
Keeping it simple proof of concept, blah, blah will have a real one here another time. Add a new field `partTwo` to the
+
record with the value `{{code}}`
+
+
+
[//]: # (<input type="file" id="part_one_input" placeholder="Enter your code here" />)
+28
shared/lexicons/codes/advent/day.json
···
+
{
+
"lexicon": 1,
+
"id": "codes.advent.challenge.day",
+
"defs": {
+
"main": {
+
"type": "record",
+
"key": "any",
+
"record": {
+
"type": "object",
+
"required": [
+
"partOne"
+
],
+
"properties": {
+
"partOne": {
+
"type": "string"
+
},
+
"partTwo": {
+
"type": "string"
+
},
+
"createdAt": {
+
"type": "string",
+
"format": "datetime"
+
}
+
}
+
}
+
}
+
}
+
}
+201
shared/src/advent/challenges/day_one.rs
···
+
use crate::OAuthAgentType;
+
use crate::advent::day::Day;
+
use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse};
+
use crate::atrium::safe_check_unknown_record_parse;
+
use async_trait::async_trait;
+
use atrium_api::types::Collection;
+
use sqlx::PgPool;
+
+
pub struct DayOne {
+
pub pool: PgPool,
+
pub oauth_client: Option<OAuthAgentType>,
+
}
+
+
#[async_trait]
+
impl AdventChallenge for DayOne {
+
fn pool(&self) -> &PgPool {
+
&self.pool
+
}
+
+
fn day(&self) -> Day {
+
Day::One
+
}
+
+
fn has_part_two(&self) -> bool {
+
true
+
}
+
+
async fn check_part_one(
+
&self,
+
did: String,
+
_verification_code: Option<String>,
+
) -> Result<ChallengeCheckResponse, AdventError> {
+
match &self.oauth_client {
+
None => Err(AdventError::ShouldNotHappen(
+
"No oauth client. This should not happen".to_string(),
+
)),
+
Some(client) => {
+
match client
+
.api
+
.com
+
.atproto
+
.repo
+
.get_record(
+
atrium_api::com::atproto::repo::get_record::ParametersData {
+
cid: None,
+
collection: crate::lexicons::codes::advent::challenge::Day::NSID
+
.parse()
+
.unwrap(),
+
repo: did.parse().unwrap(),
+
rkey: "1".parse().unwrap(),
+
}
+
.into(),
+
)
+
.await
+
{
+
Ok(record) => {
+
//TODO trouble, and make it double
+
let challenge = self.get_days_challenge(did.clone()).await?;
+
+
match challenge {
+
None => {
+
log::error!(
+
"Could not find a challenge record for day: {} for the user: {}",
+
self.day(),
+
did.clone()
+
);
+
Err(AdventError::ShouldNotHappen(
+
"Could not find a challenge record".to_string(),
+
))
+
}
+
Some(challenge) => {
+
let parse_record_result =
+
safe_check_unknown_record_parse::<
+
crate::lexicons::codes::advent::challenge::day::RecordData,
+
>(record.value.clone());
+
+
match parse_record_result {
+
Ok(record_data) => {
+
match record_data.part_one
+
== challenge
+
.verification_code_one
+
.unwrap_or("".to_string())
+
{
+
true => Ok(ChallengeCheckResponse::Correct),
+
false => {
+
Ok(ChallengeCheckResponse::Incorrect(format!(
+
"The code {} is incorrect",
+
record_data.part_one
+
)))
+
}
+
}
+
}
+
Err(err) => {
+
log::error!("Error parsing record: {}", err);
+
Ok(ChallengeCheckResponse::Incorrect(format!(
+
"There is a record at the correct location, but it does not seem like it is correct. Try again:\n{err}"
+
)))
+
}
+
}
+
}
+
}
+
}
+
Err(err) => {
+
log::error!("Error getting record: {}", err);
+
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()))
+
}
+
}
+
}
+
}
+
}
+
+
///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
+
async fn check_part_two(
+
&self,
+
did: String,
+
_verification_code: Option<String>,
+
) -> Result<ChallengeCheckResponse, AdventError> {
+
match &self.oauth_client {
+
None => Err(AdventError::ShouldNotHappen(
+
"No oauth client. This should not happen".to_string(),
+
)),
+
Some(client) => {
+
match client
+
.api
+
.com
+
.atproto
+
.repo
+
.get_record(
+
atrium_api::com::atproto::repo::get_record::ParametersData {
+
cid: None,
+
collection: crate::lexicons::codes::advent::challenge::Day::NSID
+
.parse()
+
.unwrap(),
+
repo: did.parse().unwrap(),
+
rkey: "1".parse().unwrap(),
+
}
+
.into(),
+
)
+
.await
+
{
+
Ok(record) => {
+
//TODO trouble, and make it double
+
let challenge = self.get_days_challenge(did.clone()).await?;
+
+
match challenge {
+
None => {
+
log::error!(
+
"Could not find a challenge record for day: {} for the user: {}",
+
self.day(),
+
did.clone()
+
);
+
Err(AdventError::ShouldNotHappen(
+
"Could not find a challenge record".to_string(),
+
))
+
}
+
Some(challenge) => {
+
let parse_record_result =
+
safe_check_unknown_record_parse::<
+
crate::lexicons::codes::advent::challenge::day::RecordData,
+
>(record.value.clone());
+
+
match parse_record_result {
+
Ok(record_data) => match record_data.part_two {
+
None => {
+
Ok(ChallengeCheckResponse::Incorrect("The record is there, it's the right kind. But aren't you forgetting something?".to_string()))
+
}
+
Some(part_two_code) => {
+
match part_two_code
+
== challenge
+
.verification_code_two
+
.unwrap_or("".to_string())
+
{
+
true => Ok(ChallengeCheckResponse::Correct),
+
false => {
+
Ok(ChallengeCheckResponse::Incorrect(format!(
+
"The code {} is incorrect",
+
record_data.part_one
+
)))
+
}
+
}
+
}
+
},
+
Err(err) => {
+
log::error!("Error parsing record: {}", err);
+
Ok(ChallengeCheckResponse::Incorrect(format!(
+
"There is a record at the correct location, but it does not seem like it is correct. Try again:\n{err}"
+
)))
+
}
+
}
+
}
+
}
+
}
+
Err(err) => {
+
log::error!("Error getting record: {}", err);
+
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()))
+
}
+
}
+
}
+
}
+
}
+
}
+33
shared/src/advent/challenges/day_two.rs
···
+
use crate::OAuthAgentType;
+
use crate::advent::day::Day;
+
use crate::advent::{AdventChallenge, AdventError, ChallengeCheckResponse};
+
use async_trait::async_trait;
+
use sqlx::PgPool;
+
+
pub struct DayTwo {
+
pub pool: PgPool,
+
pub oauth_client: Option<OAuthAgentType>,
+
}
+
+
#[async_trait]
+
impl AdventChallenge for DayTwo {
+
fn pool(&self) -> &PgPool {
+
&self.pool
+
}
+
+
fn day(&self) -> Day {
+
Day::Two
+
}
+
+
fn has_part_two(&self) -> bool {
+
false
+
}
+
+
async fn check_part_one(
+
&self,
+
_did: String,
+
_verification_code: Option<String>,
+
) -> Result<ChallengeCheckResponse, AdventError> {
+
todo!()
+
}
+
}
+2
shared/src/advent/challenges/mod.rs
···
+
pub mod day_one;
+
pub mod day_two;
+172
shared/src/advent/day.rs
···
+
/// 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.
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+
pub enum Day {
+
One = 1,
+
Two = 2,
+
Three = 3,
+
Four = 4,
+
Five = 5,
+
Six = 6,
+
Seven = 7,
+
Eight = 8,
+
Nine = 9,
+
Ten = 10,
+
Eleven = 11,
+
Twelve = 12,
+
Thirteen = 13,
+
Fourteen = 14,
+
Fifteen = 15,
+
Sixteen = 16,
+
Seventeen = 17,
+
Eighteen = 18,
+
Nineteen = 19,
+
Twenty = 20,
+
TwentyOne = 21,
+
TwentyTwo = 22,
+
TwentyThree = 23,
+
TwentyFour = 24,
+
TwentyFive = 25,
+
}
+
+
impl From<Day> for u8 {
+
fn from(day: Day) -> Self {
+
match day {
+
Day::One => 1,
+
Day::Two => 2,
+
Day::Three => 3,
+
Day::Four => 4,
+
Day::Five => 5,
+
Day::Six => 6,
+
Day::Seven => 7,
+
Day::Eight => 8,
+
Day::Nine => 9,
+
Day::Ten => 10,
+
Day::Eleven => 11,
+
Day::Twelve => 12,
+
Day::Thirteen => 13,
+
Day::Fourteen => 14,
+
Day::Fifteen => 15,
+
Day::Sixteen => 16,
+
Day::Seventeen => 17,
+
Day::Eighteen => 18,
+
Day::Nineteen => 19,
+
Day::Twenty => 20,
+
Day::TwentyOne => 21,
+
Day::TwentyTwo => 22,
+
Day::TwentyThree => 23,
+
Day::TwentyFour => 24,
+
Day::TwentyFive => 25,
+
}
+
}
+
}
+
+
impl core::fmt::Display for Day {
+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
+
f.write_str(match self {
+
Day::One => "One",
+
Day::Two => "Two",
+
Day::Three => "Three",
+
Day::Four => "Four",
+
Day::Five => "Five",
+
Day::Six => "Six",
+
Day::Seven => "Seven",
+
Day::Eight => "Eight",
+
Day::Nine => "Nine",
+
Day::Ten => "Ten",
+
Day::Eleven => "Eleven",
+
Day::Twelve => "Twelve",
+
Day::Thirteen => "Thirteen",
+
Day::Fourteen => "Fourteen",
+
Day::Fifteen => "Fifteen",
+
Day::Sixteen => "Sixteen",
+
Day::Seventeen => "Seventeen",
+
Day::Eighteen => "Eighteen",
+
Day::Nineteen => "Nineteen",
+
Day::Twenty => "Twenty",
+
Day::TwentyOne => "TwentyOne",
+
Day::TwentyTwo => "TwentyTwo",
+
Day::TwentyThree => "TwentyThree",
+
Day::TwentyFour => "TwentyFour",
+
Day::TwentyFive => "TwentyFive",
+
})
+
}
+
}
+
+
impl From<Day> for String {
+
fn from(day: Day) -> Self {
+
day.to_string()
+
}
+
}
+
+
impl core::convert::From<u8> for Day {
+
fn from(value: u8) -> Self {
+
match value {
+
1 => Day::One,
+
2 => Day::Two,
+
3 => Day::Three,
+
4 => Day::Four,
+
5 => Day::Five,
+
6 => Day::Six,
+
7 => Day::Seven,
+
8 => Day::Eight,
+
9 => Day::Nine,
+
10 => Day::Ten,
+
11 => Day::Eleven,
+
12 => Day::Twelve,
+
13 => Day::Thirteen,
+
14 => Day::Fourteen,
+
15 => Day::Fifteen,
+
16 => Day::Sixteen,
+
17 => Day::Seventeen,
+
18 => Day::Eighteen,
+
19 => Day::Nineteen,
+
20 => Day::Twenty,
+
21 => Day::TwentyOne,
+
22 => Day::TwentyTwo,
+
23 => Day::TwentyThree,
+
24 => Day::TwentyFour,
+
25 => Day::TwentyFive,
+
_ => panic!("day out of range (1..=25)"),
+
}
+
}
+
}
+
+
impl core::convert::From<&str> for Day {
+
fn from(s: &str) -> Self {
+
match s {
+
"One" => Day::One,
+
"Two" => Day::Two,
+
"Three" => Day::Three,
+
"Four" => Day::Four,
+
"Five" => Day::Five,
+
"Six" => Day::Six,
+
"Seven" => Day::Seven,
+
"Eight" => Day::Eight,
+
"Nine" => Day::Nine,
+
"Ten" => Day::Ten,
+
"Eleven" => Day::Eleven,
+
"Twelve" => Day::Twelve,
+
"Thirteen" => Day::Thirteen,
+
"Fourteen" => Day::Fourteen,
+
"Fifteen" => Day::Fifteen,
+
"Sixteen" => Day::Sixteen,
+
"Seventeen" => Day::Seventeen,
+
"Eighteen" => Day::Eighteen,
+
"Nineteen" => Day::Nineteen,
+
"Twenty" => Day::Twenty,
+
"TwentyOne" => Day::TwentyOne,
+
"TwentyTwo" => Day::TwentyTwo,
+
"TwentyThree" => Day::TwentyThree,
+
"TwentyFour" => Day::TwentyFour,
+
"TwentyFive" => Day::TwentyFive,
+
_ => panic!("unknown day string"),
+
}
+
}
+
}
+
+
impl core::convert::From<String> for Day {
+
fn from(value: String) -> Self {
+
Day::from(value.as_str())
+
}
+
}
+
+327
shared/src/advent/mod.rs
···
+
pub mod challenges;
+
pub mod day;
+
+
use crate::advent::day::Day;
+
use crate::assets::ChallengesMarkdown;
+
use crate::models::db_models::ChallengeProgress;
+
use async_trait::async_trait;
+
use handlebars::{Handlebars, RenderError};
+
use markdown::{CompileOptions, Options};
+
use rand::distr::{Alphanumeric, SampleString};
+
use rust_embed::EmbeddedFile;
+
use serde_json::json;
+
use sqlx::PgPool;
+
use std::str::Utf8Error;
+
use thiserror::Error;
+
+
#[derive(Debug, Error)]
+
pub enum AdventError {
+
#[error("Database error: {0}")]
+
Database(#[from] sqlx::Error),
+
#[error("Io error: {0}")]
+
Io(#[from] std::io::Error),
+
#[error("Invalid day: {0}. Day must be between 1 and 25")]
+
InvalidDay(i32),
+
#[error("This challenge only has a single challenge")]
+
NoPartTwo,
+
#[error("UTF-8 error: {0}")]
+
Utf8Error(#[from] Utf8Error),
+
#[error("Render error: {0}")]
+
RenderError(#[from] RenderError),
+
#[error("This was not designed to happen: {0}")]
+
ShouldNotHappen(String),
+
}
+
+
pub enum AdventAction {
+
//If Part one is done it shows both, if not it only shows part one
+
// the Option<String> here is the did of the user. If none only show part one no matter what
+
ViewChallenge(Option<String>),
+
+
//The strings here are the users did's
+
StartPartOne(String),
+
StartPartTwo(String),
+
+
//TODO should I have like SubmitPartOne(String) and it's the code?
+
//These actions will be locked behind logged in
+
SubmitPartOne,
+
SubmitPartTwo,
+
}
+
+
pub enum AdventPart {
+
One,
+
Two,
+
}
+
+
pub enum CompletionStatus {
+
///None of the day's challenges have been completed
+
None,
+
///PartOne of the day's challenges has been completed
+
PartOne,
+
///PartTwo of the day's challenges has been completed
+
//i dont think this was needed
+
// PartTwo,
+
///Both of the day's challenges have been completed
+
Both,
+
}
+
+
pub enum ChallengeCheckResponse {
+
Correct,
+
///Error message on why it was incorrect
+
Incorrect(String),
+
}
+
+
pub enum AdventActionResult {
+
ShowPartOne,
+
// If partone is completed, this will be shown
+
ShowPartTwo,
+
+
CorrectSubmission(AdventPart),
+
IncorrectSubmission,
+
+
Completed,
+
+
Error(String),
+
}
+
+
#[async_trait]
+
pub trait AdventChallenge {
+
/// The db pool in case the challenge needs extra access
+
fn pool(&self) -> &PgPool;
+
+
/// The day of the challenge 1-25
+
fn day(&self) -> Day;
+
+
fn get_day_markdown_file(&self, part: AdventPart) -> Result<Option<EmbeddedFile>, AdventError> {
+
let day = self.day().to_string().to_ascii_lowercase();
+
let path = match part {
+
AdventPart::One => format!("{day}/part_one.md"),
+
AdventPart::Two => format!("{day}/part_two.md"),
+
};
+
match ChallengesMarkdown::get(path.as_str()) {
+
None => {
+
log::error!("Missing the part one challenge file for day: {}", day);
+
Ok(None)
+
}
+
Some(day_one_file) => Ok(Some(day_one_file)),
+
}
+
}
+
+
/// Does the day have a part two challenge?
+
fn has_part_two(&self) -> bool;
+
//Commenting this out and just going leave it to who makes the impl. This is less code to write,
+
// but really it's a faster response to just have the author put true or false
+
//
+
// {
+
// match self.get_day_markdown_file(AdventPart::Two) {
+
// Ok(_) => true,
+
// Err(_) => false,
+
// }
+
// }
+
+
/// The text Markdown for challenge 1
+
fn markdown_text_part_one(
+
&self,
+
verification_code: Option<String>,
+
) -> Result<String, AdventError> {
+
let day = self.day();
+
+
//May acutally leave this unwrap or put a panic in case it doesn't exist since it is needed to have a part one
+
match self.get_day_markdown_file(AdventPart::One)? {
+
None => {
+
log::error!("Missing the part one challenge file for day: {}", day);
+
Ok("Someone let the admins know this page is missing. No, this is not an Easter egg, it's an actual bug".to_string())
+
}
+
Some(day_one_file) => {
+
//TODO probably should be a shared variable, but prototyping
+
let reg = Handlebars::new();
+
+
let day_one_text = std::str::from_utf8(day_one_file.data.as_ref())?;
+
let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string());
+
let handlebar_rendered =
+
reg.render_template(day_one_text, &json!({"code": code}))?;
+
+
Ok(
+
markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options())
+
.unwrap(),
+
)
+
}
+
}
+
}
+
+
/// The text Markdown for challenge 2, could be None
+
fn markdown_text_part_two(
+
&self,
+
verification_code: Option<String>,
+
) -> Result<Option<String>, AdventError> {
+
match self.get_day_markdown_file(AdventPart::Two)? {
+
None => Ok(None),
+
Some(day_two_file) => {
+
//TODO probably should be a shared variable, but prototyping
+
let reg = Handlebars::new();
+
+
let day_two_text = std::str::from_utf8(day_two_file.data.as_ref())?;
+
let code = verification_code.unwrap_or_else(|| "Login to get a code".to_string());
+
let handlebar_rendered =
+
reg.render_template(day_two_text, &json!({"code": code}))?;
+
+
Ok(Some(
+
markdown::to_html_with_options(&handlebar_rendered, &get_markdown_options())
+
.unwrap(),
+
))
+
}
+
}
+
}
+
+
/// Checks to see if the day's challenge had been started for the user
+
async fn day_started(&self, did: &str) -> Result<bool, AdventError> {
+
let exists = sqlx::query_scalar::<_, i64>(
+
"SELECT id FROM challenges WHERE user_did = $1 AND day = $2 LIMIT 1",
+
)
+
.bind(did)
+
.bind(self.day() as i16)
+
.fetch_optional(self.pool())
+
.await?;
+
Ok(exists.is_some())
+
}
+
+
async fn get_days_challenge(
+
&self,
+
did: String,
+
) -> Result<Option<ChallengeProgress>, AdventError> {
+
let day = self.day();
+
Ok(sqlx::query_as::<_, ChallengeProgress>(
+
"SELECT * FROM challenges WHERE user_did = $1 AND day = $2",
+
)
+
.bind(did)
+
.bind(day as i16)
+
.fetch_optional(self.pool())
+
.await?)
+
}
+
+
async fn start_challenge(&self, did: String, part: AdventPart) -> Result<String, AdventError> {
+
let code = get_random_token();
+
match part {
+
AdventPart::One => sqlx::query(
+
"INSERT INTO challenges (user_did, day, time_started, verification_code_one)
+
VALUES ($1, $2, NOW(), $3)
+
ON CONFLICT (user_did, day)
+
DO UPDATE SET verification_code_one = $3
+
WHERE challenges.user_did = $1 AND challenges.day = $2",
+
),
+
//TODO just going leave these as an update. It should never ideally be an insert
+
AdventPart::Two => sqlx::query(
+
"UPDATE challenges
+
SET verification_code_two = $3
+
WHERE challenges.user_did = $1 AND challenges.day = $2",
+
),
+
}
+
.bind(did)
+
.bind(self.day() as i16)
+
.bind(code.clone())
+
.execute(self.pool())
+
.await?;
+
Ok(code)
+
}
+
+
/// Marks the challenge as completed.
+
async fn complete_part_one(&self, did: String) -> Result<(), AdventError> {
+
sqlx::query(
+
"UPDATE challenges
+
SET time_challenge_one_completed = COALESCE(time_challenge_one_completed, NOW())
+
WHERE user_did = $1 AND day = $2",
+
)
+
.bind(did)
+
.bind(self.day() as i16)
+
.execute(self.pool())
+
.await?;
+
Ok(())
+
}
+
+
/// Marks the challenge as completed.
+
async fn complete_part_two(&self, did: String) -> Result<(), AdventError> {
+
sqlx::query(
+
"UPDATE challenges
+
SET time_challenge_two_completed = COALESCE(time_challenge_two_completed, NOW())
+
WHERE user_did = $1 AND day = $2",
+
)
+
.bind(did)
+
.bind(self.day() as i16)
+
.execute(self.pool())
+
.await?;
+
Ok(())
+
}
+
+
async fn get_completed_status(
+
&self,
+
did: Option<String>,
+
) -> Result<CompletionStatus, AdventError> {
+
match did {
+
None => Ok(CompletionStatus::None),
+
Some(did) => {
+
let day = self.day() as i32;
+
let result = sqlx::query!(
+
"SELECT time_challenge_one_completed, time_challenge_two_completed
+
FROM challenges
+
WHERE user_did = $1 AND day = $2",
+
did,
+
day
+
)
+
.fetch_optional(self.pool())
+
.await?;
+
+
Ok(match result {
+
None => CompletionStatus::None,
+
Some(row) => match (
+
row.time_challenge_one_completed,
+
row.time_challenge_two_completed,
+
) {
+
(None, None) => CompletionStatus::None,
+
(Some(_), None) => CompletionStatus::PartOne,
+
(Some(_), Some(_)) => CompletionStatus::Both,
+
_ => panic!(
+
"This should never happen as in part one shouldn't be not done but 2 is"
+
),
+
},
+
})
+
}
+
}
+
}
+
+
///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
+
async fn check_part_one(
+
&self,
+
did: String,
+
verification_code: Option<String>,
+
) -> Result<ChallengeCheckResponse, AdventError>;
+
+
///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
+
/// part two does have a hard error if its called and there is not a part 2
+
async fn check_part_two(
+
&self,
+
_did: String,
+
_verification_code: Option<String>,
+
) -> Result<ChallengeCheckResponse, AdventError> {
+
unimplemented!("Second day challenges are optional")
+
}
+
}
+
+
pub fn get_random_token() -> String {
+
let mut rng = rand::rng();
+
+
let full_code = Alphanumeric.sample_string(&mut rng, 10);
+
+
let slice_one = &full_code[0..5].to_ascii_uppercase();
+
let slice_two = &full_code[5..10].to_ascii_uppercase();
+
format!("{slice_one}-{slice_two}")
+
}
+
+
fn get_markdown_options() -> Options {
+
Options {
+
parse: Default::default(),
+
compile: CompileOptions {
+
//Setting this to allow HTML in the markdown. So pleas be careful what you put in there
+
allow_dangerous_html: true,
+
..Default::default()
+
},
+
}
+
}
+6
shared/src/assets.rs
···
+
use rust_embed::Embed;
+
+
#[derive(Embed)]
+
#[folder = "challenges_markdown/"]
+
#[include = "*.md"]
+
pub struct ChallengesMarkdown;
+15
shared/src/atrium/mod.rs
···
+
use atrium_api::types::Unknown;
+
use serde::de;
+
pub mod dns_resolver;
pub mod stores;
+
+
/// Safely parses an unknown record into a type. If it fails, it logs the error and returns an error.
+
pub fn safe_check_unknown_record_parse<T>(unknown: Unknown) -> serde_json::Result<T>
+
where
+
T: de::DeserializeOwned,
+
{
+
let json = serde_json::to_vec(&unknown).map_err(|err| {
+
log::error!("Error getting the bytes of a record: {}", err);
+
err
+
})?;
+
serde_json::from_slice::<T>(&json)
+
}
+7 -4
shared/src/atrium/stores.rs
···
/// Storage impls to persis OAuth sessions if you are not using the memory stores
/// https://github.com/bluesky-social/statusphere-example-app/blob/main/src/auth/storage.ts
-
use crate::cache::{ATRIUM_SESSION_STORE_PREFIX, ATRIUM_STATE_STORE_KEY, create_prefixed_key};
+
use crate::cache::{
+
ATRIUM_SESSION_STORE_PREFIX, ATRIUM_STATE_STORE_KEY, Cache, create_prefixed_key,
+
};
use atrium_api::types::string::Did;
use atrium_common::store::Store;
use atrium_oauth::store::session::SessionStore;
···
async fn set(&self, key: K, value: V) -> Result<(), Self::Error> {
let cache_key = create_prefixed_key(ATRIUM_STATE_STORE_KEY, key.as_ref());
-
let json_value = serde_json::to_string(&value)?;
-
let mut cache = self.cache_pool.get().await?;
-
let _: () = cache.set(cache_key, json_value).await?;
+
let mut cache = Cache::new(self.cache_pool.get().await?);
+
let _ = cache
+
.write_to_cache_with_seconds(&cache_key, value, 3_6000)
+
.await?;
Ok(())
}
+3 -1
shared/src/cache.rs
···
pub const ATRIUM_SESSION_STORE_PREFIX: &str = "atrium_session:";
pub const ATRIUM_STATE_STORE_KEY: &str = "atrium_state:";
+
pub const TOWER_SESSION_KEY: &str = "tower_session:";
+
pub fn create_prefixed_key(prefix: &str, key: &str) -> String {
format!("{}{}", prefix, key)
}
pub struct Cache<'a> {
-
redis_pool: PooledConnection<'a, RedisConnectionManager>,
+
pub redis_pool: PooledConnection<'a, RedisConnectionManager>,
}
#[derive(Debug, Error)]
+3
shared/src/lexicons/codes.rs
···
+
// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
+
//!Definitions for the `codes` namespace.
+
pub mod advent;
+3
shared/src/lexicons/codes/advent.rs
···
+
// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
+
//!Definitions for the `codes.advent` namespace.
+
pub mod challenge;
+9
shared/src/lexicons/codes/advent/challenge.rs
···
+
// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
+
//!Definitions for the `codes.advent.challenge` namespace.
+
pub mod day;
+
#[derive(Debug)]
+
pub struct Day;
+
impl atrium_api::types::Collection for Day {
+
const NSID: &'static str = "codes.advent.challenge.day";
+
type Record = day::Record;
+
}
+18
shared/src/lexicons/codes/advent/challenge/day.rs
···
+
// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
+
//!Definitions for the `codes.advent.challenge.day` namespace.
+
use atrium_api::types::TryFromUnknown;
+
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
+
#[serde(rename_all = "camelCase")]
+
pub struct RecordData {
+
#[serde(skip_serializing_if = "core::option::Option::is_none")]
+
pub created_at: core::option::Option<atrium_api::types::string::Datetime>,
+
pub part_one: String,
+
#[serde(skip_serializing_if = "core::option::Option::is_none")]
+
pub part_two: core::option::Option<String>,
+
}
+
pub type Record = atrium_api::types::Object<RecordData>;
+
impl From<atrium_api::types::Unknown> for RecordData {
+
fn from(value: atrium_api::types::Unknown) -> Self {
+
Self::try_from_unknown(value).unwrap()
+
}
+
}
+3
shared/src/lexicons/mod.rs
···
+
// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
+
pub mod record;
+
pub mod codes;
+27
shared/src/lexicons/record.rs
···
+
// @generated - This file is generated by esquema-codegen (forked from atrium-codegen). DO NOT EDIT.
+
//!A collection of known record types.
+
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq)]
+
#[serde(tag = "$type")]
+
pub enum KnownRecord {
+
#[serde(rename = "codes.advent.challenge.day")]
+
LexiconsCodesAdventChallengeDay(
+
Box<crate::lexicons::codes::advent::challenge::day::Record>,
+
),
+
}
+
impl From<crate::lexicons::codes::advent::challenge::day::Record> for KnownRecord {
+
fn from(record: crate::lexicons::codes::advent::challenge::day::Record) -> Self {
+
KnownRecord::LexiconsCodesAdventChallengeDay(Box::new(record))
+
}
+
}
+
impl From<crate::lexicons::codes::advent::challenge::day::RecordData> for KnownRecord {
+
fn from(
+
record_data: crate::lexicons::codes::advent::challenge::day::RecordData,
+
) -> Self {
+
KnownRecord::LexiconsCodesAdventChallengeDay(Box::new(record_data.into()))
+
}
+
}
+
impl Into<atrium_api::types::Unknown> for KnownRecord {
+
fn into(self) -> atrium_api::types::Unknown {
+
atrium_api::types::TryIntoUnknown::try_into_unknown(&self).unwrap()
+
}
+
}
+37
shared/src/lib.rs
···
+
extern crate core;
+
+
use crate::atrium::dns_resolver::HickoryDnsTxtResolver;
+
use crate::atrium::stores::{AtriumSessionStore, AtriumStateStore};
+
use atrium_api::agent::Agent;
+
use atrium_identity::did::CommonDidResolver;
+
use atrium_identity::handle::AtprotoHandleResolver;
+
use atrium_oauth::{DefaultHttpClient, OAuthClient};
+
use std::sync::Arc;
+
+
pub mod advent;
+
pub mod assets;
pub mod atrium;
pub mod cache;
pub mod db;
pub mod models;
pub mod web_helpers;
+
+
pub mod lexicons;
+
+
/// OAuthClientType to make it easier to access the OAuthClient in web requests
+
pub type OAuthClientType = Arc<
+
OAuthClient<
+
AtriumStateStore,
+
AtriumSessionStore,
+
CommonDidResolver<DefaultHttpClient>,
+
AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>,
+
>,
+
>;
+
+
/// HandleResolver type to make it easier to access the resolver in web requests
+
pub type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>;
+
+
/// The agent(what makes atproto calls)
+
pub type OAuthAgentType = Agent<
+
atrium_oauth::OAuthSession<
+
DefaultHttpClient,
+
CommonDidResolver<DefaultHttpClient>,
+
AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>,
+
AtriumSessionStore,
+
>,
+
>;
+13
shared/src/models/db_models.rs
···
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
+
use sqlx::types::chrono::{DateTime, Utc};
#[derive(FromRow, Serialize, Deserialize, Debug, Default)]
pub struct TestModel {
pub id: i64,
pub test: String,
}
+
+
#[derive(FromRow, Serialize, Deserialize, Debug, Clone)]
+
pub struct ChallengeProgress {
+
pub id: i64,
+
pub user_did: String,
+
pub day: i32,
+
pub time_started: DateTime<Utc>,
+
pub time_challenge_one_completed: Option<DateTime<Utc>>,
+
pub time_challenge_two_completed: Option<DateTime<Utc>>,
+
pub verification_code_one: Option<String>,
+
pub verification_code_two: Option<String>,
+
}
+9 -2
web/Cargo.toml
···
atrium-identity.workspace = true
atrium-oauth.workspace = true
axum.workspace = true
+
chrono.workspace = true
bb8.workspace = true
bb8-redis.workspace = true
dotenv.workspace = true
···
serde_json.workspace = true
shared.workspace = true
sqlx.workspace = true
-
tokio = { version = "1.46.1", features = ["full"] }
+
tokio.workspace = true
tower-http = { version = "0.6.6", features = ["trace"] }
tower-sessions = "0.14.0"
tracing.workspace = true
tracing-subscriber.workspace = true
-
serde = { version = "1.0.219", features = ["derive"] }
+
serde = { version = "1.0.219", features = ["derive"] }
+
askama = "0.14"
+
async-trait = "0.1.88"
+
hypertext = "0.12.1"
+
+
[build-dependencies]
+
pool = "0.1.4"
+112
web/src/handlers/auth.rs
···
+
use crate::session::{AxumSessionStore, FlashMessage, get_flash_message, set_flash_message};
+
use crate::templates::{HtmlTemplate, login::LoginTemplate};
+
use crate::{error_response, oauth_scopes};
+
use atrium_api::agent::Agent;
+
use atrium_oauth::{AuthorizeOptions, CallbackParams};
+
use axum::{
+
extract::{Path, Query, State},
+
http::StatusCode,
+
response::{IntoResponse, Redirect, Response},
+
};
+
use shared::OAuthClientType;
+
+
pub async fn login_page_handler(
+
mut session: AxumSessionStore,
+
) -> Result<impl IntoResponse, Response> {
+
let possible_error = match get_flash_message(&mut session, "error").await? {
+
Some(FlashMessage::Error(msg)) => Some(msg),
+
_ => None,
+
};
+
+
Ok(HtmlTemplate(LoginTemplate {
+
title: "at://advent - Login",
+
error: possible_error,
+
}))
+
}
+
+
pub async fn login_handle(
+
Path(handle): Path<String>,
+
State(oauth_client): State<OAuthClientType>,
+
mut session: AxumSessionStore,
+
) -> Result<impl IntoResponse, Response> {
+
match atrium_api::types::string::Handle::new(handle) {
+
Ok(handle) => {
+
match oauth_client
+
.authorize(
+
&handle,
+
AuthorizeOptions {
+
scopes: oauth_scopes(),
+
..Default::default()
+
},
+
)
+
.await
+
{
+
Ok(url) => Ok(Redirect::to(url.as_str())),
+
Err(err) => {
+
log::error!("Error generating OAuth URL: {err}");
+
set_flash_message(
+
&mut session,
+
"error",
+
FlashMessage::Error("Error creating login URL".to_string()),
+
)
+
.await?;
+
Err(error_response(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error creating login URL",
+
))
+
}
+
}
+
}
+
Err(err) => {
+
log::error!("Error parsing the handle: {err}");
+
set_flash_message(
+
&mut session,
+
"error",
+
FlashMessage::Error("Error parsing the handle".to_string()),
+
)
+
.await?;
+
+
Ok(Redirect::to("/login"))
+
}
+
}
+
}
+
+
pub async fn handle_root_handler() -> impl IntoResponse {
+
Redirect::to("/login")
+
}
+
+
///End point that takes back the OAuth call back and creates a session
+
pub async fn oauth_callback_handler(
+
params: Query<CallbackParams>,
+
State(oauth_client): State<OAuthClientType>,
+
mut session: AxumSessionStore,
+
) -> Response {
+
let call_back_params = CallbackParams {
+
code: params.code.clone(),
+
state: params.state.clone(),
+
iss: params.iss.clone(),
+
};
+
match oauth_client.callback(call_back_params).await {
+
Ok((bsky_session, _)) => {
+
let agent = Agent::new(bsky_session);
+
match agent.did().await {
+
Some(did) => {
+
if let Err(err) = session.set_did(did.clone().to_string()).await {
+
log::error!("Failed to write session: {err}");
+
return error_response(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Failed to create session",
+
);
+
}
+
+
Redirect::permanent("/day/1").into_response()
+
}
+
None => error_response(StatusCode::INTERNAL_SERVER_ERROR, "No DID found"),
+
}
+
}
+
Err(err) => {
+
log::error!("OAuth callback error: {err}");
+
error_response(StatusCode::INTERNAL_SERVER_ERROR, "OAuth callback failed")
+
}
+
}
+
}
+378
web/src/handlers/day.rs
···
+
use crate::error_response;
+
use crate::session::{AxumSessionStore, FlashMessage, get_flash_message, set_flash_message};
+
use crate::templates::{HtmlTemplate, day::DayTemplate};
+
use atrium_api::agent::Agent;
+
use atrium_api::types::string::Did;
+
use axum::{
+
extract::{Form, Path, State},
+
http::StatusCode,
+
response::{IntoResponse, Redirect, Response},
+
};
+
use shared::advent::ChallengeCheckResponse;
+
use shared::{
+
OAuthAgentType, OAuthClientType,
+
advent::challenges::day_one::DayOne,
+
advent::challenges::day_two::DayTwo,
+
advent::day::Day,
+
advent::{AdventChallenge, AdventError},
+
advent::{AdventPart, CompletionStatus},
+
};
+
use sqlx::PgPool;
+
+
fn pick_day(
+
day: Day,
+
pool: PgPool,
+
oauth_client: Option<OAuthAgentType>,
+
) -> Result<Box<dyn AdventChallenge + Send + Sync>, AdventError> {
+
match day {
+
Day::One => Ok(Box::new(DayOne { pool, oauth_client })),
+
Day::Two => Ok(Box::new(DayTwo { pool, oauth_client })),
+
_ => Err(AdventError::InvalidDay(0)), // Day::Three => {}
+
// Day::Four => {}
+
// Day::Five => {}
+
// Day::Six => {}
+
// Day::Seven => {}
+
// Day::Eight => {}
+
// Day::Nine => {}
+
// Day::Ten => {}
+
// Day::Eleven => {}
+
// Day::Twelve => {}
+
// Day::Thirteen => {}
+
// Day::Fourteen => {}
+
// Day::Fifteen => {}
+
// Day::Sixteen => {}
+
// Day::Seventeen => {}
+
// Day::Eighteen => {}
+
// Day::Nineteen => {}
+
// Day::Twenty => {}
+
// Day::TwentyOne => {}
+
// Day::TwentyTwo => {}
+
// Day::TwentyThree => {}
+
// Day::TwentyFour => {}
+
// Day::TwentyFive => {}
+
}
+
}
+
+
fn log_and_respond<E: std::fmt::Display>(
+
status: StatusCode,
+
context: &'static str,
+
) -> impl FnOnce(E) -> Response {
+
move |err| {
+
log::error!("{context}: {err}");
+
error_response(status, context)
+
}
+
}
+
+
pub async fn view_day_handler(
+
Path(id): Path<u8>,
+
State(pool): State<PgPool>,
+
session: AxumSessionStore,
+
) -> Result<impl IntoResponse, Response> {
+
let day = Day::from(id);
+
+
let did = session.get_did();
+
let did_clone = did.clone();
+
let challenge = pick_day(day, pool, None).map_err(|err| {
+
log::error!("Error picking day: {err}");
+
error_response(StatusCode::INTERNAL_SERVER_ERROR, "Error picking day")
+
})?;
+
+
let title = format!("at://advent - Day {}", day as u8);
+
let part_one_text = match did_clone {
+
None => challenge
+
.markdown_text_part_one(None)
+
.map(|s| s.to_string())
+
.unwrap_or_else(|_| "Error loading part one".to_string()),
+
Some(ref users_did) => match challenge.get_days_challenge(users_did.clone()).await {
+
Ok(current_challenge) => match current_challenge {
+
None => {
+
let new_code = challenge
+
.start_challenge(users_did.to_string(), AdventPart::One)
+
.await
+
.unwrap();
+
challenge
+
.markdown_text_part_one(Some(new_code))
+
.map(|s| s.to_string())
+
.unwrap_or_else(|_| "Error loading part one".to_string())
+
}
+
Some(current_challenge) => match current_challenge.verification_code_one {
+
None => {
+
let new_code = challenge
+
.start_challenge(users_did.to_string(), AdventPart::One)
+
.await
+
.unwrap();
+
challenge
+
.markdown_text_part_one(Some(new_code))
+
.map(|s| s.to_string())
+
.unwrap_or_else(|_| "Error loading part one".to_string())
+
}
+
Some(code) => challenge
+
.markdown_text_part_one(Some(code))
+
.map(|s| s.to_string())
+
.unwrap_or_else(|_| "Error loading part one".to_string()),
+
},
+
},
+
+
Err(err) => {
+
log::error!("Error loading today's challenge for the user: {users_did} \n {err}");
+
"There was an error loading the challenge...sorry about that".to_string()
+
}
+
},
+
};
+
+
let status = challenge.get_completed_status(did).await.map_err(|err| {
+
log::error!("Error getting completed status: {err}");
+
error_response(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error getting completed status",
+
)
+
})?;
+
+
let mut session = session;
+
let part_one_flash = get_flash_message(&mut session, "part_one_result").await?;
+
let part_two_flash = get_flash_message(&mut session, "part_two_result").await?;
+
+
let template = match status {
+
CompletionStatus::None => DayTemplate {
+
title,
+
day: id,
+
challenge_one_text: part_one_text,
+
challenge_one_completed: false,
+
challenge_two_text: None,
+
challenge_two_completed: false,
+
part_one_submit_message: part_one_flash,
+
part_two_submit_message: part_two_flash,
+
},
+
CompletionStatus::PartOne => {
+
let part_two_text = get_part_two_text(did_clone, &challenge).await;
+
let completed = part_two_text.is_none();
+
DayTemplate {
+
title,
+
day: id,
+
challenge_one_text: part_one_text,
+
challenge_one_completed: true,
+
challenge_two_text: part_two_text,
+
challenge_two_completed: completed,
+
part_one_submit_message: part_one_flash,
+
part_two_submit_message: part_two_flash,
+
}
+
}
+
CompletionStatus::Both => {
+
let part_two_text = get_part_two_text(did_clone, &challenge).await;
+
DayTemplate {
+
title,
+
day: id,
+
challenge_one_text: part_one_text,
+
challenge_one_completed: true,
+
challenge_two_text: part_two_text,
+
challenge_two_completed: true,
+
part_one_submit_message: part_one_flash,
+
part_two_submit_message: part_two_flash,
+
}
+
}
+
};
+
+
Ok(HtmlTemplate(template))
+
}
+
+
///TODO prob look and see if this can be shared between part one since it is similar logic...
+
/// Also this is in a function since PartOne and Both load the partwo text
+
async fn get_part_two_text(
+
did_clone: Option<String>,
+
challenge: &Box<dyn AdventChallenge + Send + Sync>,
+
) -> Option<String> {
+
let part_two_text: Option<String> = match did_clone {
+
None => challenge
+
.markdown_text_part_two(None)
+
.map(|opt| opt.map(|s| s.to_string()))
+
.unwrap_or(None),
+
Some(users_did) => match challenge.get_days_challenge(users_did.clone()).await {
+
Ok(current_challenge) => match current_challenge {
+
None => {
+
if challenge.has_part_two() {
+
let new_code = challenge
+
.start_challenge(users_did.to_string(), AdventPart::Two)
+
.await
+
.unwrap();
+
challenge
+
.markdown_text_part_two(Some(new_code))
+
.map(|opt| opt.map(|s| s.to_string()))
+
.unwrap_or(None)
+
} else {
+
None
+
}
+
}
+
Some(current_challenge) => {
+
// If there is no code yet for part two, start it; otherwise use the existing code
+
if challenge.has_part_two() {
+
match current_challenge.verification_code_two {
+
None => {
+
let new_code = challenge
+
.start_challenge(users_did.to_string(), AdventPart::Two)
+
.await
+
.unwrap();
+
challenge
+
.markdown_text_part_two(Some(new_code))
+
.map(|opt| opt.map(|s| s.to_string()))
+
.unwrap_or(None)
+
}
+
Some(code) => challenge
+
.markdown_text_part_two(Some(code))
+
.map(|opt| opt.map(|s| s.to_string()))
+
.unwrap_or(None),
+
}
+
} else {
+
let day = current_challenge.day;
+
log::warn!(
+
"There is no part two for day: {day}. Developer may of forgotten to set the has_part_two flag to true."
+
);
+
None
+
}
+
}
+
},
+
Err(err) => {
+
log::error!("Error loading today's challenge for the user: {users_did} \n {err}");
+
None
+
}
+
},
+
};
+
part_two_text
+
}
+
+
/// This can be used to verify the day's challenge. Empty if it's up to the backend to grab the verification code
+
/// from somewhere like a lexicon record
+
#[derive(Debug, serde::Deserialize, Clone)]
+
pub struct PostDayForm {
+
#[serde(default)]
+
pub verification_code_one: Option<String>,
+
#[serde(default)]
+
pub verification_code_two: Option<String>,
+
}
+
+
///This is the endpoint to verify the day's challenge
+
pub async fn post_day_handler(
+
Path(day): Path<u8>,
+
State(pool): State<PgPool>,
+
State(oauth_client): State<OAuthClientType>,
+
mut session: AxumSessionStore,
+
Form(form): Form<PostDayForm>,
+
) -> Result<impl IntoResponse, Response> {
+
match &session.get_did() {
+
None => Err(error_response(
+
StatusCode::FORBIDDEN,
+
"You need to be logged in to submit an answer",
+
)),
+
Some(did) => {
+
let did_as_string = did.clone();
+
let did = Did::new(did.to_string())
+
.map_err(log_and_respond(StatusCode::BAD_REQUEST, "Invalid DID"))?;
+
+
let client = oauth_client.restore(&did).await.map_err(log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"There was an error restoring the oauth client",
+
))?;
+
+
let agent = Agent::new(client);
+
let day = Day::from(day);
+
+
let challenge = pick_day(day, pool, Some(agent)).map_err(log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error picking the day",
+
))?;
+
+
let status = challenge
+
.get_completed_status(Some(did_as_string.clone()))
+
.await
+
.map_err(log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error getting the completed status",
+
))?;
+
+
match status {
+
CompletionStatus::None => {
+
let result = challenge
+
.check_part_one(did_as_string.clone(), form.verification_code_one)
+
.await
+
.map_err(log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error checking part one",
+
))?;
+
match result {
+
ChallengeCheckResponse::Correct => {
+
challenge.complete_part_one(did_as_string).await.map_err(
+
log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error completing part one",
+
),
+
)?;
+
set_flash_message(
+
&mut session,
+
"part_one_result",
+
FlashMessage::Success(
+
"Good job, you've completed Part 1".to_string(),
+
),
+
)
+
.await?;
+
Ok(Redirect::to(format!("/day/{}", day as u8).as_str()))
+
}
+
ChallengeCheckResponse::Incorrect(message) => {
+
set_flash_message(
+
&mut session,
+
"part_one_result",
+
FlashMessage::Error(message),
+
)
+
.await?;
+
Ok(Redirect::to(format!("/day/{}", day as u8).as_str()))
+
}
+
}
+
}
+
CompletionStatus::PartOne => {
+
if !challenge.has_part_two() {
+
log::info!(
+
"Someone tried to check for part two on day:{day}, when there was not one"
+
);
+
return Ok(Redirect::to(format!("/day/{}", day as u8).as_str()));
+
}
+
+
let result = challenge
+
.check_part_two(did_as_string.clone(), form.verification_code_two)
+
.await
+
.map_err(log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error checking part two",
+
))?;
+
+
match result {
+
ChallengeCheckResponse::Correct => {
+
challenge.complete_part_two(did_as_string).await.map_err(
+
log_and_respond(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error completing part two",
+
),
+
)?;
+
set_flash_message(
+
&mut session,
+
"part_two_result",
+
FlashMessage::Success(
+
"Good job, you've completed Part 2".to_string(),
+
),
+
)
+
.await?;
+
Ok(Redirect::to(format!("/day/{}", day as u8).as_str()))
+
}
+
ChallengeCheckResponse::Incorrect(message) => {
+
set_flash_message(
+
&mut session,
+
"part_two_result",
+
FlashMessage::Error(message),
+
)
+
.await?;
+
Ok(Redirect::to(format!("/day/{}", day as u8).as_str()))
+
}
+
}
+
}
+
CompletionStatus::Both => Ok(Redirect::to(format!("/day/{}", day as u8).as_str())),
+
}
+
}
+
}
+
}
+2
web/src/handlers/mod.rs
···
+
pub mod day;
+
pub mod auth;
+100 -173
web/src/main.rs
···
-
use atrium_api::agent::Agent;
-
use atrium_api::types::string::Did;
-
use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL};
-
use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig};
+
use crate::{
+
templates::HtmlTemplate, templates::error::ErrorTemplate, templates::home::HomeTemplate,
+
};
+
use atrium_identity::{
+
did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL},
+
handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
+
};
use atrium_oauth::{
-
AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient,
-
KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope,
+
AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClient, OAuthClientConfig,
+
OAuthResolverConfig, Scope,
+
};
+
use axum::{
+
Router,
+
http::StatusCode,
+
middleware,
+
response::IntoResponse,
+
response::Response,
+
routing::{get, post},
};
-
use axum::extract::{Query, State};
-
use axum::http::StatusCode;
-
use axum::{Json, Router, extract::Path, middleware, routing::get};
use bb8_redis::RedisConnectionManager;
+
use chrono::Datelike;
use dotenv::dotenv;
use redis::AsyncCommands;
-
use shared::atrium::dns_resolver::HickoryDnsTxtResolver;
-
use shared::atrium::stores::{AtriumSessionStore, AtriumStateStore};
-
use shared::cache::CacheConnection;
-
use shared::models::db_models::TestModel;
-
use sqlx::PgPool;
-
use sqlx::postgres::PgPoolOptions;
-
use std::sync::Arc;
+
use shared::{
+
HandleResolver, OAuthClientType, atrium::dns_resolver::HickoryDnsTxtResolver,
+
atrium::stores::AtriumSessionStore, atrium::stores::AtriumStateStore,
+
};
+
use sqlx::{PgPool, postgres::PgPoolOptions};
use std::{
env,
net::{IpAddr, Ipv4Addr, SocketAddr},
+
sync::Arc,
time,
};
use time::Duration;
use tower_http::trace::TraceLayer;
-
use tower_sessions::{MemoryStore, Session, SessionManagerLayer};
+
use tower_sessions::{SessionManagerLayer, cookie::SameSite};
use tracing_subscriber::EnvFilter;
+
mod handlers;
+
extern crate dotenv;
mod extractors;
+
mod redis_session_store;
+
mod session;
+
mod templates;
mod unlock;
#[derive(Clone)]
···
postgres_pool: PgPool,
redis_pool: bb8::Pool<RedisConnectionManager>,
oauth_client: OAuthClientType,
-
//Used to get did to handle leaving cause I figured we'd need it
+
//Used to get did to handle leaving because I figured we'd need it
_handle_resolver: HandleResolver,
}
-
/// OAuthClientType to make it easier to access the OAuthClient in web requests
-
type OAuthClientType = Arc<
-
OAuthClient<
-
AtriumStateStore,
-
AtriumSessionStore,
-
CommonDidResolver<DefaultHttpClient>,
-
AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>,
-
>,
-
>;
+
fn oauth_scopes() -> Vec<Scope> {
+
vec![
+
Scope::Known(KnownScope::Atproto),
+
//Gives full CRUD to the codes.advent.* collection
+
Scope::Unknown("repo:codes.advent.*".to_string()),
+
]
+
}
-
/// HandleResolver type to make it easier to access the resolver in web requests
-
type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>;
+
fn error_response(status: StatusCode, message: &str) -> Response {
+
IntoResponse::into_response((
+
status,
+
HtmlTemplate(ErrorTemplate {
+
title: "at://advent - Error",
+
message,
+
}),
+
))
+
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
···
let port = addr.port();
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
-
let now = time::Instant::now();
-
let daily = time::Duration::from_secs(24 * 60 * 60);
-
let state = unlock::Unlock::new(now, daily);
-
//sqlx pool
let database_url =
env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment or .env");
···
//This must match the endpoint you use the callback function
"http://{host}:{port}/oauth/callback"
))]),
-
scopes: Some(vec![
-
Scope::Known(KnownScope::Atproto),
-
Scope::Known(KnownScope::TransitionGeneric),
-
]),
+
scopes: Some(oauth_scopes()),
},
keys: None,
resolver: OAuthResolverConfig {
···
};
let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"));
-
//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
-
// https://docs.rs/tower-sessions/latest/tower_sessions/trait.SessionStore.html
-
let session_store = MemoryStore::default();
-
let session_layer = SessionManagerLayer::new(session_store).with_secure(false);
+
let session_store = redis_session_store::RedisSessionStore::new(redis_pool.clone());
+
let session_layer = SessionManagerLayer::new(session_store)
+
//Set to lax so session id cookie can be set on redirect
+
.with_same_site(SameSite::Lax)
+
.with_secure(false);
let app_state = AppState {
postgres_pool,
···
oauth_client: client,
_handle_resolver: handle_resolver,
};
-
+
//HACK Yeah I don't like it either - bt
+
let prod: bool = env::var("PROD")
+
.map(|val| val == "true")
+
.unwrap_or_else(|_| true);
log::info!("listening on http://{}", addr);
let app = Router::new()
+
.route("/", get(home_handler))
+
.route(
+
"/day/{id}",
+
match prod {
+
true => get(handlers::day::view_day_handler)
+
.route_layer(middleware::from_fn(unlock::unlock)),
+
false => get(handlers::day::view_day_handler),
+
},
+
)
.route(
"/day/{id}",
-
get(handler).route_layer(middleware::from_fn_with_state(
-
state.clone(),
-
unlock::unlock,
-
)),
+
match prod {
+
true => post(handlers::day::post_day_handler)
+
.route_layer(middleware::from_fn(unlock::unlock)),
+
false => post(handlers::day::post_day_handler),
+
},
+
)
+
.route("/login", get(handlers::auth::login_page_handler))
+
.route("/handle", get(handlers::auth::handle_root_handler))
+
.route("/login/{handle}", get(handlers::auth::login_handle))
+
.route(
+
"/oauth/callback",
+
get(handlers::auth::oauth_callback_handler),
)
-
.route("/sql-test", get(sql_test_handler))
-
.route("/redis-test", get(redis_test_handler))
-
.route("/login/{handle}", get(login_test_handler))
-
.route("/oauth/callback", get(oauth_callback_handler))
-
.route("/logged-in", get(logged_in_test_handler))
.layer(session_layer)
.with_state(app_state)
.layer(TraceLayer::new_for_http());
···
Ok(())
}
-
async fn handler(Path(id): Path<u32>) -> String {
-
format!("hello day {id}")
-
}
-
-
async fn sql_test_handler(State(pool): State<PgPool>) -> Json<Vec<TestModel>> {
-
Json(
-
sqlx::query_as::<_, TestModel>("SELECT id, test FROM test_table")
-
.fetch_all(&pool)
-
.await
-
.unwrap(),
-
)
-
}
-
-
/// Pass in your handle like /login/baileytownsend.dev
-
async fn login_test_handler(
-
Path(handle): Path<String>,
-
State(oauth_client): State<OAuthClientType>,
-
) -> String {
-
match atrium_api::types::string::Handle::new(handle) {
-
Ok(handle) => {
-
//Creates the oauth url to redirect to for the user to log in with their credentials
-
let oauth_url = oauth_client
-
.authorize(
-
&handle,
-
AuthorizeOptions {
-
scopes: vec![
-
Scope::Known(KnownScope::Atproto),
-
Scope::Known(KnownScope::TransitionGeneric),
-
],
-
..Default::default()
-
},
-
)
-
.await;
-
oauth_url.unwrap_or_else(|err| {
-
log::error!("Error: {err}");
-
err.to_string()
-
})
-
}
-
Err(err) => err.to_string(),
-
}
-
}
-
-
///End point that takes back the OAuth call back and creates a session
-
async fn oauth_callback_handler(
-
params: Query<CallbackParams>,
-
State(oauth_client): State<OAuthClientType>,
-
session: Session,
-
) -> String {
-
//HACK, yeah I gave up... hoping someone has a better solution
-
let call_back_params = CallbackParams {
-
code: params.code.clone(),
-
state: params.state.clone(),
-
iss: params.iss.clone(),
-
};
-
match oauth_client.callback(call_back_params).await {
-
Ok((bsky_session, _)) => {
-
let agent = Agent::new(bsky_session);
-
match agent.did().await {
-
Some(did) => {
-
session.insert("did", did.clone()).await.unwrap();
-
format!("Session created for {}", did.to_string())
-
// did.to_string()
-
// session.insert("did", did).unwrap();
-
// Redirect::to("/")
-
// .see_other()
-
// .respond_to(&request)
-
// .map_into_boxed_body()
-
}
-
None => String::from("No DID found"),
+
/// Landing page showing currently unlocked days and a login button
+
async fn home_handler() -> impl IntoResponse {
+
//TODO make a helper function for this since it is similar to the middleware
+
let now = chrono::Utc::now();
+
let mut unlocked: Vec<u8> = Vec::new();
+
+
//HACK Yeah I don't like it either - bt
+
let prod: bool = env::var("PROD")
+
.map(|val| val == "true")
+
.unwrap_or_else(|_| true);
+
if prod {
+
if now.month() == 12 {
+
let today = now.day().min(25);
+
for d in 1..=today {
+
unlocked.push(d as u8);
}
}
-
Err(err) => {
-
log::error!("Error: {err}");
-
err.to_string()
+
} else {
+
for d in 1..=25 {
+
unlocked.push(d as u8);
}
}
-
}
-
async fn logged_in_test_handler(
-
State(oauth_client): State<OAuthClientType>,
-
session: Session,
-
) -> String {
-
let session_did = session.get::<String>("did").await.unwrap().unwrap();
-
let did = Did::new(session_did).expect("failed to parse did");
-
let client = oauth_client.restore(&did).await.unwrap();
-
let agent = Agent::new(client);
-
let notifications = agent
-
.api
-
.app
-
.bsky
-
.notification
-
.list_notifications(
-
atrium_api::app::bsky::notification::list_notifications::ParametersData {
-
cursor: None,
-
limit: None,
-
priority: None,
-
reasons: None,
-
seen_at: None,
-
}
-
.into(),
-
)
-
.await
-
.unwrap();
-
-
notifications
-
.notifications
-
.iter()
-
.map(|n| {
-
format!(
-
"Author: {} Reason: {}, URI: {}",
-
n.author.handle.as_str(),
-
n.reason,
-
n.uri
-
)
-
})
-
.collect::<Vec<String>>()
-
.join("\n")
-
}
-
-
async fn redis_test_handler(
-
CacheConnection(mut conn): CacheConnection<'_>,
-
) -> Result<String, (StatusCode, String)> {
-
let result: String = conn
-
.fetch_redis("foo")
-
.await
-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
-
Ok(result)
+
HtmlTemplate(HomeTemplate {
+
title: "at://advent",
+
unlocked_days: unlocked,
+
})
}
+111
web/src/redis_session_store.rs
···
+
use async_trait::async_trait;
+
use bb8::Pool;
+
use bb8_redis::{RedisConnectionManager, redis::cmd};
+
use shared::cache::{Cache, TOWER_SESSION_KEY, create_prefixed_key};
+
use std::fmt::Display;
+
use std::fmt::{Debug, Formatter};
+
use tower_sessions::SessionStore;
+
use tower_sessions::session::{Id, Record};
+
use tower_sessions::session_store::{Error, Result as StoreResult};
+
+
#[derive(Clone)]
+
pub struct RedisSessionStore {
+
cache_pool: Pool<RedisConnectionManager>,
+
}
+
+
impl RedisSessionStore {
+
pub fn new(cache_pool: Pool<RedisConnectionManager>) -> Self {
+
Self { cache_pool }
+
}
+
}
+
+
impl Debug for RedisSessionStore {
+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+
f.debug_struct("RedisSessionStore").finish()
+
}
+
}
+
+
// Small helper to convert any error into tower-sessions Backend error consistently
+
fn backend_map<E: Display>(context: &'static str) -> impl FnOnce(E) -> Error {
+
move |err| {
+
log::error!("{}: {}", context, err);
+
Error::Backend(err.to_string())
+
}
+
}
+
+
#[async_trait]
+
impl SessionStore for RedisSessionStore {
+
async fn create(&self, session_record: &mut Record) -> StoreResult<()> {
+
//TODO i don't think there is an issue with overwriting the session here since it's redis and should be no collision
+
//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
+
self.save(session_record).await
+
}
+
+
async fn save(&self, session_record: &Record) -> StoreResult<()> {
+
let id_as_str: String = session_record.id.0.to_string();
+
let key = create_prefixed_key(TOWER_SESSION_KEY, id_as_str.as_str());
+
+
// Get a redis connection
+
let conn = self
+
.cache_pool
+
.get()
+
.await
+
.map_err(backend_map("There was an error connecting to the cache"))?;
+
+
// Set value with TTL based on expiry_date
+
let expiry = session_record.expiry_date;
+
let now = std::time::SystemTime::now()
+
.duration_since(std::time::UNIX_EPOCH)
+
.unwrap_or_default()
+
.as_secs() as i64;
+
let ttl_secs = expiry.unix_timestamp().saturating_sub(now).max(0) as usize;
+
+
//Helper for some cache functions
+
let mut cache = Cache { redis_pool: conn };
+
cache
+
.write_to_cache_with_seconds(&key, &session_record, ttl_secs as u64)
+
.await
+
.map_err(backend_map("There was an error saving the session"))?;
+
+
Ok(())
+
}
+
+
async fn load(&self, session_id: &Id) -> StoreResult<Option<Record>> {
+
let id_as_str: String = session_id.0.to_string();
+
let key = create_prefixed_key(TOWER_SESSION_KEY, id_as_str.as_str());
+
+
let conn = self
+
.cache_pool
+
.get()
+
.await
+
.map_err(backend_map("There was an error connecting to the cache"))?;
+
let mut cache = Cache { redis_pool: conn };
+
+
let val = match cache.fetch_redis_json_object::<Option<Record>>(&key).await {
+
Ok(Some(record)) => Ok(record),
+
Ok(None) => Ok(None),
+
Err(err) => Err(err),
+
}
+
.map_err(backend_map("There was an error loading the session"))?;
+
Ok(val)
+
}
+
+
async fn delete(&self, session_id: &Id) -> StoreResult<()> {
+
let id_as_str: String = session_id.0.to_string();
+
let key = create_prefixed_key(TOWER_SESSION_KEY, id_as_str.as_str());
+
+
let mut conn = self
+
.cache_pool
+
.get()
+
.await
+
.map_err(backend_map("There was an error connecting to the cache"))?;
+
+
let _: usize = cmd("DEL")
+
.arg(&key)
+
.query_async::<usize>(&mut *conn)
+
.await
+
.map_err(backend_map("There was an error deleting the session"))?;
+
+
Ok(())
+
}
+
}
+148
web/src/session.rs
···
+
/// A bunch of syntax sugar too make strongly typed sessions for Axum's sessions store
+
use crate::error_response;
+
use axum::extract::FromRequestParts;
+
use axum::http::StatusCode;
+
use axum::http::request::Parts;
+
use axum::response::Response;
+
use serde::{Deserialize, Serialize};
+
use std::collections::HashMap;
+
use std::fmt;
+
use tower_sessions::Session;
+
+
#[derive(Debug, Deserialize, Serialize, Clone)]
+
pub enum FlashMessage {
+
Success(String),
+
Error(String),
+
}
+
+
/// THis is the actual session store for axum sessions
+
#[derive(Debug, Deserialize, Serialize)]
+
struct SessionData {
+
did: Option<String>,
+
+
flash_message: HashMap<String, FlashMessage>,
+
}
+
+
impl Default for SessionData {
+
fn default() -> Self {
+
Self {
+
did: None,
+
flash_message: HashMap::new(),
+
}
+
}
+
}
+
+
pub struct AxumSessionStore {
+
session: Session,
+
data: SessionData,
+
}
+
+
/// How you actually interact with the session store
+
impl AxumSessionStore {
+
const SESSION_DATA_KEY: &'static str = "session.data";
+
+
pub fn _logged_in(&self) -> bool {
+
self.data.did.is_some()
+
}
+
+
pub async fn set_did(&mut self, did: String) -> Result<(), tower_sessions::session::Error> {
+
self.data.did = Some(did);
+
Self::update_session(&self.session, &self.data).await
+
}
+
+
pub fn get_did(&self) -> Option<String> {
+
self.data.did.clone()
+
}
+
+
///Gets the message as well as removes it from the session
+
pub async fn get_flash_message(
+
&mut self,
+
key: &str,
+
) -> Result<Option<FlashMessage>, tower_sessions::session::Error> {
+
let message = self.data.flash_message.get(key).cloned();
+
if message.is_some() {
+
self.data.flash_message.remove(key);
+
Self::update_session(&self.session, &self.data).await?
+
}
+
Ok(message)
+
}
+
+
pub async fn set_flash_message(
+
&mut self,
+
key: &str,
+
message: FlashMessage,
+
) -> Result<(), tower_sessions::session::Error> {
+
self.data.flash_message.insert(key.to_string(), message);
+
Self::update_session(&self.session, &self.data).await
+
}
+
+
/// Make sure to call this or your session won't actually be saved
+
async fn update_session(
+
session: &Session,
+
session_data: &SessionData,
+
) -> Result<(), tower_sessions::session::Error> {
+
session.insert(Self::SESSION_DATA_KEY, session_data).await
+
}
+
}
+
+
impl fmt::Display for AxumSessionStore {
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
f.debug_struct("SessionStore")
+
.field("did", &self.data.did)
+
.finish()
+
}
+
}
+
+
impl<S> FromRequestParts<S> for AxumSessionStore
+
where
+
S: Send + Sync,
+
{
+
type Rejection = (StatusCode, &'static str);
+
+
async fn from_request_parts(req: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
+
let session = Session::from_request_parts(req, state).await?;
+
+
let data: SessionData = session
+
.get(Self::SESSION_DATA_KEY)
+
.await
+
.unwrap()
+
.unwrap_or_default();
+
+
Ok(Self { session, data })
+
}
+
}
+
+
/// Helper wrapper for handling http responses if theres an error
+
pub async fn set_flash_message(
+
session: &mut AxumSessionStore,
+
key: &str,
+
flash_message: FlashMessage,
+
) -> Result<(), Response> {
+
session
+
.set_flash_message(key, flash_message)
+
.await
+
.map_err(|err| {
+
log::error!("Error setting flash message: {err}");
+
error_response(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error setting flash message",
+
)
+
})
+
}
+
+
/// Helper wrapper for handling http responses if theres an error
+
pub async fn get_flash_message(
+
session: &mut AxumSessionStore,
+
key: &str,
+
) -> Result<Option<FlashMessage>, Response> {
+
match session.get_flash_message(key).await {
+
Ok(message) => Ok(message),
+
Err(err) => {
+
log::error!("Error getting flash message: {err}");
+
Err(error_response(
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Error getting flash message",
+
))
+
}
+
}
+
}
+17
web/src/templates/day.rs
···
+
use crate::session::FlashMessage;
+
use askama::Template;
+
+
#[derive(Template)]
+
#[template(path = "day.askama.html")]
+
pub struct DayTemplate {
+
pub title: String,
+
pub day: u8,
+
pub challenge_one_text: String,
+
pub challenge_one_completed: bool,
+
pub challenge_two_text: Option<String>,
+
pub challenge_two_completed: bool,
+
+
//If these are set than it was a redirect from checking the challenge.
+
pub part_one_submit_message: Option<FlashMessage>,
+
pub part_two_submit_message: Option<FlashMessage>,
+
}
+8
web/src/templates/error.rs
···
+
use askama::Template;
+
+
#[derive(Template)]
+
#[template(path = "error.askama.html")]
+
pub struct ErrorTemplate<'a> {
+
pub title: &'a str,
+
pub message: &'a str,
+
}
+8
web/src/templates/home.rs
···
+
use askama::Template;
+
+
#[derive(Template)]
+
#[template(path = "index.askama.html")]
+
pub struct HomeTemplate {
+
pub title: &'static str,
+
pub unlocked_days: Vec<u8>,
+
}
+8
web/src/templates/login.rs
···
+
use askama::Template;
+
+
#[derive(Template)]
+
#[template(path = "login.askama.html")]
+
pub struct LoginTemplate<'a> {
+
pub title: &'a str,
+
pub error: Option<String>,
+
}
+33
web/src/templates/mod.rs
···
+
use askama::Template;
+
use axum::http::StatusCode;
+
use axum::response::{Html, IntoResponse, Response};
+
+
pub mod day;
+
pub mod error;
+
pub mod login;
+
pub mod home;
+
+
pub struct HtmlTemplate<T>(pub T);
+
+
/// Allows us to convert Askama HTML templates into valid HTML
+
/// for axum to serve in the response.
+
impl<T> IntoResponse for HtmlTemplate<T>
+
where
+
T: Template,
+
{
+
fn into_response(self) -> Response {
+
// Attempt to render the template with askama
+
match self.0.render() {
+
// If we're able to successfully parse and aggregate the template, serve it
+
Ok(html) => Html(html).into_response(),
+
// If we're not, return an error or some bit of fallback HTML
+
Err(err) => {
+
log::error!("Failed to render template: {}", err);
+
IntoResponse::into_response((
+
StatusCode::INTERNAL_SERVER_ERROR,
+
"Failed to render the HTML Template",
+
))
+
}
+
}
+
}
+
}
+54 -25
web/src/unlock.rs
···
-
use axum::extract::{Path, Request, State};
+
use axum::extract::{Path, Request};
use axum::http;
use axum::{
middleware,
response::{self, IntoResponse},
};
-
use std::time;
-
-
#[derive(Clone)]
-
pub struct Unlock {
-
start: time::Instant,
-
interval: time::Duration,
-
}
-
-
impl Unlock {
-
pub fn new(start: time::Instant, interval: time::Duration) -> Self {
-
Self { start, interval }
-
}
-
}
+
use chrono::Datelike;
pub async fn unlock(
-
Path(day): Path<u32>,
-
State(unlocker): State<Unlock>,
+
Path(mut day): Path<u8>,
request: Request,
next: middleware::Next,
) -> response::Response {
-
let deadline = unlocker.start + unlocker.interval * day;
-
let now = time::Instant::now();
-
if now >= deadline {
+
if day == 0 {
+
day = 1;
+
}
+
+
if day == 69 {
+
return (http::StatusCode::FORBIDDEN, "Really?").into_response();
+
}
+
+
if day == 42 {
+
return (
+
http::StatusCode::FORBIDDEN,
+
"Oh, you have all the answers, huh?",
+
)
+
.into_response();
+
}
+
+
if day > 25 || day < 1 {
+
return (
+
http::StatusCode::FORBIDDEN,
+
"This isn't even a day in the advent calendar????",
+
)
+
.into_response();
+
}
+
+
let now = chrono::Utc::now();
+
let current_day = now.day();
+
let month = now.month();
+
+
if month != 12 {
+
return (
+
http::StatusCode::FORBIDDEN,
+
"It's not December yet! NO PEAKING",
+
)
+
.into_response();
+
}
+
+
//Show any day previous to the current day and current day
+
if day as u32 <= current_day {
return next.run(request).await;
}
-
let time_remaining = deadline.saturating_duration_since(now);
-
let error_response = axum::Json(serde_json::json!({
-
"error": "Route Locked",
-
"time_remaining_seconds": time_remaining.as_secs(),
-
}));
-
(http::StatusCode::FORBIDDEN, error_response).into_response()
+
(
+
http::StatusCode::FORBIDDEN,
+
"Now just hold on a minute. It ain't time yet.",
+
)
+
.into_response()
+
+
// Just commenting out for now if we do want a json endpoint and i forgot easiest way to return it
+
// let error_response = axum::Json(serde_json::json!({
+
// "error": "Route Locked",
+
// "time_remaining_seconds": time_remaining.as_secs(),
+
// }));
+
+
// (http::StatusCode::FORBIDDEN, error_response).into_response()
}
+55
web/templates/day.askama.html
···
+
{% extends "layout.askama.html" %}
+
+
{% block content %}
+
<h2 class="text-xl">Day {{ day }}</h2>
+
<p>Part 1:</p>
+
<article class="prose">{{ challenge_one_text | safe }}</article>
+
<br/>
+
{% if let Some(msg) = part_one_submit_message %}
+
{% match msg %}
+
{% when FlashMessage::Success with (success) %}
+
<span class="text-success">{{success}}</span>
+
{% when FlashMessage::Error with (error) %}
+
<div class="alert alert-error mb-2">{{error}}</div>
+
{% endmatch %}
+
{% endif %}
+
+
{% if !challenge_one_completed %}
+
<form method="post" action="/day/{{ day }}">
+
<!-- TODO will be optional prob load from a markdown variable? -->
+
<!-- <input type="text" name="verification_code_one" placeholder="Enter Part 1 code" class="input input-bordered mr-2"/>-->
+
<button class="btn" type="submit">Check answer</button>
+
</form>
+
{% else %}
+
<span class="text-success">Great work, you've completed Part 1</span>
+
{% endif %}
+
+
+
{% if let Some(challenge_two_text) = challenge_two_text %}
+
<hr class="my-4"/>
+
<p>Part 2:</p>
+
<article class="prose">{{ challenge_two_text | safe }}</article>
+
{% if let Some(msg) = part_two_submit_message %}
+
{% match msg %}
+
{% when FlashMessage::Success with (success) %}
+
<span class="text-success">{{success}}</span>
+
{% when FlashMessage::Error with (error) %}
+
<div class="alert alert-error mb-2">{{error}}</div>
+
{% endmatch %}
+
{% endif %}
+
{% if !challenge_two_completed %}
+
<form method="post" action="/day/{{ day }}">
+
<!-- TODO will be optional prob load from a markdown variable? -->
+
<!-- <input type="text" name="verification_code_two" placeholder="Enter Part 2 code" class="input input-bordered mr-2"/>-->
+
<button class="btn" type="submit">Check answer</button>
+
</form>
+
{% endif %}
+
+
{% endif %}
+
+
{% if challenge_one_completed && challenge_two_completed %}
+
<br>
+
<span class="text-success">Great work, you've completed all the challenges for today! Come back tomorrow for more at 00:00 UTC</span>
+
{% endif %}
+
+
{% endblock %}
+7
web/templates/error.askama.html
···
+
{% extends "layout.askama.html" %}
+
+
{% block content %}
+
<h2 class="text-xl">An error occurred</h2>
+
<p class="mt-2">{{ message }}</p>
+
<p class="mt-4"><a class="link" href="?">Return</a></p>
+
{% endblock %}
+19
web/templates/index.askama.html
···
+
{% extends "layout.askama.html" %}
+
+
{% block content %}
+
<div class="flex items-center justify-between mb-6">
+
<h2 class="text-xl font-semibold">Welcome</h2>
+
<a class="btn" href="/login">Login</a>
+
</div>
+
+
<p class="mb-3">Unlocked days:</p>
+
{% if unlocked_days.len() == 0 %}
+
<div class="alert">No days are unlocked yet. Please check back in December!</div>
+
{% else %}
+
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
+
{% for d in unlocked_days %}
+
<a class="btn" href="/day/{{ d }}">Day {{ d }}</a>
+
{% endfor %}
+
</div>
+
{% endif %}
+
{% endblock %}
+25
web/templates/layout.askama.html
···
+
<!DOCTYPE html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8">
+
<meta name="viewport" content="width=device-width, initial-scale=1">
+
<title>{{ title }}</title>
+
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
+
+
<style>
+
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; }
+
header { background: #0d47a1; color: #fff; padding: 1rem; }
+
main { padding: 1rem; max-width: 900px; margin: 0 auto; }
+
+
</style>
+
</head>
+
<body>
+
<header>
+
<h1>at://advent</h1>
+
</header>
+
<main>
+
{% block content %}{% endblock %}
+
</main>
+
</body>
+
</html>
+27
web/templates/login.askama.html
···
+
{% extends "layout.askama.html" %}
+
+
{% block content %}
+
<h2 class="text-xl mb-4">Login</h2>
+
<p class="mb-4">Enter your Bluesky handle to continue.</p>
+
{% if let Some(err) = error %}
+
<div class="alert alert-error mb-4">{{ err }}</div>
+
{% endif %}
+
<form id="handle-form" class="flex gap-2" onsubmit="return goToHandle(event)">
+
<input id="handle-input" type="text" name="handle" placeholder="you.bsky.social" class="input input-bordered"
+
required/>
+
<button class="btn" type="submit">Continue</button>
+
</form>
+
<script>
+
+
function goToHandle(e) {
+
e.preventDefault();
+
const input = document.getElementById('handle-input');
+
const handle = (input.value || '').trim();
+
if (!handle) return false;
+
const encoded = encodeURIComponent(handle);
+
// Redirect to /handle/{handle}
+
window.location.href = `/login/${encoded}`;
+
return false;
+
}
+
</script>
+
{% endblock %}