1use crate::{
2 templates::HtmlTemplate, templates::error::ErrorTemplate, templates::home::HomeTemplate,
3};
4use atrium_identity::{
5 did::{CommonDidResolver, CommonDidResolverConfig, DEFAULT_PLC_DIRECTORY_URL},
6 handle::{AtprotoHandleResolver, AtprotoHandleResolverConfig},
7};
8use atrium_oauth::{
9 AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClient, OAuthClientConfig,
10 OAuthResolverConfig, Scope,
11};
12use axum::{
13 Router,
14 http::StatusCode,
15 middleware,
16 response::IntoResponse,
17 response::Response,
18 routing::{get, post},
19};
20use bb8_redis::RedisConnectionManager;
21use chrono::Datelike;
22use dotenv::dotenv;
23use redis::AsyncCommands;
24use shared::{
25 HandleResolver, OAuthClientType, atrium::dns_resolver::HickoryDnsTxtResolver,
26 atrium::stores::AtriumSessionStore, atrium::stores::AtriumStateStore,
27};
28use sqlx::{PgPool, postgres::PgPoolOptions};
29use std::{
30 env,
31 net::{IpAddr, Ipv4Addr, SocketAddr},
32 sync::Arc,
33 time,
34};
35use time::Duration;
36use tower_http::trace::TraceLayer;
37use tower_sessions::{SessionManagerLayer, cookie::SameSite};
38use tracing_subscriber::EnvFilter;
39
40mod handlers;
41
42extern crate dotenv;
43
44mod extractors;
45mod redis_session_store;
46mod session;
47mod templates;
48mod unlock;
49
50#[derive(Clone)]
51struct AppState {
52 postgres_pool: PgPool,
53 redis_pool: bb8::Pool<RedisConnectionManager>,
54 oauth_client: OAuthClientType,
55 //Used to get did to handle leaving because I figured we'd need it
56 _handle_resolver: HandleResolver,
57}
58
59fn oauth_scopes() -> Vec<Scope> {
60 vec![
61 Scope::Known(KnownScope::Atproto),
62 //Gives full CRUD to the codes.advent.* collection
63 Scope::Unknown("repo:codes.advent.*".to_string()),
64 ]
65}
66
67fn error_response(status: StatusCode, message: &str) -> Response {
68 IntoResponse::into_response((
69 status,
70 HtmlTemplate(ErrorTemplate {
71 title: "at://advent - Error",
72 message,
73 }),
74 ))
75}
76
77#[tokio::main]
78async fn main() -> Result<(), Box<dyn std::error::Error>> {
79 dotenv().ok();
80
81 //Sets up logging/tracing
82 tracing_subscriber::fmt()
83 .with_env_filter(
84 EnvFilter::try_from_default_env()
85 .or_else(|_| EnvFilter::try_new("info,axum_tracing_example=error,tower_http=warn"))
86 .unwrap(),
87 )
88 .init();
89
90 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 7878);
91 let host = addr.ip();
92 let port = addr.port();
93 let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
94
95 //sqlx pool
96 let database_url =
97 env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment or .env");
98
99 // set up a postgres connection pool
100 let postgres_pool = PgPoolOptions::new()
101 .max_connections(5)
102 .acquire_timeout(Duration::from_secs(3))
103 .connect(&database_url)
104 .await
105 .expect("can't connect to database");
106
107 // redis pool setup
108 let redis_url =
109 env::var("REDIS_URL").expect("REDIS_URL must be set in the environment or .env");
110 let manager = RedisConnectionManager::new(redis_url.clone()).unwrap();
111 let redis_pool = bb8::Pool::builder().build(manager).await.unwrap();
112 //cam be deleted, just an example for the test endpoint
113 {
114 // ping the database before starting
115 let mut conn = redis_pool.get().await.unwrap();
116 conn.set::<&str, &str, ()>("foo", "bar").await.unwrap();
117 let result: String = conn.get("foo").await.unwrap();
118 assert_eq!(result, "bar");
119 }
120
121 //Atrium/atproto setup
122
123 //Create a new handle resolver for the home page
124 let http_client = Arc::new(DefaultHttpClient::default());
125
126 let handle_resolver = CommonDidResolver::new(CommonDidResolverConfig {
127 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
128 http_client: http_client.clone(),
129 });
130 let handle_resolver = Arc::new(handle_resolver);
131
132 // Create a new OAuth client
133 let http_client = Arc::new(DefaultHttpClient::default());
134 let config = OAuthClientConfig {
135 client_metadata: AtprotoLocalhostClientMetadata {
136 redirect_uris: Some(vec![String::from(format!(
137 //This must match the endpoint you use the callback function
138 "http://{host}:{port}/oauth/callback"
139 ))]),
140 scopes: Some(oauth_scopes()),
141 },
142 keys: None,
143 resolver: OAuthResolverConfig {
144 did_resolver: CommonDidResolver::new(CommonDidResolverConfig {
145 plc_directory_url: DEFAULT_PLC_DIRECTORY_URL.to_string(),
146 http_client: http_client.clone(),
147 }),
148 handle_resolver: AtprotoHandleResolver::new(AtprotoHandleResolverConfig {
149 dns_txt_resolver: HickoryDnsTxtResolver::default(),
150 http_client: http_client.clone(),
151 }),
152 authorization_server_metadata: Default::default(),
153 protected_resource_metadata: Default::default(),
154 },
155 state_store: AtriumStateStore::new(redis_pool.clone()),
156 session_store: AtriumSessionStore::new(redis_pool.clone()),
157 };
158 let client = Arc::new(OAuthClient::new(config).expect("failed to create OAuth client"));
159
160 let session_store = redis_session_store::RedisSessionStore::new(redis_pool.clone());
161 let session_layer = SessionManagerLayer::new(session_store)
162 //Set to lax so session id cookie can be set on redirect
163 .with_same_site(SameSite::Lax)
164 .with_secure(false);
165
166 let app_state = AppState {
167 postgres_pool,
168 redis_pool,
169 oauth_client: client,
170 _handle_resolver: handle_resolver,
171 };
172 //HACK Yeah I don't like it either - bt
173 let prod: bool = env::var("PROD")
174 .map(|val| val == "true")
175 .unwrap_or_else(|_| true);
176 log::info!("listening on http://{}", addr);
177 let app = Router::new()
178 .route("/", get(home_handler))
179 .route(
180 "/day/{id}",
181 match prod {
182 true => get(handlers::day::view_day_handler)
183 .route_layer(middleware::from_fn(unlock::unlock)),
184 false => get(handlers::day::view_day_handler),
185 },
186 )
187 .route(
188 "/day/{id}",
189 match prod {
190 true => post(handlers::day::post_day_handler)
191 .route_layer(middleware::from_fn(unlock::unlock)),
192 false => post(handlers::day::post_day_handler),
193 },
194 )
195 .route("/login", get(handlers::auth::login_page_handler))
196 .route("/handle", get(handlers::auth::handle_root_handler))
197 .route("/login/{handle}", get(handlers::auth::login_handle))
198 .route(
199 "/oauth/callback",
200 get(handlers::auth::oauth_callback_handler),
201 )
202 .layer(session_layer)
203 .with_state(app_state)
204 .layer(TraceLayer::new_for_http());
205 axum::serve(listener, app).await?;
206 Ok(())
207}
208
209/// Landing page showing currently unlocked days and a login button
210async fn home_handler() -> impl IntoResponse {
211 //TODO make a helper function for this since it is similar to the middleware
212 let now = chrono::Utc::now();
213 let mut unlocked: Vec<u8> = Vec::new();
214
215 //HACK Yeah I don't like it either - bt
216 let prod: bool = env::var("PROD")
217 .map(|val| val == "true")
218 .unwrap_or_else(|_| true);
219 if prod {
220 if now.month() == 12 {
221 let today = now.day().min(25);
222 for d in 1..=today {
223 unlocked.push(d as u8);
224 }
225 }
226 } else {
227 for d in 1..=25 {
228 unlocked.push(d as u8);
229 }
230 }
231
232 HtmlTemplate(HomeTemplate {
233 title: "at://advent",
234 unlocked_days: unlocked,
235 })
236}