chore: improved navigation and auth params
919fb282
6 file(s) · +41 −6
| 3929 | 3929 | ||
| 3930 | 3930 | [[package]] |
|
| 3931 | 3931 | name = "shrink" |
|
| 3932 | - | version = "0.1.1" |
|
| 3932 | + | version = "0.1.2" |
|
| 3933 | 3933 | dependencies = [ |
|
| 3934 | 3934 | "askama 0.15.6", |
|
| 3935 | 3935 | "axum", |
| 27 | 27 | return Ok(AuthSession); |
|
| 28 | 28 | } |
|
| 29 | 29 | } |
|
| 30 | - | Err(Redirect::to("/admin/login").into_response()) |
|
| 30 | + | let path = parts.uri.path_and_query() |
|
| 31 | + | .map(|pq| pq.as_str()) |
|
| 32 | + | .unwrap_or(parts.uri.path()); |
|
| 33 | + | let login_url = format!("/admin/login?next={}", urlencoding(path)); |
|
| 34 | + | Err(Redirect::to(&login_url).into_response()) |
|
| 31 | 35 | } |
|
| 32 | 36 | } |
|
| 33 | 37 | ||
| 58 | 62 | "{:04}-{:02}-{:02} {:02}:{:02}:{:02}", |
|
| 59 | 63 | year, month, day, hours, minutes, seconds |
|
| 60 | 64 | ) |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | fn urlencoding(s: &str) -> String { |
|
| 68 | + | let mut out = String::with_capacity(s.len()); |
|
| 69 | + | for b in s.bytes() { |
|
| 70 | + | match b { |
|
| 71 | + | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => { |
|
| 72 | + | out.push(b as char); |
|
| 73 | + | } |
|
| 74 | + | _ => { |
|
| 75 | + | out.push_str(&format!("%{:02X}", b)); |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | out |
|
| 61 | 80 | } |
|
| 62 | 81 | ||
| 63 | 82 | fn days_to_ymd(mut days: i64) -> (i64, i64, i64) { |
|
| 37 | 37 | #[template(path = "login.html")] |
|
| 38 | 38 | struct LoginTemplate { |
|
| 39 | 39 | error: Option<String>, |
|
| 40 | + | next: Option<String>, |
|
| 40 | 41 | } |
|
| 41 | 42 | ||
| 42 | 43 | struct WineWithSvg { |
|
| 76 | 77 | #[derive(serde::Deserialize, Default)] |
|
| 77 | 78 | pub struct FlashQuery { |
|
| 78 | 79 | pub error: Option<String>, |
|
| 80 | + | pub next: Option<String>, |
|
| 79 | 81 | } |
|
| 80 | 82 | ||
| 81 | 83 | #[derive(serde::Deserialize)] |
|
| 226 | 228 | // --- Auth handlers --- |
|
| 227 | 229 | ||
| 228 | 230 | async fn get_login(Query(q): Query<FlashQuery>) -> Response { |
|
| 229 | - | WebTemplate(LoginTemplate { error: q.error }).into_response() |
|
| 231 | + | WebTemplate(LoginTemplate { error: q.error, next: q.next }).into_response() |
|
| 230 | 232 | } |
|
| 231 | 233 | ||
| 232 | 234 | async fn post_login( |
|
| 235 | + | Query(q): Query<FlashQuery>, |
|
| 233 | 236 | State(state): State<Arc<AppState>>, |
|
| 234 | 237 | axum::extract::Form(form): axum::extract::Form<LoginForm>, |
|
| 235 | 238 | ) -> Response { |
|
| 239 | + | let next = q.next.as_deref().unwrap_or("/admin"); |
|
| 236 | 240 | if !auth::verify_password(&form.password, &state.app_password) { |
|
| 237 | - | return Redirect::to("/admin/login?error=Invalid+password").into_response(); |
|
| 241 | + | return Redirect::to(&format!("/admin/login?error=Invalid+password&next={}", urlencoded(next))).into_response(); |
|
| 238 | 242 | } |
|
| 239 | 243 | ||
| 240 | 244 | let token = auth::generate_session_token(); |
|
| 268 | 272 | let _ = db::prune_expired_sessions(&state.db); |
|
| 269 | 273 | ||
| 270 | 274 | let cookie = auth::build_session_cookie(&token, state.cookie_secure); |
|
| 271 | - | let mut resp = Redirect::to("/admin").into_response(); |
|
| 275 | + | // Only allow relative redirects to prevent open redirect |
|
| 276 | + | let redirect_to = if next.starts_with('/') { next } else { "/admin" }; |
|
| 277 | + | let mut resp = Redirect::to(redirect_to).into_response(); |
|
| 272 | 278 | resp.headers_mut().insert( |
|
| 273 | 279 | axum::http::header::SET_COOKIE, |
|
| 274 | 280 | HeaderValue::from_str(&cookie).unwrap(), |
|
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}Cellar{% endblock %} |
|
| 3 | + | {% block nav %} |
|
| 4 | + | <nav class="links"> |
|
| 5 | + | <a href="/admin/new">new</a> |
|
| 6 | + | </nav> |
|
| 7 | + | {% endblock %} |
|
| 3 | 8 | {% block content %} |
|
| 4 | 9 | {% if wines.is_empty() %} |
|
| 5 | 10 | <p class="empty">no wines yet</p> |
| 15 | 15 | {% if let Some(error) = error %} |
|
| 16 | 16 | <p class="error">{{ error }}</p> |
|
| 17 | 17 | {% endif %} |
|
| 18 | - | <form method="POST" action="/admin/login" class="form"> |
|
| 18 | + | <form method="POST" action="/admin/login{% if let Some(next) = next %}?next={{ next }}{% endif %}" class="form"> |
|
| 19 | 19 | <label for="password">password</label> |
|
| 20 | 20 | <input type="password" id="password" name="password" autofocus required> |
|
| 21 | 21 | <button type="submit">login</button> |
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% block title %}{{ wine.name }} - Cellar{% endblock %} |
|
| 3 | + | {% block nav %} |
|
| 4 | + | <nav class="links"> |
|
| 5 | + | <a href="/admin/edit/{{ wine.short_id }}">edit</a> |
|
| 6 | + | </nav> |
|
| 7 | + | {% endblock %} |
|
| 3 | 8 | {% block content %} |
|
| 4 | 9 | <div class="wine-detail"> |
|
| 5 | 10 | <h1 class="wine-detail-name">{{ wine.name }}</h1> |