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 version = 4 4 5 [[package]] 6 name = "allocator-api2" 7 version = "0.2.21" 8 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 90 dependencies = [ 91 "proc-macro2", 92 "quote", 93 - "syn", 94 ] 95 96 [[package]] ··· 252 dependencies = [ 253 "proc-macro2", 254 "quote", 255 - "syn", 256 ] 257 258 [[package]] ··· 825 "html-escape", 826 "include_dir", 827 "percent-encoding", 828 "serde", 829 "sqlx", 830 "tokio", ··· 974 dependencies = [ 975 "proc-macro2", 976 "quote", 977 - "syn", 978 ] 979 980 [[package]] ··· 1101 ] 1102 1103 [[package]] 1104 name = "ring" 1105 version = "0.17.14" 1106 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1213 dependencies = [ 1214 "proc-macro2", 1215 "quote", 1216 - "syn", 1217 ] 1218 1219 [[package]] ··· 1394 "quote", 1395 "sqlx-core", 1396 "sqlx-macros-core", 1397 - "syn", 1398 ] 1399 1400 [[package]] ··· 1417 "sqlx-mysql", 1418 "sqlx-postgres", 1419 "sqlx-sqlite", 1420 - "syn", 1421 "tokio", 1422 "url", 1423 ] ··· 1553 1554 [[package]] 1555 name = "syn" 1556 version = "2.0.112" 1557 source = "registry+https://github.com/rust-lang/crates.io-index" 1558 checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" ··· 1576 dependencies = [ 1577 "proc-macro2", 1578 "quote", 1579 - "syn", 1580 ] 1581 1582 [[package]] ··· 1596 dependencies = [ 1597 "proc-macro2", 1598 "quote", 1599 - "syn", 1600 ] 1601 1602 [[package]] ··· 1647 dependencies = [ 1648 "proc-macro2", 1649 "quote", 1650 - "syn", 1651 ] 1652 1653 [[package]] ··· 1769 dependencies = [ 1770 "proc-macro2", 1771 "quote", 1772 - "syn", 1773 ] 1774 1775 [[package]] ··· 1916 "bumpalo", 1917 "proc-macro2", 1918 "quote", 1919 - "syn", 1920 "wasm-bindgen-shared", 1921 ] 1922 ··· 2210 dependencies = [ 2211 "proc-macro2", 2212 "quote", 2213 - "syn", 2214 "synstructure", 2215 ] 2216 ··· 2231 dependencies = [ 2232 "proc-macro2", 2233 "quote", 2234 - "syn", 2235 ] 2236 2237 [[package]] ··· 2251 dependencies = [ 2252 "proc-macro2", 2253 "quote", 2254 - "syn", 2255 "synstructure", 2256 ] 2257 ··· 2291 dependencies = [ 2292 "proc-macro2", 2293 "quote", 2294 - "syn", 2295 ] 2296 2297 [[package]]
··· 3 version = 4 4 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]] 15 name = "allocator-api2" 16 version = "0.2.21" 17 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 99 dependencies = [ 100 "proc-macro2", 101 "quote", 102 + "syn 2.0.112", 103 ] 104 105 [[package]] ··· 261 dependencies = [ 262 "proc-macro2", 263 "quote", 264 + "syn 2.0.112", 265 ] 266 267 [[package]] ··· 834 "html-escape", 835 "include_dir", 836 "percent-encoding", 837 + "regex", 838 + "regex_static", 839 "serde", 840 "sqlx", 841 "tokio", ··· 985 dependencies = [ 986 "proc-macro2", 987 "quote", 988 + "syn 2.0.112", 989 ] 990 991 [[package]] ··· 1112 ] 1113 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]] 1184 name = "ring" 1185 version = "0.17.14" 1186 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1293 dependencies = [ 1294 "proc-macro2", 1295 "quote", 1296 + "syn 2.0.112", 1297 ] 1298 1299 [[package]] ··· 1474 "quote", 1475 "sqlx-core", 1476 "sqlx-macros-core", 1477 + "syn 2.0.112", 1478 ] 1479 1480 [[package]] ··· 1497 "sqlx-mysql", 1498 "sqlx-postgres", 1499 "sqlx-sqlite", 1500 + "syn 2.0.112", 1501 "tokio", 1502 "url", 1503 ] ··· 1633 1634 [[package]] 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" 1647 version = "2.0.112" 1648 source = "registry+https://github.com/rust-lang/crates.io-index" 1649 checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" ··· 1667 dependencies = [ 1668 "proc-macro2", 1669 "quote", 1670 + "syn 2.0.112", 1671 ] 1672 1673 [[package]] ··· 1687 dependencies = [ 1688 "proc-macro2", 1689 "quote", 1690 + "syn 2.0.112", 1691 ] 1692 1693 [[package]] ··· 1738 dependencies = [ 1739 "proc-macro2", 1740 "quote", 1741 + "syn 2.0.112", 1742 ] 1743 1744 [[package]] ··· 1860 dependencies = [ 1861 "proc-macro2", 1862 "quote", 1863 + "syn 2.0.112", 1864 ] 1865 1866 [[package]] ··· 2007 "bumpalo", 2008 "proc-macro2", 2009 "quote", 2010 + "syn 2.0.112", 2011 "wasm-bindgen-shared", 2012 ] 2013 ··· 2301 dependencies = [ 2302 "proc-macro2", 2303 "quote", 2304 + "syn 2.0.112", 2305 "synstructure", 2306 ] 2307 ··· 2322 dependencies = [ 2323 "proc-macro2", 2324 "quote", 2325 + "syn 2.0.112", 2326 ] 2327 2328 [[package]] ··· 2342 dependencies = [ 2343 "proc-macro2", 2344 "quote", 2345 + "syn 2.0.112", 2346 "synstructure", 2347 ] 2348 ··· 2382 dependencies = [ 2383 "proc-macro2", 2384 "quote", 2385 + "syn 2.0.112", 2386 ] 2387 2388 [[package]]
+2
menu/Cargo.toml
··· 12 html-escape = "0.2.13" 13 include_dir = "0.7.4" 14 percent-encoding = "2.3.2" 15 serde = { version = "1.0.228", features = ["derive"] } 16 sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "uuid", "macros"] } 17 tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
··· 12 html-escape = "0.2.13" 13 include_dir = "0.7.4" 14 percent-encoding = "2.3.2" 15 + regex = "1.12.2" 16 + regex_static = "0.1.1" 17 serde = { version = "1.0.228", features = ["derive"] } 18 sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "uuid", "macros"] } 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 font-family: sans-serif; 27 } 28 29 #logo { 30 background: #144f5f; 31 border-radius: 0 0 0.5rem 0.5rem;
··· 26 font-family: sans-serif; 27 } 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 + 41 #logo { 42 background: #144f5f; 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 }; 12 use include_dir::{Dir, include_dir}; 13 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 14 use sqlx::{Connection, PgConnection}; 15 16 #[cfg(debug_assertions)] ··· 30 #[cfg(debug_assertions)] 31 static DEVELOPMENT: OnceLock<bool> = OnceLock::new(); 32 33 fn template_html<'a>( 34 - mut html: String, 35 - replacements: HashMap<&str, Box<dyn 'a + FnOnce() -> Option<&'a str>>>, 36 ) -> 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 - } 52 53 - html 54 } 55 56 /// include_str, but if DEVELOPMENT then the string is dynamically fetched for easy reloading ··· 125 get_redirect("https://kagi.com/search?q=", go).await 126 } 127 128 - async fn handle_root( 129 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()) 138 } 139 140 async fn handle_base(Path(go): Path<String>) -> Redirect { ··· 149 } 150 } 151 152 #[derive(Clone)] 153 enum StaticPageType { 154 Create, 155 - CreateSuccess, 156 CreateConflict, 157 CreateFailure, 158 - DeleteSuccess, 159 DeleteFailure, 160 } 161 162 async fn handle_static_page<'a>( ··· 170 171 let html = match page_type { 172 StaticPageType::Create => include_String_dynamic!("./html/create.html"), 173 - StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 174 StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 175 StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 176 - StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 177 StaticPageType::DeleteFailure => include_String_dynamic!("./html/delete/failure.html"), 178 }; 179 180 let username = if auth_required { ··· 187 None 188 }; 189 190 - let mut replacements: HashMap<&str, Box<dyn 'a + FnOnce() -> Option<&'a str>>> = HashMap::new(); 191 replacements.insert( 192 "host", 193 Box::new(|| { 194 - Some(clean_host( 195 headers 196 .get("host") 197 .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 198 .unwrap_or_else(|| "go"), 199 - )) 200 }), 201 ); 202 replacements.insert( 203 "from", 204 - Box::new(|| params.get("from").and_then(|from| Some(from.as_str()))), 205 ); 206 replacements.insert( 207 "to", 208 - Box::new(|| params.get("to").and_then(|to| Some(to.as_str()))), 209 ); 210 replacements.insert( 211 "current", 212 Box::new(|| { 213 params 214 .get("current") 215 - .and_then(|current| Some(current.as_str())) 216 }), 217 ); 218 - replacements.insert("username", Box::new(move || username)); 219 220 let result = template_html(html, replacements); 221 Ok(Html(result)) ··· 480 } 481 482 router = router 483 - .route("/", get(handle_root)) 484 .route("/_/create", get(handle_create_page)) 485 .route("/_/create/success", get(handle_create_success_page)) 486 .route("/_/create/conflict", get(handle_create_conflict_page))
··· 11 }; 12 use include_dir::{Dir, include_dir}; 13 use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; 14 + use regex::Captures; 15 use sqlx::{Connection, PgConnection}; 16 17 #[cfg(debug_assertions)] ··· 31 #[cfg(debug_assertions)] 32 static DEVELOPMENT: OnceLock<bool> = OnceLock::new(); 33 34 + #[derive(Clone)] 35 + enum AnyString<'a> { 36 + Owned(String), 37 + Ref(&'a str), 38 + } 39 + 40 fn template_html<'a>( 41 + html: String, 42 + replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>>, 43 ) -> String { 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 + }; 56 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() 68 } 69 70 /// include_str, but if DEVELOPMENT then the string is dynamically fetched for easy reloading ··· 139 get_redirect("https://kagi.com/search?q=", go).await 140 } 141 142 + #[axum::debug_handler] 143 + async fn handle_index( 144 headers: HeaderMap, 145 + Query(params): Query<HashMap<String, String>>, 146 + ) -> Result<Html<String>> { 147 + handle_static_page(StaticPageType::Index, &params, &headers).await 148 } 149 150 async fn handle_base(Path(go): Path<String>) -> Redirect { ··· 159 } 160 } 161 162 + struct Link { 163 + from: String, 164 + to: String, 165 + owner: String, 166 + } 167 + 168 #[derive(Clone)] 169 enum StaticPageType { 170 Create, 171 CreateConflict, 172 CreateFailure, 173 + CreateSuccess, 174 DeleteFailure, 175 + DeleteSuccess, 176 + Index, 177 } 178 179 async fn handle_static_page<'a>( ··· 187 188 let html = match page_type { 189 StaticPageType::Create => include_String_dynamic!("./html/create.html"), 190 StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 191 StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 192 + StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 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"), 196 }; 197 198 let username = if auth_required { ··· 205 None 206 }; 207 208 + let mut replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>> = 209 + HashMap::new(); 210 replacements.insert( 211 "host", 212 Box::new(|| { 213 + Some(AnyString::Ref(clean_host( 214 headers 215 .get("host") 216 .and_then(|header| Some(header.to_str().unwrap_or_else(|_| "go"))) 217 .unwrap_or_else(|| "go"), 218 + ))) 219 }), 220 ); 221 replacements.insert( 222 "from", 223 + Box::new(|| { 224 + params 225 + .get("from") 226 + .and_then(|from| Some(AnyString::Ref(from.as_str()))) 227 + }), 228 ); 229 replacements.insert( 230 "to", 231 + Box::new(|| { 232 + params 233 + .get("to") 234 + .and_then(|to| Some(AnyString::Ref(to.as_str()))) 235 + }), 236 ); 237 replacements.insert( 238 "current", 239 Box::new(|| { 240 params 241 .get("current") 242 + .and_then(|current| Some(AnyString::Ref(current.as_str()))) 243 }), 244 ); 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 + } 297 298 let result = template_html(html, replacements); 299 Ok(Html(result)) ··· 558 } 559 560 router = router 561 + .route("/", get(handle_index)) 562 .route("/_/create", get(handle_create_page)) 563 .route("/_/create/success", get(handle_create_success_page)) 564 .route("/_/create/conflict", get(handle_create_conflict_page))