Merge pull request #10 from stevedylandev/chore/cellar-nav-improvements 95c2b1ca
chore: improved navigation and auth params
Steve Simkins · 2026-04-05 19:35 6 file(s) · +41 −6
Cargo.lock +1 −1
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",
apps/cellar/src/auth.rs +20 −1
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) {
apps/cellar/src/server.rs +9 −3
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(),
apps/cellar/templates/index.html +5 −0
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>
apps/cellar/templates/login.html +1 −1
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>
apps/cellar/templates/wine.html +5 −0
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>