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

feat(m): add "list links" page

The main page should list all of our links, as well as allowing us to
edit or delete them. I've also rewritten the templating system here to
work off a regex, which is a lot more comfortable and also means that
templates which aren't used can avoid being evaluated (at the cost of
making a template that is used multiple times be evaluated multiple
times - perhaps we should be memoizing here?)

+371 -57
menu/.sqlx/query-735cda2fe387b6b852a03ba7ccba41353667bd505f80c1cfe3ad16b738b45ba5.json.license menu/.sqlx/query-ca0119f85393f3c3aa1907ed10aa70d9227a6d9586c3295af4dbca3ef4ea6be5.json.license
-3
menu/.sqlx/query-9eecf9b43e5458bc95fc45fe8e48d6da5edb50cdbf1e7a478faf310d6b9022ad.json.license
··· 1 - SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 - 3 - SPDX-License-Identifier: CC0-1.0
+32
menu/.sqlx/query-ca0119f85393f3c3aa1907ed10aa70d9227a6d9586c3295af4dbca3ef4ea6be5.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT \"from\", \"to\", \"owner\" FROM direct ORDER BY direct.\"from\" ASC", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "from", 9 + "type_info": "Varchar" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "to", 14 + "type_info": "Varchar" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "owner", 19 + "type_info": "Varchar" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [] 24 + }, 25 + "nullable": [ 26 + false, 27 + false, 28 + false 29 + ] 30 + }, 31 + "hash": "ca0119f85393f3c3aa1907ed10aa70d9227a6d9586c3295af4dbca3ef4ea6be5" 32 + }
+106 -15
menu/Cargo.lock
··· 3 3 version = 4 4 4 5 5 [[package]] 6 + name = "aho-corasick" 7 + version = "1.1.4" 8 + source = "registry+https://github.com/rust-lang/crates.io-index" 9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" 10 + dependencies = [ 11 + "memchr", 12 + ] 13 + 14 + [[package]] 6 15 name = "allocator-api2" 7 16 version = "0.2.21" 8 17 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 90 99 dependencies = [ 91 100 "proc-macro2", 92 101 "quote", 93 - "syn", 102 + "syn 2.0.112", 94 103 ] 95 104 96 105 [[package]] ··· 252 261 dependencies = [ 253 262 "proc-macro2", 254 263 "quote", 255 - "syn", 264 + "syn 2.0.112", 256 265 ] 257 266 258 267 [[package]] ··· 825 834 "html-escape", 826 835 "include_dir", 827 836 "percent-encoding", 837 + "regex", 838 + "regex_static", 828 839 "serde", 829 840 "sqlx", 830 841 "tokio", ··· 974 985 dependencies = [ 975 986 "proc-macro2", 976 987 "quote", 977 - "syn", 988 + "syn 2.0.112", 978 989 ] 979 990 980 991 [[package]] ··· 1101 1112 ] 1102 1113 1103 1114 [[package]] 1115 + name = "regex" 1116 + version = "1.12.2" 1117 + source = "registry+https://github.com/rust-lang/crates.io-index" 1118 + checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" 1119 + dependencies = [ 1120 + "aho-corasick", 1121 + "memchr", 1122 + "regex-automata", 1123 + "regex-syntax 0.8.8", 1124 + ] 1125 + 1126 + [[package]] 1127 + name = "regex-automata" 1128 + version = "0.4.13" 1129 + source = "registry+https://github.com/rust-lang/crates.io-index" 1130 + checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" 1131 + dependencies = [ 1132 + "aho-corasick", 1133 + "memchr", 1134 + "regex-syntax 0.8.8", 1135 + ] 1136 + 1137 + [[package]] 1138 + name = "regex-syntax" 1139 + version = "0.6.29" 1140 + source = "registry+https://github.com/rust-lang/crates.io-index" 1141 + checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 1142 + 1143 + [[package]] 1144 + name = "regex-syntax" 1145 + version = "0.8.8" 1146 + source = "registry+https://github.com/rust-lang/crates.io-index" 1147 + checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" 1148 + 1149 + [[package]] 1150 + name = "regex_static" 1151 + version = "0.1.1" 1152 + source = "registry+https://github.com/rust-lang/crates.io-index" 1153 + checksum = "6126d61c5e4b41929098f73b42fc1d257116cc95d19739248c51591f77cc0021" 1154 + dependencies = [ 1155 + "once_cell", 1156 + "regex", 1157 + "regex_static_macro", 1158 + ] 1159 + 1160 + [[package]] 1161 + name = "regex_static_impl" 1162 + version = "0.1.0" 1163 + source = "registry+https://github.com/rust-lang/crates.io-index" 1164 + checksum = "9c3755019886a70e772e6360b0b58501d75cf7dc17a53e08aa97e59ecb2c2bc5" 1165 + dependencies = [ 1166 + "proc-macro2", 1167 + "quote", 1168 + "regex-syntax 0.6.29", 1169 + "syn 1.0.109", 1170 + ] 1171 + 1172 + [[package]] 1173 + name = "regex_static_macro" 1174 + version = "0.1.0" 1175 + source = "registry+https://github.com/rust-lang/crates.io-index" 1176 + checksum = "79b15495fd034158635bc8b762a132dfc83864d6992aeda1ffabf01b03b611a1" 1177 + dependencies = [ 1178 + "proc-macro2", 1179 + "regex_static_impl", 1180 + "syn 1.0.109", 1181 + ] 1182 + 1183 + [[package]] 1104 1184 name = "ring" 1105 1185 version = "0.17.14" 1106 1186 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1213 1293 dependencies = [ 1214 1294 "proc-macro2", 1215 1295 "quote", 1216 - "syn", 1296 + "syn 2.0.112", 1217 1297 ] 1218 1298 1219 1299 [[package]] ··· 1394 1474 "quote", 1395 1475 "sqlx-core", 1396 1476 "sqlx-macros-core", 1397 - "syn", 1477 + "syn 2.0.112", 1398 1478 ] 1399 1479 1400 1480 [[package]] ··· 1417 1497 "sqlx-mysql", 1418 1498 "sqlx-postgres", 1419 1499 "sqlx-sqlite", 1420 - "syn", 1500 + "syn 2.0.112", 1421 1501 "tokio", 1422 1502 "url", 1423 1503 ] ··· 1553 1633 1554 1634 [[package]] 1555 1635 name = "syn" 1636 + version = "1.0.109" 1637 + source = "registry+https://github.com/rust-lang/crates.io-index" 1638 + checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 1639 + dependencies = [ 1640 + "proc-macro2", 1641 + "quote", 1642 + "unicode-ident", 1643 + ] 1644 + 1645 + [[package]] 1646 + name = "syn" 1556 1647 version = "2.0.112" 1557 1648 source = "registry+https://github.com/rust-lang/crates.io-index" 1558 1649 checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" ··· 1576 1667 dependencies = [ 1577 1668 "proc-macro2", 1578 1669 "quote", 1579 - "syn", 1670 + "syn 2.0.112", 1580 1671 ] 1581 1672 1582 1673 [[package]] ··· 1596 1687 dependencies = [ 1597 1688 "proc-macro2", 1598 1689 "quote", 1599 - "syn", 1690 + "syn 2.0.112", 1600 1691 ] 1601 1692 1602 1693 [[package]] ··· 1647 1738 dependencies = [ 1648 1739 "proc-macro2", 1649 1740 "quote", 1650 - "syn", 1741 + "syn 2.0.112", 1651 1742 ] 1652 1743 1653 1744 [[package]] ··· 1769 1860 dependencies = [ 1770 1861 "proc-macro2", 1771 1862 "quote", 1772 - "syn", 1863 + "syn 2.0.112", 1773 1864 ] 1774 1865 1775 1866 [[package]] ··· 1916 2007 "bumpalo", 1917 2008 "proc-macro2", 1918 2009 "quote", 1919 - "syn", 2010 + "syn 2.0.112", 1920 2011 "wasm-bindgen-shared", 1921 2012 ] 1922 2013 ··· 2210 2301 dependencies = [ 2211 2302 "proc-macro2", 2212 2303 "quote", 2213 - "syn", 2304 + "syn 2.0.112", 2214 2305 "synstructure", 2215 2306 ] 2216 2307 ··· 2231 2322 dependencies = [ 2232 2323 "proc-macro2", 2233 2324 "quote", 2234 - "syn", 2325 + "syn 2.0.112", 2235 2326 ] 2236 2327 2237 2328 [[package]] ··· 2251 2342 dependencies = [ 2252 2343 "proc-macro2", 2253 2344 "quote", 2254 - "syn", 2345 + "syn 2.0.112", 2255 2346 "synstructure", 2256 2347 ] 2257 2348 ··· 2291 2382 dependencies = [ 2292 2383 "proc-macro2", 2293 2384 "quote", 2294 - "syn", 2385 + "syn 2.0.112", 2295 2386 ] 2296 2387 2297 2388 [[package]]
+2
menu/Cargo.toml
··· 12 12 html-escape = "0.2.13" 13 13 include_dir = "0.7.4" 14 14 percent-encoding = "2.3.2" 15 + regex = "1.12.2" 16 + regex_static = "0.1.1" 15 17 serde = { version = "1.0.228", features = ["derive"] } 16 18 sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "uuid", "macros"] } 17 19 tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
+29
menu/src/html/index.html
··· 1 + 2 + <!-- 3 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 4 + 5 + SPDX-License-Identifier: MIT 6 + --> 7 + 8 + <html> 9 + <head> 10 + <title>Add shortlink</title> 11 + <link rel="stylesheet" href="/_/public/logo.css"/> 12 + <link rel="stylesheet" href="/_/public/tables.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span>Menu</span><img src="/_/public/logo.svg"></div></div> 17 + <span class="actions">(<a href="/_/create">New link</a>) (<a href="/menu">Learn more</a>)</span> 18 + <table class="autogrid links"> 19 + <tr> 20 + <th>From</th> 21 + <th>To</th> 22 + <th>Owner</th> 23 + <th>Actions</th> 24 + </tr> 25 + {links:dangerous_raw} 26 + </table> 27 + <span class="actions">(<a href="/_/create">New link</a>) (<a href="/menu">Learn more</a>)</span> 28 + </body> 29 + </html>
+12
menu/src/html/public/logo.css
··· 26 26 font-family: sans-serif; 27 27 } 28 28 29 + .actions { 30 + align-self: start; 31 + font-size: 1rem; 32 + margin-top: 0.5rem; 33 + margin-bottom: 0.5rem; 34 + } 35 + 36 + p { 37 + max-width: 100%; 38 + overflow-wrap: break-word; 39 + } 40 + 29 41 #logo { 30 42 background: #144f5f; 31 43 border-radius: 0 0 0.5rem 0.5rem;
+73
menu/src/html/public/tables.css
··· 1 + /* 2 + * SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + * 4 + * SPDX-License-Identifier: MIT 5 + */ 6 + 7 + table.autogrid { 8 + width: 100%; 9 + display: grid; 10 + justify-content: stretch; 11 + } 12 + 13 + table.links { 14 + grid-template-columns: minmax(min-content, max-content) minmax(0, 1fr) minmax(min-content, max-content) minmax(min-content, max-content); 15 + } 16 + 17 + .autogrid tbody { 18 + display: contents; 19 + } 20 + 21 + .autogrid tr { 22 + margin: 0; 23 + display: contents; 24 + } 25 + 26 + .autogrid :is(th, td) { 27 + display: block; 28 + border: 0.05em solid #144f5f; 29 + padding: 0.5em 0.75em; 30 + margin: 0; 31 + overflow-wrap: break-word; 32 + } 33 + 34 + table.autogrid tr { 35 + &:first-child :is(th, td) { 36 + /* First row */ 37 + border-top-width: 0.1em; 38 + 39 + &:first-child { 40 + border-top-left-radius: 0.5rem; 41 + } 42 + 43 + &:last-child { 44 + border-top-right-radius: 0.5rem; 45 + } 46 + } 47 + 48 + &:last-child :is(th, td) { 49 + /* Last row */ 50 + border-bottom-width: 0.1em; 51 + 52 + &:first-child { 53 + border-bottom-left-radius: 0.5rem; 54 + } 55 + 56 + &:last-child { 57 + border-bottom-right-radius: 0.5rem; 58 + } 59 + } 60 + 61 + & :is(th, td) { 62 + &:first-child { 63 + /* First column */ 64 + border-left-width: 0.1em; 65 + } 66 + 67 + &:last-child { 68 + /* Last column */ 69 + border-right-width: 0.1em; 70 + } 71 + } 72 + } 73 +
+117 -39
menu/src/main.rs
··· 11 11 }; 12 12 use include_dir::{Dir, include_dir}; 13 13 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 14 + use regex::Captures; 14 15 use sqlx::{Connection, PgConnection}; 15 16 16 17 #[cfg(debug_assertions)] ··· 30 31 #[cfg(debug_assertions)] 31 32 static DEVELOPMENT: OnceLock<bool> = OnceLock::new(); 32 33 34 + #[derive(Clone)] 35 + enum AnyString<'a> { 36 + Owned(String), 37 + Ref(&'a str), 38 + } 39 + 33 40 fn template_html<'a>( 34 - mut html: String, 35 - replacements: HashMap<&str, Box<dyn 'a + FnOnce() -> Option<&'a str>>>, 41 + html: String, 42 + replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>>, 36 43 ) -> String { 37 - for (text, maybe_replacement) in replacements { 38 - let replacement = maybe_replacement().unwrap_or_else(|| ""); 39 - html = html.replace( 40 - &("{".to_owned() + text + "}"), 41 - &html_escape::encode_text(replacement), 42 - ); 43 - html = html.replace( 44 - &("{".to_owned() + text + ":attribute}"), 45 - &html_escape::encode_quoted_attribute(replacement), 46 - ); 47 - html = html.replace( 48 - &("{".to_owned() + text + ":url}"), 49 - &utf8_percent_encode(replacement, NON_ALPHANUMERIC).to_string(), 50 - ); 51 - } 44 + let re = regex_static::static_regex!(r"\{([^\}:]+)(?::([^\}]+))?\}"); 45 + re.replace_all(&html, |captures: &Captures| { 46 + let replacement_name = &captures[1]; 47 + let replacement = replacements 48 + .get(replacement_name) 49 + .and_then(|maybe_replacement| maybe_replacement()) 50 + .unwrap_or_else(|| AnyString::Ref("")) 51 + .clone(); 52 + let replacement_owned = match replacement { 53 + AnyString::Owned(owned) => owned, 54 + AnyString::Ref(str) => str.to_string(), 55 + }; 52 56 53 - html 57 + match captures.get(2).and_then(|m| Some(m.as_str())) { 58 + Some("dangerous_raw") => replacement_owned, 59 + Some("attribute") => { 60 + html_escape::encode_quoted_attribute(&replacement_owned).to_string() 61 + } 62 + Some("url") => utf8_percent_encode(&replacement_owned, NON_ALPHANUMERIC).to_string(), 63 + None => html_escape::encode_text(&replacement_owned).to_string(), 64 + Some(_) => "UNKNOWN_MATCH_TYPE".to_string(), 65 + } 66 + }) 67 + .to_string() 54 68 } 55 69 56 70 /// include_str, but if DEVELOPMENT then the string is dynamically fetched for easy reloading ··· 125 139 get_redirect("https://kagi.com/search?q=", go).await 126 140 } 127 141 128 - async fn handle_root( 142 + #[axum::debug_handler] 143 + async fn handle_index( 129 144 headers: HeaderMap, 130 - #[cfg(debug_assertions)] Query(params): Query<HashMap<String, String>>, 131 - ) -> Result<String> { 132 - ensure_authenticated( 133 - &headers, 134 - #[cfg(debug_assertions)] 135 - &params, 136 - )?; 137 - Ok("Hello, world!".to_string()) 145 + Query(params): Query<HashMap<String, String>>, 146 + ) -> Result<Html<String>> { 147 + handle_static_page(StaticPageType::Index, &params, &headers).await 138 148 } 139 149 140 150 async fn handle_base(Path(go): Path<String>) -> Redirect { ··· 149 159 } 150 160 } 151 161 162 + struct Link { 163 + from: String, 164 + to: String, 165 + owner: String, 166 + } 167 + 152 168 #[derive(Clone)] 153 169 enum StaticPageType { 154 170 Create, 155 - CreateSuccess, 156 171 CreateConflict, 157 172 CreateFailure, 158 - DeleteSuccess, 173 + CreateSuccess, 159 174 DeleteFailure, 175 + DeleteSuccess, 176 + Index, 160 177 } 161 178 162 179 async fn handle_static_page<'a>( ··· 170 187 171 188 let html = match page_type { 172 189 StaticPageType::Create => include_String_dynamic!("./html/create.html"), 173 - StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 174 190 StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 175 191 StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 176 - StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 192 + StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 177 193 StaticPageType::DeleteFailure => include_String_dynamic!("./html/delete/failure.html"), 194 + StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 195 + StaticPageType::Index => include_String_dynamic!("./html/index.html"), 178 196 }; 179 197 180 198 let username = if auth_required { ··· 187 205 None 188 206 }; 189 207 190 - let mut replacements: HashMap<&str, Box<dyn 'a + FnOnce() -> Option<&'a str>>> = HashMap::new(); 208 + let mut replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>> = 209 + HashMap::new(); 191 210 replacements.insert( 192 211 "host", 193 212 Box::new(|| { 194 - Some(clean_host( 213 + Some(AnyString::Ref(clean_host( 195 214 headers 196 215 .get("host") 197 216 .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 198 217 .unwrap_or_else(|| "go"), 199 - )) 218 + ))) 200 219 }), 201 220 ); 202 221 replacements.insert( 203 222 "from", 204 - Box::new(|| params.get("from").and_then(|from| Some(from.as_str()))), 223 + Box::new(|| { 224 + params 225 + .get("from") 226 + .and_then(|from| Some(AnyString::Ref(from.as_str()))) 227 + }), 205 228 ); 206 229 replacements.insert( 207 230 "to", 208 - Box::new(|| params.get("to").and_then(|to| Some(to.as_str()))), 231 + Box::new(|| { 232 + params 233 + .get("to") 234 + .and_then(|to| Some(AnyString::Ref(to.as_str()))) 235 + }), 209 236 ); 210 237 replacements.insert( 211 238 "current", 212 239 Box::new(|| { 213 240 params 214 241 .get("current") 215 - .and_then(|current| Some(current.as_str())) 242 + .and_then(|current| Some(AnyString::Ref(current.as_str()))) 216 243 }), 217 244 ); 218 - replacements.insert("username", Box::new(move || username)); 245 + replacements.insert( 246 + "username", 247 + Box::new(move || username.and_then(|name| Some(AnyString::Ref(name)))), 248 + ); 249 + 250 + if matches!(page_type, StaticPageType::Index) { 251 + let links_query = sqlx::query_as!( 252 + Link, 253 + r#"SELECT "from", "to", "owner" FROM direct ORDER BY direct."from" ASC"#, 254 + ) 255 + .fetch_all( 256 + STATE 257 + .get() 258 + .expect("Server must be initialized before processing connections") 259 + .sqlx_connection 260 + .lock() 261 + .await 262 + .deref_mut(), 263 + ) 264 + .await; 265 + 266 + let Ok(links) = links_query else { 267 + return Err("Failed to query database".into()); 268 + }; 269 + 270 + let mut rows = vec![]; 271 + 272 + for link in links { 273 + let from_attribute = html_escape::encode_quoted_attribute(&link.from); 274 + let from = html_escape::encode_text(&link.from); 275 + let from_url = utf8_percent_encode(&link.from, NON_ALPHANUMERIC); 276 + let to_attribute = html_escape::encode_quoted_attribute(&link.to); 277 + let to = html_escape::encode_text(&link.to); 278 + let to_url = utf8_percent_encode(&link.to, NON_ALPHANUMERIC); 279 + let owner = html_escape::encode_text(&link.owner); 280 + 281 + rows.push(format!( 282 + r#"<tr> 283 + <td><a href="{from_attribute}">{from}</a></td> 284 + <td><a href="{to_attribute}">{to}</a></td> 285 + <td>{owner}</td> 286 + <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}">delete</a>)</td> 287 + </tr>"#, 288 + )); 289 + } 290 + 291 + let link_table = rows.join("\n"); 292 + replacements.insert( 293 + "links", 294 + Box::new(move || Some(AnyString::Owned(link_table.clone()))), 295 + ); 296 + } 219 297 220 298 let result = template_html(html, replacements); 221 299 Ok(Html(result)) ··· 480 558 } 481 559 482 560 router = router 483 - .route("/", get(handle_root)) 561 + .route("/", get(handle_index)) 484 562 .route("/_/create", get(handle_create_page)) 485 563 .route("/_/create/success", get(handle_create_success_page)) 486 564 .route("/_/create/conflict", get(handle_create_conflict_page))