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

feat(m): add "list links" page #169

merged opened by a.starrysky.fyi targeting main from private/minion/push-mkpovzswunnr

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?)

Labels

None yet.

requested-reviewers

None yet.

approved

None yet.

tested-working

None yet.

rejected

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:uuyqs6y3pwtbteet4swt5i5y/sh.tangled.repo.pull/3mc6qjooc5c22
+372 -58
Diff #4
-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 + }
menu/.sqlx/query-735cda2fe387b6b852a03ba7ccba41353667bd505f80c1cfe3ad16b738b45ba5.json.license menu/.sqlx/query-ca0119f85393f3c3aa1907ed10aa70d9227a6d9586c3295af4dbca3ef4ea6be5.json.license
+106 -15
menu/Cargo.lock
··· 2 2 # It is not intended for manual editing. 3 3 version = 4 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 + 5 14 [[package]] 6 15 name = "allocator-api2" 7 16 version = "0.2.21" ··· 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]] ··· 1100 1111 "bitflags", 1101 1112 ] 1102 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 + 1103 1183 [[package]] 1104 1184 name = "ring" 1105 1185 version = "0.17.14" ··· 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 ] ··· 1551 1631 source = "registry+https://github.com/rust-lang/crates.io-index" 1552 1632 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 1553 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 + 1554 1645 [[package]] 1555 1646 name = "syn" 1556 1647 version = "2.0.112" ··· 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 +
+118 -40
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 - } 52 - 53 - html 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() 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))

History

5 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
feat(m): add "list links" page
5/5 success
expand
expand 0 comments
pull request successfully merged
1 commit
expand
feat(m): add "list links" page
5/5 success
expand
expand 0 comments
1 commit
expand
feat(m): add "list links" page
5/5 success
expand
expand 0 comments
1 commit
expand
feat(m): add "list links" page
1/5 failed, 4/5 success
expand
expand 0 comments
1 commit
expand
feat(m): add "list links" page
5/5 success
expand
expand 0 comments