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

feat(m): add regex support #190

closed opened by a.starrysky.fyi targeting main from private/minion/push-xpqrvpwlrtsk

Since before we started menu, we've wanted regex support. This will let us have shortcuts like go/gh/foo/bar go to https://github.com/foo/bar, say

I've written this in a way where it could be extended, for example I want to have shortcuts like go/M65.15+83 go to https://wiki.freshly.space/wiki/M65.15%2B83_Serenity, say, which isn't generically possible without querying the MediaWiki API. That means I didn't change the direct table to have a field "type" or something - since as further schemes would want to use a completely different data format

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/3mdj5iloylm22
+547 -42
Diff #3
+25
menu/.sqlx/query-182348173eb341595351d1586dc3534d376daba786d0658323217e022d22d165.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n WITH insertion AS (\n INSERT INTO regex (\"from\", \"to\", \"owner\")\n VALUES ($1, $2, $3)\n ON CONFLICT (\"from\")\n DO UPDATE SET \"to\" = EXCLUDED.to, \"owner\" = EXCLUDED.owner WHERE regex.to = $4\n RETURNING regex.from\n )\n SELECT regex.to FROM regex\n WHERE regex.from NOT IN (SELECT insertion.from FROM insertion) AND regex.from = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "to", 9 + "type_info": "Varchar" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Varchar", 15 + "Varchar", 16 + "Varchar", 17 + "Text" 18 + ] 19 + }, 20 + "nullable": [ 21 + false 22 + ] 23 + }, 24 + "hash": "182348173eb341595351d1586dc3534d376daba786d0658323217e022d22d165" 25 + }
+3
menu/.sqlx/query-182348173eb341595351d1586dc3534d376daba786d0658323217e022d22d165.json.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: CC0-1.0
+32
menu/.sqlx/query-25a4aa6f9b3625e5fdc1edde3719982241c29d68352236e71bd9e87059e7012d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT \"from\", \"to\", \"owner\" FROM regex ORDER BY regex.\"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": "25a4aa6f9b3625e5fdc1edde3719982241c29d68352236e71bd9e87059e7012d" 32 + }
+3
menu/.sqlx/query-25a4aa6f9b3625e5fdc1edde3719982241c29d68352236e71bd9e87059e7012d.json.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: CC0-1.0
+28
menu/.sqlx/query-631d51301aa4b22c09d3899089e4d40a8e2485ab6aa9ab9e42292958c2c1bf93.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT \"from\", \"to\" FROM regex WHERE $1 ~* ('^' || \"from\" || '$') LIMIT 1", 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 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "631d51301aa4b22c09d3899089e4d40a8e2485ab6aa9ab9e42292958c2c1bf93" 28 + }
+3
menu/.sqlx/query-631d51301aa4b22c09d3899089e4d40a8e2485ab6aa9ab9e42292958c2c1bf93.json.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: CC0-1.0
+15
menu/.sqlx/query-cbc8750d6e1de27fb56dabc10e35731327faa8aa030fdcc109ce3a08c98adb11.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM regex WHERE regex.from = $1 AND regex.to = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "cbc8750d6e1de27fb56dabc10e35731327faa8aa030fdcc109ce3a08c98adb11" 15 + }
+3
menu/.sqlx/query-cbc8750d6e1de27fb56dabc10e35731327faa8aa030fdcc109ce3a08c98adb11.json.license
··· 1 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + 3 + SPDX-License-Identifier: CC0-1.0
+9
menu/migrations/20260128121115_add-regex.sql
··· 1 + -- SPDX-FileCopyrightText: 2026 Freshly Baked Cake 2 + -- 3 + -- SPDX-License-Identifier: MIT 4 + 5 + CREATE TABLE "regex" ( 6 + "from" varchar PRIMARY KEY, 7 + "to" varchar NOT NULL, 8 + "owner" varchar NOT NULL 9 + );
+4 -4
menu/src/direct.rs
··· 14 14 owner: String, 15 15 } 16 16 17 - pub(crate) async fn get_redirect(default_location: &str, go: &str) -> Redirect { 17 + pub(crate) async fn get_redirect(go: &str) -> Option<Redirect> { 18 18 let redirect = sqlx::query!( 19 19 r#"SELECT ("to") FROM direct WHERE "from" = $1 LIMIT 1"#, 20 20 go.to_lowercase() ··· 31 31 .await; 32 32 33 33 if let Ok(record) = redirect { 34 - Redirect::temporary(&record.to) 34 + Some(Redirect::temporary(&record.to)) 35 35 } else { 36 - Redirect::temporary(&("".to_string() + default_location + go)) 36 + None 37 37 } 38 38 } 39 39 ··· 73 73 <td><a href="{from_attribute}">{from}</a></td> 74 74 <td><a href="{to_attribute}">{to}</a></td> 75 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> 76 + <td>(<a href="/_/create?from={from_url}&to={to_url}&current={to_url}&format=direct">edit</a>) (<a href="/_/delete/do?from={from_url}&current={to_url}&token={token}&format=direct">delete</a>)</td> 77 77 </tr>"#, 78 78 )); 79 79 }
+8 -1
menu/src/html/create.html
··· 21 21 </div> 22 22 <div> 23 23 ...<label for="to">send me to </label> 24 - <input type="url" name="to" id="to" value="{to:attribute}" placeholder="https://kagi.com" required /> 24 + <input type="url" name="to" id="to" value="{to:attribute}" placeholder="https://kagi.com" required />... 25 + </div> 26 + <div> 27 + ...<label for="">and match with </label> 28 + <select name="format" id="format" required> 29 + <option value="direct" {format:if:equal:direct:selected}>direct</option> 30 + <option value="regex" {format:if:equal:regex:selected}>regex</option> 31 + </select> matching... 25 32 </div> 26 33 </div> 27 34 <input type="hidden" name="current" id="current" value="{current:attribute}" />
+2 -2
menu/src/html/create/conflict.html menu/src/html/create/conflict/direct.html
··· 16 16 <div id="logo"><div><span><span class="failure">Couldn't create</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 17 <p>You can't create a link from <a href="/{from:url}">{host}/{from}</a> to <a href="{to:attribute}">{to}</a> because <a href="/{from:url}">{host}/{from}</a> already goes to <a href="{current:attribute}">{current}</a>.</p> 18 18 <ul> 19 - <li><a href="/_/create/do?from={from:url}&to={to:url}&current={current:url}&token={token:url}">Overwrite it</a></li> 20 - <li><a href="/_/create?from={from:url}&to={to:url}">Back to editor</a></li> 19 + <li><a href="/_/create/do?from={from:url}&to={to:url}&current={current:url}&token={token:url}&format={format:url}">Overwrite it</a></li> 20 + <li><a href="/_/create?from={from:url}&to={to:url}&format={format:url}">Back to editor</a></li> 21 21 <li><a href="/">All shortlinks</a></li> 22 22 </ul> 23 23 </body>
+24
menu/src/html/create/conflict/regex.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="failure">Couldn't create</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p>You can't create a link from <code>/{from}/i</code> to <code>{to}</code> because <code>/{from}/i</code> already goes to <code>{current}</code>.</p> 18 + <ul> 19 + <li><a href="/_/create/do?from={from:url}&to={to:url}&current={current:url}&token={token:url}&format={format:url}">Overwrite it</a></li> 20 + <li><a href="/_/create?from={from:url}&to={to:url}&format={format:url}">Back to editor</a></li> 21 + <li><a href="/">All shortlinks</a></li> 22 + </ul> 23 + </body> 24 + </html>
+1 -1
menu/src/html/create/failure.html
··· 16 16 <div id="logo"><div><span><span class="failure">Couldn't create</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 17 <p>There was an internal error when creating your shortlink. You may try again. If you keep experiencing this error, please contact Minion or Coded.</p> 18 18 <ul> 19 - <li><a href="/_/create?from={from:url}&to={to:url}">Back to editor</a></li> 19 + <li><a href="/_/create?from={from:url}&to={to:url}&format={format:url}">Back to editor</a></li> 20 20 <li><a href="/">All shortlinks</a></li> 21 21 </ul> 22 22 </body>
+2 -2
menu/src/html/create/success.html menu/src/html/create/success/direct.html
··· 19 19 <ul> 20 20 <li>Access it at <a href="/{from:url}">{host}/{from}</a> (<span id="copy" text="{host:attribute}/{from:attribute}">copy</span>)</li> 21 21 <li><a href="/_/create">Create another one</a></li> 22 - <li><a href="/_/create?from={from:url}&to={to:url}&current={to:url}">Edit it</a></li> 23 - <li><a href="/_/delete/do?from={from:url}&current={to:url}&token={token:url}">Delete it</a></li> 22 + <li><a href="/_/create?from={from:url}&to={to:url}&current={to:url}&format={format:url}">Edit it</a></li> 23 + <li><a href="/_/delete/do?from={from:url}&current={to:url}&token={token:url}&format={format:url}">Delete it</a></li> 24 24 <li><a href="/">All shortlinks</a></li> 25 25 </ul> 26 26 <script>
+26
menu/src/html/create/success/regex.html
··· 1 + <!-- 2 + SPDX-FileCopyrightText: 2026 Freshly Baked Cake 3 + 4 + SPDX-License-Identifier: MIT 5 + --> 6 + 7 + <html> 8 + <head> 9 + <title>Add shortlink</title> 10 + <link rel="stylesheet" href="/_/public/logo.css"/> 11 + <link rel="stylesheet" href="/_/public/colors.css"/> 12 + <link rel="stylesheet" href="/_/public/actions.css"/> 13 + </head> 14 + 15 + <body> 16 + <div id="logo"><div><span><span class="success">Created</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 + <p>Your new shortlink is up!</p> 18 + <p><code>/{from}/i</code> now goes to <code>{to}</code></p> 19 + <ul> 20 + <li><a href="/_/create">Create another one</a></li> 21 + <li><a href="/_/create?from={from:url}&to={to:url}&current={to:url}&format={format:url}">Edit it</a></li> 22 + <li><a href="/_/delete/do?from={from:url}&current={to:url}&token={token:url}&format={format:url}">Delete it</a></li> 23 + <li><a href="/">All shortlinks</a></li> 24 + </ul> 25 + </body> 26 + </html>
+1 -1
menu/src/html/delete/success.html
··· 16 16 <div id="logo"><div><span><span class="success">Deleted</span> shortlink</span><img src="/_/public/logo.svg"></div></div> 17 17 <p><a href="/{from:url}">{host}/{from}</a> no longer goes to <a href="{current:attribute}">{current}</a></p> 18 18 <ul> 19 - <li><a href="/_/create?from={from:url}&to={current:url}">Recreate it</a></li> 19 + <li><a href="/_/create?from={from:url}&to={current:url}&format={format:url}">Recreate it</a></li> 20 20 <li><a href="/">All shortlinks</a></li> 21 21 </ul> 22 22 </body>
+16 -3
menu/src/html/index.html
··· 14 14 15 15 <body> 16 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> 17 + <h2>Direct</h2> 18 + <span class="actions">(<a href="/_/create?format=direct">New link</a>) (<a href="/menu">Learn more</a>)</span> 18 19 <table class="autogrid links"> 19 20 <tr> 20 21 <th>From</th> ··· 22 23 <th>Owner</th> 23 24 <th>Actions</th> 24 25 </tr> 25 - {links:dangerous_raw} 26 + {direct_links:dangerous_raw} 26 27 </table> 27 - <span class="actions">(<a href="/_/create">New link</a>) (<a href="/menu">Learn more</a>)</span> 28 + <span class="actions">(<a href="/_/create?format=direct">New link</a>) (<a href="/menu">Learn more</a>)</span> 29 + <h2>Regex</h2> 30 + <span class="actions">(<a href="/_/create?format=regex">New link</a>) (<a href="/menu">Learn more</a>)</span> 31 + <table class="autogrid links"> 32 + <tr> 33 + <th>From</th> 34 + <th>To</th> 35 + <th>Owner</th> 36 + <th>Actions</th> 37 + </tr> 38 + {regex_links:dangerous_raw} 39 + </table> 40 + <span class="actions">(<a href="/_/create?format=regex">New link</a>) (<a href="/menu">Learn more</a>)</span> 28 41 </body> 29 42 </html>
+1 -1
menu/src/html/public/forms.css
··· 18 18 padding: 0.1em; 19 19 } 20 20 21 - input { 21 + input, select { 22 22 border: 0.1em solid black; 23 23 border-radius: 0.25em; 24 24 height: 1.5em;
+12
menu/src/html/public/logo.css
··· 63 63 #logo > div > img { 64 64 height: 100%; 65 65 } 66 + 67 + code { 68 + background: #64e4ff; 69 + padding: 0.2rem; 70 + border-radius: 0.2em; 71 + } 72 + 73 + h2 { 74 + font-size: 2.5rem; 75 + margin-bottom: 0.1em; 76 + align-self: start; 77 + }
+93 -19
menu/src/main.rs
··· 3 3 // SPDX-License-Identifier: MIT 4 4 mod auth; 5 5 mod direct; 6 + mod regex; 6 7 mod static_html; 7 8 8 9 use axum::{ ··· 58 59 return "go"; 59 60 } 60 61 62 + async fn get_redirect(go: &str) -> Option<Redirect> { 63 + let go = &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(); 64 + 65 + if let Some(redirect) = direct::get_redirect(go).await { 66 + return Some(redirect); 67 + } 68 + 69 + if let Some(redirect) = regex::get_redirect(go).await { 70 + return Some(redirect); 71 + } 72 + 73 + None 74 + } 75 + 61 76 async fn get_redirect_base(go: &str) -> Redirect { 62 - direct::get_redirect( 63 - "/_/create?from=", 64 - &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(), 65 - ) 66 - .await 77 + let go = &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(); 78 + 79 + get_redirect(go) 80 + .await 81 + .unwrap_or_else(|| Redirect::temporary(&("/_/create?format=direct&from=".to_string() + go))) 67 82 } 68 83 69 84 async fn get_redirect_search(go: &str) -> Redirect { 70 - direct::get_redirect( 71 - "https://kagi.com/search?q=", 72 - &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(), 73 - ) 74 - .await 85 + let go = &utf8_percent_encode(go, NON_ALPHANUMERIC).to_string(); 86 + 87 + get_redirect(go) 88 + .await 89 + .unwrap_or_else(|| Redirect::temporary(&("https://kagi.com/search?q=".to_string() + go))) 75 90 } 76 91 77 92 #[axum::debug_handler] ··· 119 134 Query(params): Query<HashMap<String, String>>, 120 135 headers: HeaderMap, 121 136 ) -> Result<Html<String>> { 122 - handle_static_page(StaticPageType::CreateSuccess, session, &params, &headers).await 137 + match params.get("format").and_then(|s| Some(s.as_str())) { 138 + Some("direct") => { 139 + handle_static_page( 140 + StaticPageType::CreateDirectSuccess, 141 + session, 142 + &params, 143 + &headers, 144 + ) 145 + .await 146 + } 147 + Some("regex") => { 148 + handle_static_page( 149 + StaticPageType::CreateRegexSuccess, 150 + session, 151 + &params, 152 + &headers, 153 + ) 154 + .await 155 + } 156 + _ => Err("Invalid format".into()), 157 + } 123 158 } 124 159 async fn handle_create_conflict_page( 125 160 session: Session, 126 161 Query(params): Query<HashMap<String, String>>, 127 162 headers: HeaderMap, 128 163 ) -> Result<Html<String>> { 129 - handle_static_page(StaticPageType::CreateConflict, session, &params, &headers).await 164 + match params.get("format").and_then(|s| Some(s.as_str())) { 165 + Some("direct") => { 166 + handle_static_page( 167 + StaticPageType::CreateDirectConflict, 168 + session, 169 + &params, 170 + &headers, 171 + ) 172 + .await 173 + } 174 + Some("regex") => { 175 + handle_static_page( 176 + StaticPageType::CreateRegexConflict, 177 + session, 178 + &params, 179 + &headers, 180 + ) 181 + .await 182 + } 183 + _ => Err("Invalid format".into()), 184 + } 130 185 } 131 186 async fn handle_create_failure_page( 132 187 session: Session, ··· 167 222 168 223 let from = params.get("from").ok_or("Missing from query")?; 169 224 let to = params.get("to").ok_or("Missing to query")?; 225 + let format = params.get("format").ok_or("Missing format query")?; 170 226 171 - match direct::create(from, to, owner, params.get("current")).await { 227 + let create_response = match format.as_str() { 228 + "direct" => direct::create(from, to, owner, params.get("current")).await, 229 + "regex" => regex::create(from, to, owner, params.get("current")).await, 230 + _ => return Err(format!("Invalid format {}", format).into_response().into()), 231 + }; 232 + 233 + match create_response { 172 234 CreationResult::Success => Ok(Redirect::to(&format!( 173 - "/_/create/success?from={}&to={}", 235 + "/_/create/success?from={}&to={}&format={}", 174 236 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 175 237 utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 238 + utf8_percent_encode(&format, NON_ALPHANUMERIC).to_string(), 176 239 )) 177 240 .into_response()), 178 241 CreationResult::Conflict(conflict) => Ok(Redirect::to(&format!( 179 - "/_/create/conflict?from={}&to={}&current={}", 242 + "/_/create/conflict?from={}&to={}&current={}&format={}", 180 243 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 181 244 utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 182 245 utf8_percent_encode(&conflict, NON_ALPHANUMERIC).to_string(), 246 + utf8_percent_encode(&format, NON_ALPHANUMERIC).to_string(), 183 247 )) 184 248 .into_response()), 185 249 CreationResult::Failure => Ok(Redirect::to(&format!( 186 - "/_/create/failure?from={}&to={}", 250 + "/_/create/failure?from={}&to={}&format={}", 187 251 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 188 252 utf8_percent_encode(&to, NON_ALPHANUMERIC).to_string(), 253 + utf8_percent_encode(&format, NON_ALPHANUMERIC).to_string(), 189 254 )) 190 255 .into_response()), 191 256 } ··· 206 271 207 272 let from = params.get("from").ok_or("Missing from query")?; 208 273 let current = params.get("current").ok_or("Missing current query")?; 274 + let format = params.get("format").ok_or("Missing format query")?; 275 + 276 + let delete_result = match format.as_str() { 277 + "direct" => direct::delete(from, current).await, 278 + "regex" => regex::delete(from, current).await, 279 + _ => return Err(format!("Invalid format {}", format).into_response().into()), 280 + }; 209 281 210 - match direct::delete(from, current).await { 282 + match delete_result { 211 283 DeletionResult::Success => Ok(Redirect::to(&format!( 212 - "/_/delete/success?from={}&current={}", 284 + "/_/delete/success?from={}&current={}&format={}", 213 285 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 214 286 utf8_percent_encode(&current, NON_ALPHANUMERIC).to_string(), 287 + utf8_percent_encode(&format, NON_ALPHANUMERIC).to_string(), 215 288 )) 216 289 .into_response()), 217 290 _ => Ok(Redirect::to(&format!( 218 - "/_/delete/failure?from={}&to={}", 291 + "/_/delete/failure?from={}&to={}&format={}", 219 292 utf8_percent_encode(&from, NON_ALPHANUMERIC).to_string(), 220 293 utf8_percent_encode(&current, NON_ALPHANUMERIC).to_string(), 294 + utf8_percent_encode(&format, NON_ALPHANUMERIC).to_string(), 221 295 )) 222 296 .into_response()), 223 297 }
+177
menu/src/regex.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 regex::RegexBuilder; 8 + use std::ops::DerefMut; 9 + 10 + use crate::{CreationResult, DeletionResult, STATE}; 11 + 12 + struct Link { 13 + from: String, 14 + to: String, 15 + owner: String, 16 + } 17 + 18 + pub(crate) async fn get_redirect(go: &str) -> Option<Redirect> { 19 + let redirect = sqlx::query!( 20 + r#"SELECT "from", "to" FROM regex WHERE $1 ~* ('^' || "from" || '$') LIMIT 1"#, 21 + go.to_lowercase() 22 + ) 23 + .fetch_one( 24 + STATE 25 + .get() 26 + .expect("Server must be initialized before processing connections") 27 + .sqlx_connection 28 + .lock() 29 + .await 30 + .deref_mut(), 31 + ) 32 + .await; 33 + 34 + if let Ok(record) = redirect { 35 + let re = RegexBuilder::new(&format!("^{}$", record.from)) 36 + .case_insensitive(true) 37 + .build() 38 + .unwrap(); 39 + let to = re.replace(go, record.to); 40 + Some(Redirect::temporary(&to)) 41 + } else { 42 + None 43 + } 44 + } 45 + 46 + pub(crate) async fn get_link_table(token: &str) -> Result<String> { 47 + let links_query = sqlx::query_as!( 48 + Link, 49 + r#"SELECT "from", "to", "owner" FROM regex ORDER BY regex."from" ASC"#, 50 + ) 51 + .fetch_all( 52 + STATE 53 + .get() 54 + .expect("Server must be initialized before processing connections") 55 + .sqlx_connection 56 + .lock() 57 + .await 58 + .deref_mut(), 59 + ) 60 + .await; 61 + 62 + let Ok(links) = links_query else { 63 + return Err("Failed to query database".into()); 64 + }; 65 + 66 + let mut rows = vec![]; 67 + 68 + for link in links { 69 + let from = html_escape::encode_text(&link.from); 70 + let from_url = utf8_percent_encode(&link.from, NON_ALPHANUMERIC); 71 + let to = html_escape::encode_text(&link.to); 72 + let to_url = utf8_percent_encode(&link.to, NON_ALPHANUMERIC); 73 + let owner = html_escape::encode_text(&link.owner); 74 + 75 + rows.push(format!( 76 + r#"<tr> 77 + <td><code>{from}</code></td> 78 + <td><code>{to}</code></td> 79 + <td>{owner}</td> 80 + <td>(<a href="/_/create?from={from_url}&to={to_url}&current={to_url}&format=regex">edit</a>) (<a href="/_/delete/do?from={from_url}&current={to_url}&token={token}&format=regex">delete</a>)</td> 81 + </tr>"#, 82 + )); 83 + } 84 + 85 + let link_table = rows.join("\n"); 86 + 87 + Ok(link_table) 88 + } 89 + 90 + fn trim_prefix(s: &str, prefix: char) -> &str { 91 + if s.starts_with(prefix) { &s[1..] } else { s } 92 + } 93 + 94 + fn trim_suffix(s: &str, suffix: char) -> &str { 95 + if s.starts_with(suffix) { 96 + &s[..s.len() - 1] 97 + } else { 98 + s 99 + } 100 + } 101 + 102 + pub(crate) async fn create( 103 + from: &str, 104 + to: &str, 105 + owner: &str, 106 + current: Option<&String>, 107 + ) -> CreationResult { 108 + println!("Attempting to make go/{} -> {}", from, to); 109 + 110 + let from = trim_suffix(trim_prefix(from, '^'), '$'); // I think this is maybe broken with | 111 + 112 + let create_call = sqlx::query!( 113 + r#" 114 + WITH insertion AS ( 115 + INSERT INTO regex ("from", "to", "owner") 116 + VALUES ($1, $2, $3) 117 + ON CONFLICT ("from") 118 + DO UPDATE SET "to" = EXCLUDED.to, "owner" = EXCLUDED.owner WHERE regex.to = $4 119 + RETURNING regex.from 120 + ) 121 + SELECT regex.to FROM regex 122 + WHERE regex.from NOT IN (SELECT insertion.from FROM insertion) AND regex.from = $1 123 + "#, // Insert our URL, return a row with the same from that weren't updated (i.e. a conflict) 124 + from.to_lowercase(), 125 + to, 126 + owner, 127 + current, 128 + ) 129 + .fetch_optional( 130 + STATE 131 + .get() 132 + .expect("Server must be initialized before processing connections") 133 + .sqlx_connection 134 + .lock() 135 + .await 136 + .deref_mut(), 137 + ) 138 + .await; 139 + 140 + if let Ok(None) = &create_call { 141 + CreationResult::Success 142 + } else if let Ok(Some(conflict)) = create_call { 143 + CreationResult::Conflict(conflict.to) 144 + } else { 145 + CreationResult::Failure 146 + } 147 + } 148 + 149 + pub(crate) async fn delete(from: &str, current: &str) -> DeletionResult { 150 + println!("Attempting to delete go/{} -> {}", from, current); 151 + 152 + let delete_call = sqlx::query!( 153 + r#"DELETE FROM regex WHERE regex.from = $1 AND regex.to = $2"#, 154 + from.to_lowercase(), 155 + current, 156 + ) 157 + .execute( 158 + STATE 159 + .get() 160 + .expect("Server must be initialized before processing connections") 161 + .sqlx_connection 162 + .lock() 163 + .await 164 + .deref_mut(), 165 + ) 166 + .await; 167 + 168 + let Ok(delete_result) = &delete_call else { 169 + return DeletionResult::Failure; 170 + }; 171 + 172 + if delete_result.rows_affected() > 0 { 173 + DeletionResult::Success 174 + } else { 175 + DeletionResult::NotFound 176 + } 177 + }
+59 -8
menu/src/static_html.rs
··· 40 40 #[derive(Clone)] 41 41 pub(crate) enum StaticPageType { 42 42 Create, 43 - CreateConflict, 43 + CreateDirectConflict, 44 + CreateRegexConflict, 44 45 CreateFailure, 45 - CreateSuccess, 46 + CreateDirectSuccess, 47 + CreateRegexSuccess, 46 48 DeleteFailure, 47 49 DeleteSuccess, 48 50 Index, ··· 60 62 61 63 let html = match page_type { 62 64 StaticPageType::Create => include_String_dynamic!("./html/create.html"), 63 - StaticPageType::CreateConflict => include_String_dynamic!("./html/create/conflict.html"), 65 + StaticPageType::CreateDirectConflict => { 66 + include_String_dynamic!("./html/create/conflict/direct.html") 67 + } 68 + StaticPageType::CreateRegexConflict => { 69 + include_String_dynamic!("./html/create/conflict/regex.html") 70 + } 64 71 StaticPageType::CreateFailure => include_String_dynamic!("./html/create/failure.html"), 65 - StaticPageType::CreateSuccess => include_String_dynamic!("./html/create/success.html"), 72 + StaticPageType::CreateDirectSuccess => { 73 + include_String_dynamic!("./html/create/success/direct.html") 74 + } 75 + StaticPageType::CreateRegexSuccess => { 76 + include_String_dynamic!("./html/create/success/regex.html") 77 + } 66 78 StaticPageType::DeleteFailure => include_String_dynamic!("./html/delete/failure.html"), 67 79 StaticPageType::DeleteSuccess => include_String_dynamic!("./html/delete/success.html"), 68 80 StaticPageType::Index => include_String_dynamic!("./html/index.html"), ··· 115 127 .and_then(|current| Some(AnyString::Ref(current.as_str()))) 116 128 }), 117 129 ); 130 + replacements.insert( 131 + "format", 132 + Box::new(|| { 133 + params 134 + .get("format") 135 + .and_then(|format| Some(AnyString::Ref(format.as_str()))) 136 + }), 137 + ); 118 138 replacements.insert( 119 139 "username", 120 140 Box::new(move || username.and_then(|name| Some(AnyString::Ref(name)))), ··· 122 142 123 143 let token = get_token(&session).await; 124 144 if matches!(page_type, StaticPageType::Index) { 125 - let link_table = direct::get_link_table(&token).await?; 145 + let direct_link_table = direct::get_link_table(&token).await?; 146 + 147 + replacements.insert( 148 + "direct_links", 149 + Box::new(move || Some(AnyString::Owned(direct_link_table.clone()))), 150 + ); 151 + 152 + let regex_link_table = crate::regex::get_link_table(&token).await?; 126 153 127 154 replacements.insert( 128 - "links", 129 - Box::new(move || Some(AnyString::Owned(link_table.clone()))), 155 + "regex_links", 156 + Box::new(move || Some(AnyString::Owned(regex_link_table.clone()))), 130 157 ); 131 158 } 132 159 ··· 143 170 html: String, 144 171 replacements: HashMap<&str, Box<dyn 'a + Send + Fn() -> Option<AnyString<'a>>>>, 145 172 ) -> String { 146 - let re = regex_static::static_regex!(r"\{([a-z_]+)(?::([a-z_]+))?\}"); 173 + let re = regex_static::static_regex!(r"\{([a-z_]+)(?::([a-z_]+)(?::(.*))?)?\}"); 147 174 re.replace_all(&html, |captures: &Captures| { 148 175 let replacement_name = &captures[1]; 149 176 let replacement = replacements ··· 162 189 html_escape::encode_quoted_attribute(&replacement_owned).to_string() 163 190 } 164 191 Some("url") => utf8_percent_encode(&replacement_owned, NON_ALPHANUMERIC).to_string(), 192 + Some("if") => { 193 + let Some(params) = captures.get(3) else { 194 + return "MISSING_IF_PARAMS".to_string(); 195 + }; 196 + 197 + let (cond_type, cond_arg, if_true, if_false) = 198 + match params.as_str().split(':').collect::<Vec<_>>()[..] { 199 + [cond_type, cond_arg, if_true, if_false] => { 200 + (cond_type, cond_arg, if_true, if_false) 201 + } 202 + [cond_type, cond_arg, if_true] => (cond_type, cond_arg, if_true, ""), 203 + _ => return "INVALID_IF_PARAMS".to_string(), 204 + }; 205 + 206 + match cond_type { 207 + "equal" => if cond_arg == replacement_owned { 208 + if_true 209 + } else { 210 + if_false 211 + } 212 + .to_string(), 213 + _ => return "INVALID_IF_COND_TYPE".to_string(), 214 + } 215 + } 165 216 None => html_escape::encode_text(&replacement_owned).to_string(), 166 217 Some(_) => "UNKNOWN_MATCH_TYPE".to_string(), 167 218 }

History

4 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
feat(m): add regex support
5/5 success
expand
expand 0 comments
closed without merging
1 commit
expand
feat(m): add regex support
1/5 failed, 4/5 success
expand
expand 0 comments
1 commit
expand
feat(m): add regex support
1/5 failed, 4/5 success
expand
expand 0 comments
1 commit
expand
feat(m): add regex support
expand 0 comments