+2
-1
.env.template
+2
-1
.env.template
+426
-16
Cargo.lock
+426
-16
Cargo.lock
···
48
48
]
49
49
50
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]]
51
93
name = "async-compression"
52
94
version = "0.4.27"
53
95
source = "registry+https://github.com/rust-lang/crates.io-index"
···
161
203
"jose-jwa",
162
204
"jose-jwk",
163
205
"p256",
164
-
"rand",
206
+
"rand 0.8.5",
165
207
"reqwest",
166
208
"serde",
167
209
"serde_html_form",
···
286
328
checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
287
329
288
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]]
289
340
name = "bb8"
290
341
version = "0.9.0"
291
342
source = "registry+https://github.com/rust-lang/crates.io-index"
···
325
376
]
326
377
327
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]]
328
389
name = "bumpalo"
329
390
version = "3.17.0"
330
391
source = "registry+https://github.com/rust-lang/crates.io-index"
···
524
585
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
525
586
dependencies = [
526
587
"generic-array",
527
-
"rand_core",
588
+
"rand_core 0.6.4",
528
589
"subtle",
529
590
"zeroize",
530
591
]
···
537
598
dependencies = [
538
599
"generic-array",
539
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",
540
636
]
541
637
542
638
[[package]]
···
601
697
]
602
698
603
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]]
604
731
name = "digest"
605
732
version = "0.10.7"
606
733
source = "registry+https://github.com/rust-lang/crates.io-index"
···
669
796
"ff",
670
797
"generic-array",
671
798
"group",
672
-
"rand_core",
799
+
"rand_core 0.6.4",
673
800
"sec1",
674
801
"subtle",
675
802
"zeroize",
···
747
874
source = "registry+https://github.com/rust-lang/crates.io-index"
748
875
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
749
876
dependencies = [
750
-
"rand_core",
877
+
"rand_core 0.6.4",
751
878
"subtle",
752
879
]
753
880
···
961
1088
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
962
1089
963
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]]
964
1104
name = "group"
965
1105
version = "0.13.0"
966
1106
source = "registry+https://github.com/rust-lang/crates.io-index"
967
1107
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
968
1108
dependencies = [
969
1109
"ff",
970
-
"rand_core",
1110
+
"rand_core 0.6.4",
971
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",
972
1128
]
973
1129
974
1130
[[package]]
···
1025
1181
"idna",
1026
1182
"ipnet",
1027
1183
"once_cell",
1028
-
"rand",
1184
+
"rand 0.8.5",
1029
1185
"thiserror 1.0.69",
1030
1186
"tinyvec",
1031
1187
"tokio",
···
1046
1202
"lru-cache",
1047
1203
"once_cell",
1048
1204
"parking_lot",
1049
-
"rand",
1205
+
"rand 0.8.5",
1050
1206
"resolv-conf",
1051
1207
"smallvec",
1052
1208
"thiserror 1.0.69",
···
1079
1235
checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
1080
1236
dependencies = [
1081
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",
1082
1247
]
1083
1248
1084
1249
[[package]]
···
1188
1353
]
1189
1354
1190
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]]
1191
1380
name = "iana-time-zone"
1192
1381
version = "0.1.63"
1193
1382
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1298
1487
]
1299
1488
1300
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]]
1301
1496
name = "idna"
1302
1497
version = "1.0.3"
1303
1498
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1539
1734
]
1540
1735
1541
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]]
1542
1746
name = "matchers"
1543
1747
version = "0.1.0"
1544
1748
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1688
1892
"num-integer",
1689
1893
"num-iter",
1690
1894
"num-traits",
1691
-
"rand",
1895
+
"rand 0.8.5",
1692
1896
"smallvec",
1693
1897
"zeroize",
1694
1898
]
···
1720
1924
]
1721
1925
1722
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]]
1723
1942
name = "num-traits"
1724
1943
version = "0.2.19"
1725
1944
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1851
2070
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
1852
2071
1853
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]]
1854
2117
name = "pin-project-lite"
1855
2118
version = "0.2.16"
1856
2119
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1888
2151
version = "0.3.32"
1889
2152
source = "registry+https://github.com/rust-lang/crates.io-index"
1890
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"
1891
2160
1892
2161
[[package]]
1893
2162
name = "portable-atomic"
···
1959
2228
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1960
2229
dependencies = [
1961
2230
"libc",
1962
-
"rand_chacha",
1963
-
"rand_core",
2231
+
"rand_chacha 0.3.1",
2232
+
"rand_core 0.6.4",
2233
+
]
2234
+
2235
+
[[package]]
2236
+
name = "rand"
2237
+
version = "0.9.2"
2238
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2239
+
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
2240
+
dependencies = [
2241
+
"rand_chacha 0.9.0",
2242
+
"rand_core 0.9.3",
1964
2243
]
1965
2244
1966
2245
[[package]]
···
1970
2249
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1971
2250
dependencies = [
1972
2251
"ppv-lite86",
1973
-
"rand_core",
2252
+
"rand_core 0.6.4",
2253
+
]
2254
+
2255
+
[[package]]
2256
+
name = "rand_chacha"
2257
+
version = "0.9.0"
2258
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2259
+
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
2260
+
dependencies = [
2261
+
"ppv-lite86",
2262
+
"rand_core 0.9.3",
1974
2263
]
1975
2264
1976
2265
[[package]]
···
1980
2269
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1981
2270
dependencies = [
1982
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",
1983
2281
]
1984
2282
1985
2283
[[package]]
···
2125
2423
"num-traits",
2126
2424
"pkcs1",
2127
2425
"pkcs8",
2128
-
"rand_core",
2426
+
"rand_core 0.6.4",
2129
2427
"signature",
2130
2428
"spki",
2131
2429
"subtle",
···
2133
2431
]
2134
2432
2135
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]]
2136
2469
name = "rustc-demangle"
2137
2470
version = "0.1.25"
2138
2471
source = "registry+https://github.com/rust-lang/crates.io-index"
2139
2472
checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
2140
2473
2141
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]]
2142
2481
name = "rustc_version"
2143
2482
version = "0.4.1"
2144
2483
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2180
2519
version = "1.0.20"
2181
2520
source = "registry+https://github.com/rust-lang/crates.io-index"
2182
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
+
]
2183
2531
2184
2532
[[package]]
2185
2533
name = "schannel"
···
2361
2709
name = "shared"
2362
2710
version = "0.1.0"
2363
2711
dependencies = [
2712
+
"async-trait",
2364
2713
"atrium-api",
2365
2714
"atrium-common",
2366
2715
"atrium-identity",
···
2368
2717
"axum",
2369
2718
"bb8",
2370
2719
"bb8-redis",
2720
+
"handlebars",
2371
2721
"hickory-resolver",
2372
2722
"log",
2723
+
"markdown",
2724
+
"rand 0.9.2",
2725
+
"rust-embed",
2373
2726
"serde",
2374
2727
"serde_json",
2375
2728
"sqlx",
···
2398
2751
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
2399
2752
dependencies = [
2400
2753
"digest",
2401
-
"rand_core",
2754
+
"rand_core 0.6.4",
2402
2755
]
2403
2756
2404
2757
[[package]]
···
2571
2924
"memchr",
2572
2925
"once_cell",
2573
2926
"percent-encoding",
2574
-
"rand",
2927
+
"rand 0.8.5",
2575
2928
"rsa",
2576
2929
"serde",
2577
2930
"sha1",
···
2610
2963
"md-5",
2611
2964
"memchr",
2612
2965
"once_cell",
2613
-
"rand",
2966
+
"rand 0.8.5",
2614
2967
"serde",
2615
2968
"serde_json",
2616
2969
"sha2",
···
2665
3018
]
2666
3019
2667
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]]
2668
3027
name = "subtle"
2669
3028
version = "2.6.1"
2670
3029
source = "registry+https://github.com/rust-lang/crates.io-index"
···
2983
3342
"futures",
2984
3343
"http",
2985
3344
"parking_lot",
2986
-
"rand",
3345
+
"rand 0.8.5",
2987
3346
"serde",
2988
3347
"serde_json",
2989
3348
"thiserror 2.0.12",
···
3090
3449
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
3091
3450
3092
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]]
3093
3458
name = "unicode-bidi"
3094
3459
version = "0.3.18"
3095
3460
source = "registry+https://github.com/rust-lang/crates.io-index"
3096
3461
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
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"
3097
3468
3098
3469
[[package]]
3099
3470
name = "unicode-ident"
···
3134
3505
]
3135
3506
3136
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]]
3137
3514
name = "utf8_iter"
3138
3515
version = "1.0.4"
3139
3516
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3169
3546
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
3170
3547
3171
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]]
3172
3559
name = "want"
3173
3560
version = "0.3.1"
3174
3561
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3273
3660
name = "web"
3274
3661
version = "0.1.0"
3275
3662
dependencies = [
3663
+
"askama",
3664
+
"async-trait",
3276
3665
"atrium-api",
3277
3666
"atrium-common",
3278
3667
"atrium-identity",
···
3280
3669
"axum",
3281
3670
"bb8",
3282
3671
"bb8-redis",
3672
+
"chrono",
3283
3673
"dotenv",
3674
+
"hypertext",
3284
3675
"log",
3676
+
"pool",
3285
3677
"redis",
3286
3678
"serde",
3287
3679
"serde_json",
···
3345
3737
version = "0.4.0"
3346
3738
source = "registry+https://github.com/rust-lang/crates.io-index"
3347
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
+
]
3348
3749
3349
3750
[[package]]
3350
3751
name = "winapi-x86_64-pc-windows-gnu"
···
3610
4011
version = "0.52.6"
3611
4012
source = "registry+https://github.com/rust-lang/crates.io-index"
3612
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
+
]
3613
4023
3614
4024
[[package]]
3615
4025
name = "winreg"
+5
-1
Cargo.toml
+5
-1
Cargo.toml
···
9
9
atrium-api = "0.25.4"
10
10
atrium-identity = "0.1.5"
11
11
atrium-oauth = "0.1.3"
12
+
chrono = { version = "0.4", features = ["serde", "now"] }
12
13
hickory-resolver = "0.24.1"
13
14
dotenv = "0.15.0"
14
15
log = "0.4.24"
···
20
21
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
21
22
bb8 = "0.9.0"
22
23
bb8-redis = "0.24.0"
23
-
redis = "0.32.4"
24
+
redis = "0.32.4"
25
+
tokio = { version = "1.46.1", features = ["full"] }
26
+
markdown = "1.0.0"
27
+
rust-embed = { version = "8.7.2", features = ["include-exclude"] }
+4
-4
README.md
+4
-4
README.md
···
1
1
# at://advent
2
2
3
-
An upcoming Holiday Advent Calendar for atprotocl theme challenges
3
+
An upcoming Holiday Advent Calendar for atprotocol theme challenges
4
4
5
-
[Read the intinal draft plans here!](docs/initial_plans.md)
5
+
[Read the initial draft plans here!](docs/initial_plans.md)
6
6
7
7
# Quick setup
8
8
···
10
10
11
11
## Project break down
12
12
13
-
- [./web](./web) - A axum web service to host the advent website
13
+
- [./web](./web) - An axum web service to host the advent website
14
14
- [./listener](./listener) - A JetStream listener
15
15
- [./shared](./shared) - Shared code between the 2, dbs, cache, etc
16
16
···
21
21
## Setup
22
22
23
23
1. Make a copy of [.env.template](.env.template) and name it [.env](.env)
24
-
2. Install postgres/redis, or can use docker compose with `docker compose -f compose.dev.yml -up`
24
+
2. Install postgres/redis, or can use docker compose with `docker compose -f compose.dev.yml up`
25
25
3. Install sqlx-cli `cargo install sqlx-cli`
26
26
4. Run `sqlx migrate run`
27
27
+2
-1
compose.dev.yml
+2
-1
compose.dev.yml
···
1
1
services:
2
2
postgres:
3
3
image: postgres:latest
4
+
restart: unless-stopped
4
5
environment:
5
6
POSTGRES_USER: ${DB_USER}
6
7
POSTGRES_PASSWORD: ${DB_PASSWORD}
···
15
16
- advent-network
16
17
redis:
17
18
image: 'redis:alpine'
18
-
restart: always
19
+
restart: unless-stopped
19
20
ports:
20
21
- '${FORWARD_REDIS_PORT:-6379}:6379'
21
22
volumes:
+17
migrations/20250904073900_create_challenges.sql
+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);
+9
-2
web/Cargo.toml
+9
-2
web/Cargo.toml
···
9
9
atrium-identity.workspace = true
10
10
atrium-oauth.workspace = true
11
11
axum.workspace = true
12
+
chrono.workspace = true
12
13
bb8.workspace = true
13
14
bb8-redis.workspace = true
14
15
dotenv.workspace = true
···
17
18
serde_json.workspace = true
18
19
shared.workspace = true
19
20
sqlx.workspace = true
20
-
tokio = { version = "1.46.1", features = ["full"] }
21
+
tokio.workspace = true
21
22
tower-http = { version = "0.6.6", features = ["trace"] }
22
23
tower-sessions = "0.14.0"
23
24
tracing.workspace = true
24
25
tracing-subscriber.workspace = true
25
-
serde = { version = "1.0.219", features = ["derive"] }
26
+
serde = { version = "1.0.219", features = ["derive"] }
27
+
askama = "0.14"
28
+
async-trait = "0.1.88"
29
+
hypertext = "0.12.1"
30
+
31
+
[build-dependencies]
32
+
pool = "0.1.4"
+112
web/src/handlers/auth.rs
+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
+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).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).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
+
}
+99
-172
web/src/main.rs
+99
-172
web/src/main.rs
···
1
-
use atrium_api::agent::Agent;
2
-
use atrium_api::types::string::Did;
3
-
use atrium_identity::did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL};
4
-
use atrium_identity::handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig};
1
+
use crate::{
2
+
templates::HtmlTemplate, templates::error::ErrorTemplate, templates::home::HomeTemplate,
3
+
};
4
+
use atrium_identity::{
5
+
did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL},
6
+
handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
7
+
};
5
8
use atrium_oauth::{
6
-
AtprotoLocalhostClientMetadata, AuthorizeOptions, CallbackParams, DefaultHttpClient,
7
-
KnownScope, OAuthClient, OAuthClientConfig, OAuthResolverConfig, Scope,
9
+
AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClient, OAuthClientConfig,
10
+
OAuthResolverConfig, Scope,
8
11
};
9
-
use axum::extract::{Query, State};
10
-
use axum::http::StatusCode;
11
-
use axum::{Json, Router, extract::Path, middleware, routing::get};
12
+
use axum::{
13
+
Router,
14
+
http::StatusCode,
15
+
middleware,
16
+
response::IntoResponse,
17
+
response::Response,
18
+
routing::{get, post},
19
+
};
12
20
use bb8_redis::RedisConnectionManager;
21
+
use chrono::Datelike;
13
22
use dotenv::dotenv;
14
23
use redis::AsyncCommands;
15
-
use shared::atrium::dns_resolver::HickoryDnsTxtResolver;
16
-
use shared::atrium::stores::{AtriumSessionStore, AtriumStateStore};
17
-
use shared::cache::CacheConnection;
18
-
use shared::models::db_models::TestModel;
19
-
use sqlx::PgPool;
20
-
use sqlx::postgres::PgPoolOptions;
21
-
use std::sync::Arc;
24
+
use shared::{
25
+
HandleResolver, OAuthClientType, atrium::dns_resolver::HickoryDnsTxtResolver,
26
+
atrium::stores::AtriumSessionStore, atrium::stores::AtriumStateStore,
27
+
};
28
+
use sqlx::{PgPool, postgres::PgPoolOptions};
22
29
use std::{
23
30
env,
24
31
net::{IpAddr, Ipv4Addr, SocketAddr},
32
+
sync::Arc,
25
33
time,
26
34
};
27
35
use time::Duration;
28
36
use tower_http::trace::TraceLayer;
29
-
use tower_sessions::{MemoryStore, Session, SessionManagerLayer};
37
+
use tower_sessions::{SessionManagerLayer, cookie::SameSite};
30
38
use tracing_subscriber::EnvFilter;
31
39
40
+
mod handlers;
41
+
32
42
extern crate dotenv;
33
43
34
44
mod extractors;
45
+
mod redis_session_store;
46
+
mod session;
47
+
mod templates;
35
48
mod unlock;
36
49
37
50
#[derive(Clone)]
···
39
52
postgres_pool: PgPool,
40
53
redis_pool: bb8::Pool<RedisConnectionManager>,
41
54
oauth_client: OAuthClientType,
42
-
//Used to get did to handle leaving cause I figured we'd need it
55
+
//Used to get did to handle leaving because I figured we'd need it
43
56
_handle_resolver: HandleResolver,
44
57
}
45
58
46
-
/// OAuthClientType to make it easier to access the OAuthClient in web requests
47
-
type OAuthClientType = Arc<
48
-
OAuthClient<
49
-
AtriumStateStore,
50
-
AtriumSessionStore,
51
-
CommonDidResolver<DefaultHttpClient>,
52
-
AtprotoHandleResolver<HickoryDnsTxtResolver, DefaultHttpClient>,
53
-
>,
54
-
>;
59
+
fn oauth_scopes() -> Vec<Scope> {
60
+
vec![
61
+
Scope::Known(KnownScope::Atproto),
62
+
//Gives full CRUD to the codes.advent.* collection
63
+
Scope::Unknown("repo:codes.advent.*".to_string()),
64
+
]
65
+
}
55
66
56
-
/// HandleResolver type to make it easier to access the resolver in web requests
57
-
type HandleResolver = Arc<CommonDidResolver<DefaultHttpClient>>;
67
+
fn error_response(status: StatusCode, message: &str) -> Response {
68
+
IntoResponse::into_response((
69
+
status,
70
+
HtmlTemplate(ErrorTemplate {
71
+
title: "at://advent - Error",
72
+
message,
73
+
}),
74
+
))
75
+
}
58
76
59
77
#[tokio::main]
60
78
async fn main() -> Result<(), Box<dyn std::error::Error>> {
···
73
91
let host = addr.ip();
74
92
let port = addr.port();
75
93
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
94
81
95
//sqlx pool
82
96
let database_url =
···
123
137
//This must match the endpoint you use the callback function
124
138
"http://{host}:{port}/oauth/callback"
125
139
))]),
126
-
scopes: Some(vec![
127
-
Scope::Known(KnownScope::Atproto),
128
-
Scope::Known(KnownScope::TransitionGeneric),
129
-
]),
140
+
scopes: Some(oauth_scopes()),
130
141
},
131
142
keys: None,
132
143
resolver: OAuthResolverConfig {
···
146
157
};
147
158
let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"));
148
159
149
-
//tower sessions setup. Using in memory for now, something is off about the redis one will implement our own via the trait using bb8 pool
150
-
// https://docs.rs/tower-sessions/latest/tower_sessions/trait.SessionStore.html
151
-
let session_store = MemoryStore::default();
152
-
let session_layer = SessionManagerLayer::new(session_store).with_secure(false);
160
+
let session_store = redis_session_store::RedisSessionStore::new(redis_pool.clone());
161
+
let session_layer = SessionManagerLayer::new(session_store)
162
+
//Set to lax so session id cookie can be set on redirect
163
+
.with_same_site(SameSite::Lax)
164
+
.with_secure(false);
153
165
154
166
let app_state = AppState {
155
167
postgres_pool,
···
157
169
oauth_client: client,
158
170
_handle_resolver: handle_resolver,
159
171
};
160
-
172
+
//HACK Yeah I don't like it either - bt
173
+
let prod: bool = env::var("PROD")
174
+
.map(|val| val == "true")
175
+
.unwrap_or_else(|_| true);
161
176
log::info!("listening on http://{}", addr);
162
177
let app = Router::new()
178
+
.route("/", get(home_handler))
163
179
.route(
164
180
"/day/{id}",
165
-
get(handler).route_layer(middleware::from_fn_with_state(
166
-
state.clone(),
167
-
unlock::unlock,
168
-
)),
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
+
},
169
186
)
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))
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
+
)
175
202
.layer(session_layer)
176
203
.with_state(app_state)
177
204
.layer(TraceLayer::new_for_http());
···
179
206
Ok(())
180
207
}
181
208
182
-
async fn handler(Path(id): Path<u32>) -> String {
183
-
format!("hello day {id}")
184
-
}
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();
185
214
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"),
215
+
//HACK Yeah I don't like it either - bt
216
+
let prod: bool = env::var("PROD")
217
+
.map(|val| val == "true")
218
+
.unwrap_or_else(|_| true);
219
+
if prod {
220
+
if now.month() == 12 {
221
+
let today = now.day().min(25);
222
+
for d in 1..=today {
223
+
unlocked.push(d as u8);
251
224
}
252
225
}
253
-
Err(err) => {
254
-
log::error!("Error: {err}");
255
-
err.to_string()
226
+
} else {
227
+
for d in 1..=25 {
228
+
unlocked.push(d as u8);
256
229
}
257
230
}
258
-
}
259
231
260
-
async fn logged_in_test_handler(
261
-
State(oauth_client): State<OAuthClientType>,
262
-
session: Session,
263
-
) -> String {
264
-
let session_did = session.get::<String>("did").await.unwrap().unwrap();
265
-
let did = Did::new(session_did).expect("failed to parse did");
266
-
let client = oauth_client.restore(&did).await.unwrap();
267
-
let agent = Agent::new(client);
268
-
let notifications = agent
269
-
.api
270
-
.app
271
-
.bsky
272
-
.notification
273
-
.list_notifications(
274
-
atrium_api::app::bsky::notification::list_notifications::ParametersData {
275
-
cursor: None,
276
-
limit: None,
277
-
priority: None,
278
-
reasons: None,
279
-
seen_at: None,
280
-
}
281
-
.into(),
282
-
)
283
-
.await
284
-
.unwrap();
285
-
286
-
notifications
287
-
.notifications
288
-
.iter()
289
-
.map(|n| {
290
-
format!(
291
-
"Author: {} Reason: {}, URI: {}",
292
-
n.author.handle.as_str(),
293
-
n.reason,
294
-
n.uri
295
-
)
296
-
})
297
-
.collect::<Vec<String>>()
298
-
.join("\n")
299
-
}
300
-
301
-
async fn redis_test_handler(
302
-
CacheConnection(mut conn): CacheConnection<'_>,
303
-
) -> Result<String, (StatusCode, String)> {
304
-
let result: String = conn
305
-
.fetch_redis("foo")
306
-
.await
307
-
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
308
-
Ok(result)
232
+
HtmlTemplate(HomeTemplate {
233
+
title: "at://advent",
234
+
unlocked_days: unlocked,
235
+
})
309
236
}
+111
web/src/redis_session_store.rs
+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
+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
+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
+8
web/src/templates/error.rs
+8
web/src/templates/home.rs
+8
web/src/templates/home.rs
+8
web/src/templates/login.rs
+8
web/src/templates/login.rs
+33
web/src/templates/mod.rs
+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 home;
8
+
pub mod login;
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
+54
-25
web/src/unlock.rs
···
1
-
use axum::extract::{Path, Request, State};
1
+
use axum::extract::{Path, Request};
2
2
use axum::http;
3
3
use axum::{
4
4
middleware,
5
5
response::{self, IntoResponse},
6
6
};
7
-
use std::time;
8
-
9
-
#[derive(Clone)]
10
-
pub struct Unlock {
11
-
start: time::Instant,
12
-
interval: time::Duration,
13
-
}
14
-
15
-
impl Unlock {
16
-
pub fn new(start: time::Instant, interval: time::Duration) -> Self {
17
-
Self { start, interval }
18
-
}
19
-
}
7
+
use chrono::Datelike;
20
8
21
9
pub async fn unlock(
22
-
Path(day): Path<u32>,
23
-
State(unlocker): State<Unlock>,
10
+
Path(mut day): Path<u8>,
24
11
request: Request,
25
12
next: middleware::Next,
26
13
) -> response::Response {
27
-
let deadline = unlocker.start + unlocker.interval * day;
28
-
let now = time::Instant::now();
29
-
if now >= deadline {
14
+
if day == 0 {
15
+
day = 1;
16
+
}
17
+
18
+
if day == 69 {
19
+
return (http::StatusCode::FORBIDDEN, "Really?").into_response();
20
+
}
21
+
22
+
if day == 42 {
23
+
return (
24
+
http::StatusCode::FORBIDDEN,
25
+
"Oh, you have all the answers, huh?",
26
+
)
27
+
.into_response();
28
+
}
29
+
30
+
if day > 25 || day < 1 {
31
+
return (
32
+
http::StatusCode::FORBIDDEN,
33
+
"This isn't even a day in the advent calendar????",
34
+
)
35
+
.into_response();
36
+
}
37
+
38
+
let now = chrono::Utc::now();
39
+
let current_day = now.day();
40
+
let month = now.month();
41
+
42
+
if month != 12 {
43
+
return (
44
+
http::StatusCode::FORBIDDEN,
45
+
"It's not December yet! NO PEAKING",
46
+
)
47
+
.into_response();
48
+
}
49
+
50
+
//Show any day previous to the current day and current day
51
+
if day as u32 <= current_day {
30
52
return next.run(request).await;
31
53
}
32
-
let time_remaining = deadline.saturating_duration_since(now);
33
-
let error_response = axum::Json(serde_json::json!({
34
-
"error": "Route Locked",
35
-
"time_remaining_seconds": time_remaining.as_secs(),
36
-
}));
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
+
// }));
37
66
38
-
(http::StatusCode::FORBIDDEN, error_response).into_response()
67
+
// (http::StatusCode::FORBIDDEN, error_response).into_response()
39
68
}
+55
web/templates/day.askama.html
+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
+7
web/templates/error.askama.html
+19
web/templates/index.askama.html
+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
+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
+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 %}