Your one-stop-cake-shop for everything Freshly Baked has to offer

refactor(m): split into separate files

I'm about to add regex support to menu, but before I do it might be nice
to tidy things out a bit. Doing that will allow me to make something
that is a bit more obvious how it extends the existing code

+462 -382
+99
menu/src/auth.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + // 3 + // SPDX-License-Identifier: MIT 4 + 5 + use axum::{ 6 + http::{HeaderMap, StatusCode}, 7 + response::{ErrorResponse, IntoResponse}, 8 + }; 9 + use std::collections::HashMap; 10 + use tower_sessions::Session; 11 + use uuid::Uuid; 12 + 13 + const TOKEN_KEY: &str = "token"; 14 + 15 + struct NotAuthenticated; 16 + impl IntoResponse for NotAuthenticated { 17 + fn into_response(self) -> axum::response::Response { 18 + return (StatusCode::UNAUTHORIZED, "Access over Tailscale only").into_response(); 19 + } 20 + } 21 + 22 + struct MissingToken; 23 + impl IntoResponse for MissingToken { 24 + fn into_response(self) -> axum::response::Response { 25 + return ( 26 + StatusCode::FORBIDDEN, 27 + "There's no session here - try going back and trying again?", 28 + ) 29 + .into_response(); 30 + } 31 + } 32 + 33 + struct InvalidToken; 34 + impl IntoResponse for InvalidToken { 35 + fn into_response(self) -> axum::response::Response { 36 + return ( 37 + StatusCode::FORBIDDEN, 38 + "This session is invalid - possible CSRF?", 39 + ) 40 + .into_response(); 41 + } 42 + } 43 + 44 + pub(crate) fn ensure_authenticated<'a>( 45 + headers: &'a HeaderMap, 46 + #[cfg(debug_assertions)] params: &'a HashMap<String, String>, 47 + ) -> Result<&'a str, ErrorResponse> { 48 + if let Some(user) = headers 49 + .get("X-Webauth-Login") 50 + .and_then(|header| header.to_str().ok()) 51 + { 52 + return Ok(user); 53 + } 54 + 55 + #[cfg(debug_assertions)] 56 + { 57 + use crate::DEVELOPMENT; 58 + 59 + if *DEVELOPMENT.get().unwrap() { 60 + if let Some(user) = params.get("dev_auth_as") { 61 + return Ok(user); 62 + } 63 + } 64 + } 65 + 66 + Err(NotAuthenticated {}.into()) 67 + } 68 + 69 + pub(crate) async fn get_token(session: &Session) -> String { 70 + let maybe_token = session.get(TOKEN_KEY).await.unwrap(); 71 + if let Some(token) = maybe_token { 72 + token 73 + } else { 74 + let new_token = Uuid::new_v4().to_string(); 75 + session.insert(TOKEN_KEY, &new_token).await.unwrap(); 76 + new_token 77 + } 78 + } 79 + 80 + pub(crate) async fn ensure_token<'a>( 81 + session: &Session, 82 + params: &'a HashMap<String, String>, 83 + ) -> Result<(), ErrorResponse> { 84 + let maybe_token: Option<String> = session.get(TOKEN_KEY).await.unwrap(); 85 + 86 + let Some(token) = maybe_token else { 87 + return Err(MissingToken {}.into()); 88 + }; 89 + 90 + if token == "" { 91 + return Err(MissingToken {}.into()); 92 + } 93 + 94 + if params.get("token").is_some_and(|val| *val == token) { 95 + return Ok(()); 96 + } 97 + 98 + Err(InvalidToken {}.into()) 99 + }
+159
menu/src/direct.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + // 3 + // SPDX-License-Identifier: MIT 4 + 5 + use axum::response::{Redirect, Result}; 6 + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 7 + use std::ops::DerefMut; 8 + 9 + use crate::{CreationResult, DeletionResult, STATE}; 10 + 11 + struct Link { 12 + from: String, 13 + to: String, 14 + owner: String, 15 + } 16 + 17 + pub(crate) async fn get_redirect(default_location: &str, go: &str) -> Redirect { 18 + let redirect = sqlx::query!( 19 + r#"SELECT ("to") FROM direct WHERE "from" = $1 LIMIT 1"#, 20 + go.to_lowercase() 21 + ) 22 + .fetch_one( 23 + STATE 24 + .get() 25 + .expect("Server must be initialized before processing connections") 26 + .sqlx_connection 27 + .lock() 28 + .await 29 + .deref_mut(), 30 + ) 31 + .await; 32 + 33 + if let Ok(record) = redirect { 34 + Redirect::temporary(&record.to) 35 + } else { 36 + Redirect::temporary(&("".to_string() + default_location + go)) 37 + } 38 + } 39 + 40 + pub(crate) async fn get_link_table(token: &str) -> Result<String> { 41 + let links_query = sqlx::query_as!( 42 + Link, 43 + r#"SELECT "from", "to", "owner" FROM direct ORDER BY direct."from" ASC"#, 44 + ) 45 + .fetch_all( 46 + STATE 47 + .get() 48 + .expect("Server must be initialized before processing connections") 49 + .sqlx_connection 50 + .lock() 51 + .await 52 + .deref_mut(), 53 + ) 54 + .await; 55 + 56 + let Ok(links) = links_query else { 57 + return Err("Failed to query database".into()); 58 + }; 59 + 60 + let mut rows = vec![]; 61 + 62 + for link in links { 63 + let from_attribute = html_escape::encode_quoted_attribute(&link.from); 64 + let from = html_escape::encode_text(&link.from); 65 + let from_url = utf8_percent_encode(&link.from, NON_ALPHANUMERIC); 66 + let to_attribute = html_escape::encode_quoted_attribute(&link.to); 67 + let to = html_escape::encode_text(&link.to); 68 + let to_url = utf8_percent_encode(&link.to, NON_ALPHANUMERIC); 69 + let owner = html_escape::encode_text(&link.owner); 70 + 71 + rows.push(format!( 72 + r#"<tr> 73 + <td><a href="{from_attribute}">{from}</a></td> 74 + <td><a href="{to_attribute}">{to}</a></td> 75 + <td>{owner}</td> 76 + <td>(<a href="/_/create?from={from_url}&to={to_url}&current={to_url}">edit</a>) (<a href="/_/delete/do?from={from_url}&current={to_url}&token={token}">delete</a>)</td> 77 + </tr>"#, 78 + )); 79 + } 80 + 81 + let link_table = rows.join("\n"); 82 + 83 + Ok(link_table) 84 + } 85 + 86 + pub(crate) async fn create( 87 + from: &str, 88 + to: &str, 89 + owner: &str, 90 + current: Option<&String>, 91 + ) -> CreationResult { 92 + println!("Attempting to make go/{} -> {}", from, to); 93 + 94 + let create_call = sqlx::query!( 95 + r#" 96 + WITH insertion AS ( 97 + INSERT INTO direct ("from", "to", "owner") 98 + VALUES ($1, $2, $3) 99 + ON CONFLICT ("from") 100 + DO UPDATE SET "to" = EXCLUDED.to, "owner" = EXCLUDED.owner WHERE direct.to = $4 101 + RETURNING direct.from 102 + ) 103 + SELECT direct.to FROM direct 104 + WHERE direct.from NOT IN (SELECT insertion.from FROM insertion) AND direct.from = $1 105 + "#, // Insert our URL, return a row with the same from that weren't updated (i.e. a conflict) 106 + from.to_lowercase(), 107 + to, 108 + owner, 109 + current, 110 + ) 111 + .fetch_optional( 112 + STATE 113 + .get() 114 + .expect("Server must be initialized before processing connections") 115 + .sqlx_connection 116 + .lock() 117 + .await 118 + .deref_mut(), 119 + ) 120 + .await; 121 + 122 + if let Ok(None) = &create_call { 123 + CreationResult::Success 124 + } else if let Ok(Some(conflict)) = create_call { 125 + CreationResult::Conflict(conflict.to) 126 + } else { 127 + CreationResult::Failure 128 + } 129 + } 130 + 131 + pub(crate) async fn delete(from: &str, current: &str) -> DeletionResult { 132 + println!("Attempting to delete go/{} -> {}", from, current); 133 + 134 + let delete_call = sqlx::query!( 135 + r#"DELETE FROM direct WHERE direct.from = $1 AND direct.to = $2"#, 136 + from.to_lowercase(), 137 + current, 138 + ) 139 + .execute( 140 + STATE 141 + .get() 142 + .expect("Server must be initialized before processing connections") 143 + .sqlx_connection 144 + .lock() 145 + .await 146 + .deref_mut(), 147 + ) 148 + .await; 149 + 150 + let Ok(delete_result) = &delete_call else { 151 + return DeletionResult::Failure; 152 + }; 153 + 154 + if delete_result.rows_affected() > 0 { 155 + DeletionResult::Success 156 + } else { 157 + DeletionResult::NotFound 158 + } 159 + }
+34 -382
menu/src/main.rs
··· 1 1 // SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 2 // 3 3 // SPDX-License-Identifier: MIT 4 + mod auth; 5 + mod direct; 6 + mod static_html; 7 + 4 8 use axum::{ 5 9 Router, ServiceExt, 6 10 body::Body, 7 11 extract::{Path, Query, Request}, 8 12 http::{HeaderMap, Response, StatusCode}, 9 - response::{ErrorResponse, Html, IntoResponse, Redirect, Result}, 13 + response::{Html, IntoResponse, Redirect, Result}, 10 14 routing::get, 11 15 }; 12 16 use include_dir::{Dir, include_dir}; 13 17 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 14 - use regex::Captures; 15 18 use sqlx::{Connection, PgConnection}; 16 19 use tower_sessions::{MemoryStore, Session, SessionManagerLayer}; 17 - use uuid::Uuid; 18 20 19 - #[cfg(debug_assertions)] 20 - use std::fs; 21 - 22 - use std::{collections::HashMap, env, ops::DerefMut, sync::OnceLock}; 21 + use std::{collections::HashMap, env, sync::OnceLock}; 23 22 use tokio::{ 24 23 sync::Mutex, 25 24 time::{Duration, sleep}, ··· 28 27 use tower_layer::Layer; 29 28 use tower_serve_static; 30 29 30 + use crate::{ 31 + auth::{ensure_authenticated, ensure_token}, 32 + static_html::{StaticPageType, handle_static_page}, 33 + }; 34 + 31 35 static PUBLIC_DIR: Dir<'static> = include_dir!("src/html/public"); 32 36 33 37 #[cfg(debug_assertions)] 34 38 static DEVELOPMENT: OnceLock<bool> = OnceLock::new(); 35 39 36 - const TOKEN_KEY: &str = "token"; 37 - 38 - #[derive(Clone)] 39 - enum AnyString<'a> { 40 - Owned(String), 41 - Ref(&'a str), 42 - } 43 - 44 - fn template_html<'a>( 45 - html: String, 46 - replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>>, 47 - ) -> String { 48 - let re = regex_static::static_regex!(r"\{([a-z_]+)(?::([a-z_]+))?\}"); 49 - re.replace_all(&html, |captures: &Captures| { 50 - let replacement_name = &captures[1]; 51 - let replacement = replacements 52 - .get(replacement_name) 53 - .and_then(|maybe_replacement| maybe_replacement()) 54 - .unwrap_or_else(|| AnyString::Ref("")) 55 - .clone(); 56 - let replacement_owned = match replacement { 57 - AnyString::Owned(owned) => owned, 58 - AnyString::Ref(str) => str.to_string(), 59 - }; 60 - 61 - match captures.get(2).and_then(|m| Some(m.as_str())) { 62 - Some("dangerous_raw") => replacement_owned, 63 - Some("attribute") => { 64 - html_escape::encode_quoted_attribute(&replacement_owned).to_string() 65 - } 66 - Some("url") => utf8_percent_encode(&replacement_owned, NON_ALPHANUMERIC).to_string(), 67 - None => html_escape::encode_text(&replacement_owned).to_string(), 68 - Some(_) => "UNKNOWN_MATCH_TYPE".to_string(), 69 - } 70 - }) 71 - .to_string() 72 - } 73 - 74 - /// include_str, but if DEVELOPMENT then the string is dynamically fetched for easy reloading 75 - /// to support this, the string is *always* owned. 76 - #[cfg(debug_assertions)] 77 - macro_rules! include_String_dynamic { 78 - ($file:expr $(,)?) => { 79 - if (*DEVELOPMENT.get().unwrap()) { 80 - fs::read_to_string("src/".to_string() + $file) 81 - .expect(format!("Unable to read file {}", $file).as_str()) 82 - } else { 83 - include_str!($file).to_owned() 84 - } 85 - }; 86 - } 87 - #[cfg(not(debug_assertions))] 88 - macro_rules! include_String_dynamic { 89 - ($file:expr $(,)?) => { 90 - include_str!($file).to_owned() 91 - }; 92 - } 93 - 94 40 #[derive(Debug)] 95 41 struct State { 96 42 sqlx_connection: Mutex<PgConnection>, ··· 112 58 return "go"; 113 59 } 114 60 115 - async fn get_redirect(default_location: &str, go: &str) -> Redirect { 116 - let redirect = sqlx::query!( 117 - r#"SELECT ("to") FROM direct WHERE "from" = $1 LIMIT 1"#, 118 - go.to_lowercase() 119 - ) 120 - .fetch_one( 121 - STATE 122 - .get() 123 - .expect("Server must be initialized before processing connections") 124 - .sqlx_connection 125 - .lock() 126 - .await 127 - .deref_mut(), 128 - ) 129 - .await; 130 - 131 - if let Ok(record) = redirect { 132 - Redirect::temporary(&record.to) 133 - } else { 134 - Redirect::temporary(&("".to_string() + default_location + go)) 135 - } 136 - } 137 - 138 61 async fn get_redirect_base(go: &str) -> Redirect { 139 - get_redirect( 62 + direct::get_redirect( 140 63 "/_/create?from=", 141 64 &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(), 142 65 ) ··· 144 67 } 145 68 146 69 async fn get_redirect_search(go: &str) -> Redirect { 147 - get_redirect( 70 + direct::get_redirect( 148 71 "https://kagi.com/search?q=", 149 72 &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(), 150 73 ) ··· 172 95 } 173 96 } 174 97 175 - struct Link { 176 - from: String, 177 - to: String, 178 - owner: String, 179 - } 180 - 181 - #[derive(Clone)] 182 - enum StaticPageType { 183 - Create, 184 - CreateConflict, 185 - CreateFailure, 186 - CreateSuccess, 187 - DeleteFailure, 188 - DeleteSuccess, 189 - Index, 98 + enum CreationResult { 99 + Success, 100 + Conflict(String), 101 + Failure, 190 102 } 191 103 192 - async fn handle_static_page<'a>( 193 - page_type: StaticPageType, 194 - session: Session, 195 - params: &'a HashMap<String, String>, 196 - headers: &'a HeaderMap, 197 - ) -> Result<Html<String>> { 198 - let auth_required = match page_type { 199 - _ => true, 200 - }; 201 - 202 - let html = match page_type { 203 - StaticPageType::Create => include_String_dynamic!("./html/create.html"), 204 - StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 205 - StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 206 - StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 207 - StaticPageType::DeleteFailure => include_String_dynamic!("./html/delete/failure.html"), 208 - StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 209 - StaticPageType::Index => include_String_dynamic!("./html/index.html"), 210 - }; 211 - 212 - let username = if auth_required { 213 - Some(ensure_authenticated( 214 - headers, 215 - #[cfg(debug_assertions)] 216 - params, 217 - )?) 218 - } else { 219 - None 220 - }; 221 - 222 - let mut replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>> = 223 - HashMap::new(); 224 - replacements.insert( 225 - "host", 226 - Box::new(|| { 227 - Some(AnyString::Ref(clean_host( 228 - headers 229 - .get("host") 230 - .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 231 - .unwrap_or_else(|| "go"), 232 - ))) 233 - }), 234 - ); 235 - replacements.insert( 236 - "from", 237 - Box::new(|| { 238 - params 239 - .get("from") 240 - .and_then(|from| Some(AnyString::Ref(from.as_str()))) 241 - }), 242 - ); 243 - replacements.insert( 244 - "to", 245 - Box::new(|| { 246 - params 247 - .get("to") 248 - .and_then(|to| Some(AnyString::Ref(to.as_str()))) 249 - }), 250 - ); 251 - replacements.insert( 252 - "current", 253 - Box::new(|| { 254 - params 255 - .get("current") 256 - .and_then(|current| Some(AnyString::Ref(current.as_str()))) 257 - }), 258 - ); 259 - replacements.insert( 260 - "username", 261 - Box::new(move || username.and_then(|name| Some(AnyString::Ref(name)))), 262 - ); 263 - 264 - let token: String = { 265 - let maybe_token = session.get(TOKEN_KEY).await.unwrap(); 266 - if let Some(token) = maybe_token { 267 - token 268 - } else { 269 - let new_token = Uuid::new_v4().to_string(); 270 - session.insert(TOKEN_KEY, &new_token).await.unwrap(); 271 - new_token 272 - } 273 - }; 274 - 275 - if matches!(page_type, StaticPageType::Index) { 276 - let links_query = sqlx::query_as!( 277 - Link, 278 - r#"SELECT "from", "to", "owner" FROM direct ORDER BY direct."from" ASC"#, 279 - ) 280 - .fetch_all( 281 - STATE 282 - .get() 283 - .expect("Server must be initialized before processing connections") 284 - .sqlx_connection 285 - .lock() 286 - .await 287 - .deref_mut(), 288 - ) 289 - .await; 290 - 291 - let Ok(links) = links_query else { 292 - return Err("Failed to query database".into()); 293 - }; 294 - 295 - let mut rows = vec![]; 296 - 297 - for link in links { 298 - let from_attribute = html_escape::encode_quoted_attribute(&link.from); 299 - let from = html_escape::encode_text(&link.from); 300 - let from_url = utf8_percent_encode(&link.from, NON_ALPHANUMERIC); 301 - let to_attribute = html_escape::encode_quoted_attribute(&link.to); 302 - let to = html_escape::encode_text(&link.to); 303 - let to_url = utf8_percent_encode(&link.to, NON_ALPHANUMERIC); 304 - let owner = html_escape::encode_text(&link.owner); 305 - 306 - rows.push(format!( 307 - r#"<tr> 308 - <td><a href="{from_attribute}">{from}</a></td> 309 - <td><a href="{to_attribute}">{to}</a></td> 310 - <td>{owner}</td> 311 - <td>(<a href="/_/create?from={from_url}&to={to_url}&current={to_url}">edit</a>) (<a href="/_/delete/do?from={from_url}&current={to_url}&token={token}">delete</a>)</td> 312 - </tr>"#, 313 - )); 314 - } 315 - 316 - let link_table = rows.join("\n"); 317 - replacements.insert( 318 - "links", 319 - Box::new(move || Some(AnyString::Owned(link_table.clone()))), 320 - ); 321 - } 322 - 323 - replacements.insert( 324 - "token", 325 - Box::new(move || Some(AnyString::Owned(token.clone()))), 326 - ); 327 - 328 - let result = template_html(html, replacements); 329 - Ok(Html(result)) 104 + enum DeletionResult { 105 + Success, 106 + NotFound, 107 + Failure, 330 108 } 331 109 332 110 async fn handle_create_page( ··· 374 152 handle_static_page(StaticPageType::DeleteFailure, session, &params, &headers).await 375 153 } 376 154 377 - struct NotAuthenticated; 378 - impl IntoResponse for NotAuthenticated { 379 - fn into_response(self) -> axum::response::Response { 380 - return (StatusCode::UNAUTHORIZED, "Access over Tailscale only").into_response(); 381 - } 382 - } 383 - 384 - struct MissingToken; 385 - impl IntoResponse for MissingToken { 386 - fn into_response(self) -> axum::response::Response { 387 - return ( 388 - StatusCode::FORBIDDEN, 389 - "There's no session here - try going back and trying again?", 390 - ) 391 - .into_response(); 392 - } 393 - } 394 - 395 - struct InvalidToken; 396 - impl IntoResponse for InvalidToken { 397 - fn into_response(self) -> axum::response::Response { 398 - return ( 399 - StatusCode::FORBIDDEN, 400 - "This session is invalid - possible CSRF?", 401 - ) 402 - .into_response(); 403 - } 404 - } 405 - 406 - fn ensure_authenticated<'a>( 407 - headers: &'a HeaderMap, 408 - #[cfg(debug_assertions)] params: &'a HashMap<String, String>, 409 - ) -> Result<&'a str, ErrorResponse> { 410 - if let Some(user) = headers 411 - .get("X-Webauth-Login") 412 - .and_then(|header| header.to_str().ok()) 413 - { 414 - return Ok(user); 415 - } 416 - 417 - #[cfg(debug_assertions)] 418 - { 419 - if *DEVELOPMENT.get().unwrap() { 420 - if let Some(user) = params.get("dev_auth_as") { 421 - return Ok(user); 422 - } 423 - } 424 - } 425 - 426 - Err(NotAuthenticated {}.into()) 427 - } 428 - 429 - async fn ensure_token<'a>( 430 - session: &Session, 431 - params: &'a HashMap<String, String>, 432 - ) -> Result<(), ErrorResponse> { 433 - let maybe_token: Option<String> = session.get(TOKEN_KEY).await.unwrap(); 434 - 435 - let Some(token) = maybe_token else { 436 - return Err(MissingToken {}.into()); 437 - }; 438 - 439 - if token == "" { 440 - return Err(MissingToken {}.into()); 441 - } 442 - 443 - if params.get("token").is_some_and(|val| *val == token) { 444 - return Ok(()); 445 - } 446 - 447 - Err(InvalidToken {}.into()) 448 - } 449 - 450 155 #[axum::debug_handler] 451 156 async fn handle_create_do( 452 157 session: Session, ··· 463 168 let from = params.get("from").ok_or("Missing from query")?; 464 169 let to = params.get("to").ok_or("Missing to query")?; 465 170 466 - println!("Attempting to make go/{} -> {}", from, to); 467 - 468 - let create_call = sqlx::query!( 469 - r#" 470 - WITH insertion AS ( 471 - INSERT INTO direct ("from", "to", "owner") 472 - VALUES ($1, $2, $3) 473 - ON CONFLICT ("from") 474 - DO UPDATE SET "to" = EXCLUDED.to, "owner" = EXCLUDED.owner WHERE direct.to = $4 475 - RETURNING direct.from 476 - ) 477 - SELECT direct.to FROM direct 478 - WHERE direct.from NOT IN (SELECT insertion.from FROM insertion) AND direct.from = $1 479 - "#, // Insert our URL, return a row with the same from that weren't updated (i.e. a conflict) 480 - from.to_lowercase(), 481 - to, 482 - owner, 483 - params.get("current"), 484 - ) 485 - .fetch_optional( 486 - STATE 487 - .get() 488 - .expect("Server must be initialized before processing connections") 489 - .sqlx_connection 490 - .lock() 491 - .await 492 - .deref_mut(), 493 - ) 494 - .await; 495 - 496 - if let Ok(None) = &create_call { 497 - Ok(Redirect::to(&format!( 171 + match direct::create(from, to, owner, params.get("current")).await { 172 + CreationResult::Success => Ok(Redirect::to(&format!( 498 173 "/_/create/success?from={}&to={}", 499 174 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 500 175 utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 501 176 )) 502 - .into_response()) 503 - } else if let Ok(Some(conflict)) = create_call { 504 - Ok(Redirect::to(&format!( 177 + .into_response()), 178 + CreationResult::Conflict(conflict) => Ok(Redirect::to(&format!( 505 179 "/_/create/conflict?from={}&to={}&current={}", 506 180 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 507 181 utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 508 - utf8_percent_encode(&conflict.to, NON_ALPHANUMERIC).to_string(), 182 + utf8_percent_encode(&conflict, NON_ALPHANUMERIC).to_string(), 509 183 )) 510 - .into_response()) 511 - } else { 512 - Ok(Redirect::to(&format!( 184 + .into_response()), 185 + CreationResult::Failure => Ok(Redirect::to(&format!( 513 186 "/_/create/failure?from={}&to={}", 514 187 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 515 188 utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 516 189 )) 517 - .into_response()) 190 + .into_response()), 518 191 } 519 192 } 520 193 ··· 534 207 let from = params.get("from").ok_or("Missing from query")?; 535 208 let current = params.get("current").ok_or("Missing current query")?; 536 209 537 - println!("Attempting to delete go/{} -> {}", from, current); 538 - 539 - let delete_call = sqlx::query!( 540 - r#"DELETE FROM direct WHERE direct.from = $1 AND direct.to = $2"#, 541 - from.to_lowercase(), 542 - current, 543 - ) 544 - .execute( 545 - STATE 546 - .get() 547 - .expect("Server must be initialized before processing connections") 548 - .sqlx_connection 549 - .lock() 550 - .await 551 - .deref_mut(), 552 - ) 553 - .await; 554 - 555 - if let Ok(delete_result) = &delete_call 556 - && delete_result.rows_affected() > 0 557 - { 558 - Ok(Redirect::to(&format!( 210 + match direct::delete(from, current).await { 211 + DeletionResult::Success => Ok(Redirect::to(&format!( 559 212 "/_/delete/success?from={}&current={}", 560 213 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 561 214 utf8_percent_encode(&current, NON_ALPHANUMERIC).to_string(), 562 215 )) 563 - .into_response()) 564 - } else { 565 - Ok(Redirect::to(&format!( 216 + .into_response()), 217 + _ => Ok(Redirect::to(&format!( 566 218 "/_/delete/failure?from={}&to={}", 567 219 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 568 220 utf8_percent_encode(&current, NON_ALPHANUMERIC).to_string(), 569 221 )) 570 - .into_response()) 222 + .into_response()), 571 223 } 572 224 } 573 225
+170
menu/src/static_html.rs
··· 1 + // SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + // 3 + // SPDX-License-Identifier: MIT 4 + use axum::{http::HeaderMap, response::Html, response::Result}; 5 + use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 6 + use regex::Captures; 7 + use std::collections::HashMap; 8 + #[cfg(debug_assertions)] 9 + use std::fs; 10 + use tower_sessions::Session; 11 + 12 + use crate::{auth::get_token, clean_host, direct, ensure_authenticated}; 13 + 14 + /// include_str, but if DEVELOPMENT then the string is dynamically fetched for easy reloading 15 + /// to support this, the string is *always* owned. 16 + #[cfg(debug_assertions)] 17 + macro_rules! include_String_dynamic { 18 + ($file:expr $(,)?) => { 19 + if (*crate::DEVELOPMENT.get().unwrap()) { 20 + fs::read_to_string("src/".to_string() + $file) 21 + .expect(format!("Unable to read file {}", $file).as_str()) 22 + } else { 23 + include_str!($file).to_owned() 24 + } 25 + }; 26 + } 27 + #[cfg(not(debug_assertions))] 28 + macro_rules! include_String_dynamic { 29 + ($file:expr $(,)?) => { 30 + include_str!($file).to_owned() 31 + }; 32 + } 33 + 34 + #[derive(Clone)] 35 + enum AnyString<'a> { 36 + Owned(String), 37 + Ref(&'a str), 38 + } 39 + 40 + #[derive(Clone)] 41 + pub(crate) enum StaticPageType { 42 + Create, 43 + CreateConflict, 44 + CreateFailure, 45 + CreateSuccess, 46 + DeleteFailure, 47 + DeleteSuccess, 48 + Index, 49 + } 50 + 51 + pub(crate) async fn handle_static_page<'a>( 52 + page_type: StaticPageType, 53 + session: Session, 54 + params: &'a HashMap<String, String>, 55 + headers: &'a HeaderMap, 56 + ) -> Result<Html<String>> { 57 + let auth_required = match page_type { 58 + _ => true, 59 + }; 60 + 61 + let html = match page_type { 62 + StaticPageType::Create => include_String_dynamic!("./html/create.html"), 63 + StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 64 + StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 65 + StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 66 + StaticPageType::DeleteFailure => include_String_dynamic!("./html/delete/failure.html"), 67 + StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 68 + StaticPageType::Index => include_String_dynamic!("./html/index.html"), 69 + }; 70 + 71 + let username = if auth_required { 72 + Some(ensure_authenticated( 73 + headers, 74 + #[cfg(debug_assertions)] 75 + params, 76 + )?) 77 + } else { 78 + None 79 + }; 80 + 81 + let mut replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>> = 82 + HashMap::new(); 83 + replacements.insert( 84 + "host", 85 + Box::new(|| { 86 + Some(AnyString::Ref(clean_host( 87 + headers 88 + .get("host") 89 + .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 90 + .unwrap_or_else(|| "go"), 91 + ))) 92 + }), 93 + ); 94 + replacements.insert( 95 + "from", 96 + Box::new(|| { 97 + params 98 + .get("from") 99 + .and_then(|from| Some(AnyString::Ref(from.as_str()))) 100 + }), 101 + ); 102 + replacements.insert( 103 + "to", 104 + Box::new(|| { 105 + params 106 + .get("to") 107 + .and_then(|to| Some(AnyString::Ref(to.as_str()))) 108 + }), 109 + ); 110 + replacements.insert( 111 + "current", 112 + Box::new(|| { 113 + params 114 + .get("current") 115 + .and_then(|current| Some(AnyString::Ref(current.as_str()))) 116 + }), 117 + ); 118 + replacements.insert( 119 + "username", 120 + Box::new(move || username.and_then(|name| Some(AnyString::Ref(name)))), 121 + ); 122 + 123 + let token = get_token(&session).await; 124 + if matches!(page_type, StaticPageType::Index) { 125 + let link_table = direct::get_link_table(&token).await?; 126 + 127 + replacements.insert( 128 + "links", 129 + Box::new(move || Some(AnyString::Owned(link_table.clone()))), 130 + ); 131 + } 132 + 133 + replacements.insert( 134 + "token", 135 + Box::new(move || Some(AnyString::Owned(token.clone()))), 136 + ); 137 + 138 + let result = template_html(html, replacements); 139 + Ok(Html(result)) 140 + } 141 + 142 + fn template_html<'a>( 143 + html: String, 144 + replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>>, 145 + ) -> String { 146 + let re = regex_static::static_regex!(r"\{([a-z_]+)(?::([a-z_]+))?\}"); 147 + re.replace_all(&html, |captures: &Captures| { 148 + let replacement_name = &captures[1]; 149 + let replacement = replacements 150 + .get(replacement_name) 151 + .and_then(|maybe_replacement| maybe_replacement()) 152 + .unwrap_or_else(|| AnyString::Ref("")) 153 + .clone(); 154 + let replacement_owned = match replacement { 155 + AnyString::Owned(owned) => owned, 156 + AnyString::Ref(str) => str.to_string(), 157 + }; 158 + 159 + match captures.get(2).and_then(|m| Some(m.as_str())) { 160 + Some("dangerous_raw") => replacement_owned, 161 + Some("attribute") => { 162 + html_escape::encode_quoted_attribute(&replacement_owned).to_string() 163 + } 164 + Some("url") => utf8_percent_encode(&replacement_owned, NON_ALPHANUMERIC).to_string(), 165 + None => html_escape::encode_text(&replacement_owned).to_string(), 166 + Some(_) => "UNKNOWN_MATCH_TYPE".to_string(), 167 + } 168 + }) 169 + .to_string() 170 + }