chore: initial refactor d29d0533
Steve · 2026-04-14 20:09 19 file(s) · +2724 −2855
Cargo.lock +1 −1
2191 2191
2192 2192
[[package]]
2193 2193
name = "jotts"
2194 -
version = "0.1.2"
2194 +
version = "0.2.0"
2195 2195
dependencies = [
2196 2196
 "andromeda-auth",
2197 2197
 "arboard",
apps/cellar/src/auth.rs +4 −38
27 27
                return Ok(AuthSession);
28 28
            }
29 29
        }
30 -
        let path = parts.uri.path_and_query()
30 +
        let path = parts
31 +
            .uri
32 +
            .path_and_query()
31 33
            .map(|pq| pq.as_str())
32 34
            .unwrap_or(parts.uri.path());
33 35
        let login_url = format!("/admin/login?next={}", urlencoding(path));
44 46
45 47
fn is_valid_session(state: &AppState, token: &str) -> bool {
46 48
    match db::get_session_expiry(&state.db, token) {
47 -
        Ok(Some(expires_at)) => {
48 -
            let now = chrono_now();
49 -
            expires_at > now
50 -
        }
49 +
        Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(),
51 50
        _ => false,
52 51
    }
53 52
}
54 53
55 -
pub fn chrono_now() -> String {
56 -
    use std::time::{SystemTime, UNIX_EPOCH};
57 -
    let secs = SystemTime::now()
58 -
        .duration_since(UNIX_EPOCH)
59 -
        .unwrap()
60 -
        .as_secs();
61 -
    let days_since_epoch = secs / 86400;
62 -
    let time_of_day = secs % 86400;
63 -
    let hours = time_of_day / 3600;
64 -
    let minutes = (time_of_day % 3600) / 60;
65 -
    let seconds = time_of_day % 60;
66 -
67 -
    let (year, month, day) = days_to_ymd(days_since_epoch as i64);
68 -
    format!(
69 -
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
70 -
        year, month, day, hours, minutes, seconds
71 -
    )
72 -
}
73 -
74 54
fn urlencoding(s: &str) -> String {
75 55
    let mut out = String::with_capacity(s.len());
76 56
    for b in s.bytes() {
85 65
    }
86 66
    out
87 67
}
88 -
89 -
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
90 -
    days += 719468;
91 -
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
92 -
    let doe = (days - era * 146097) as u32;
93 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
94 -
    let y = yoe as i64 + era * 400;
95 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
96 -
    let mp = (5 * doy + 2) / 153;
97 -
    let d = doy - (153 * mp + 2) / 5 + 1;
98 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
99 -
    let y = if m <= 2 { y + 1 } else { y };
100 -
    (y, m as i64, d as i64)
101 -
}
apps/cellar/src/server.rs (deleted) +0 −1092
1 -
use askama::Template;
2 -
use image::ImageDecoder;
3 -
use askama_web::WebTemplate;
4 -
use axum::{
5 -
    extract::{DefaultBodyLimit, Multipart, Path, Query, State},
6 -
    http::{HeaderValue, StatusCode},
7 -
    response::{Html, IntoResponse, Json, Redirect, Response},
8 -
    routing::{get, post},
9 -
    Router,
10 -
};
11 -
use rust_embed::Embed;
12 -
use std::sync::Arc;
13 -
14 -
use crate::auth;
15 -
use crate::claude;
16 -
use crate::db::{self, Db, Wine};
17 -
18 -
#[derive(Clone)]
19 -
pub struct AppState {
20 -
    pub db: Db,
21 -
    pub app_password: String,
22 -
    pub cookie_secure: bool,
23 -
    pub anthropic_api_key: Option<String>,
24 -
}
25 -
26 -
#[derive(Embed)]
27 -
#[folder = "static/"]
28 -
struct Static;
29 -
30 -
// --- Templates ---
31 -
32 -
#[derive(Template)]
33 -
#[template(path = "base.html")]
34 -
struct BaseTemplate;
35 -
36 -
#[derive(Template)]
37 -
#[template(path = "login.html")]
38 -
struct LoginTemplate {
39 -
    error: Option<String>,
40 -
    next: Option<String>,
41 -
}
42 -
43 -
struct WineWithSvg {
44 -
    wine: Wine,
45 -
    pentagon_svg: String,
46 -
}
47 -
48 -
#[derive(Template)]
49 -
#[template(path = "index.html")]
50 -
struct IndexTemplate {
51 -
    wines: Vec<WineWithSvg>,
52 -
}
53 -
54 -
#[derive(Template)]
55 -
#[template(path = "wine.html")]
56 -
struct WineDetailTemplate {
57 -
    wine: Wine,
58 -
    pentagon_svg: String,
59 -
    bars_svg: String,
60 -
}
61 -
62 -
#[derive(Template)]
63 -
#[template(path = "admin.html")]
64 -
struct AdminTemplate {
65 -
    wines: Vec<Wine>,
66 -
}
67 -
68 -
#[derive(Template)]
69 -
#[template(path = "wine_form.html")]
70 -
struct WineFormTemplate {
71 -
    wine: Option<Wine>,
72 -
    error: Option<String>,
73 -
    has_anthropic_key: bool,
74 -
}
75 -
76 -
#[derive(Template)]
77 -
#[template(path = "wishlist.html")]
78 -
struct WishlistTemplate {
79 -
    wines: Vec<Wine>,
80 -
    is_admin: bool,
81 -
}
82 -
83 -
#[derive(Template)]
84 -
#[template(path = "wishlist_form.html")]
85 -
struct WishlistFormTemplate {
86 -
    wine: Option<Wine>,
87 -
    error: Option<String>,
88 -
    has_anthropic_key: bool,
89 -
}
90 -
91 -
// --- Query/Form structs ---
92 -
93 -
#[derive(serde::Deserialize, Default)]
94 -
pub struct FlashQuery {
95 -
    pub error: Option<String>,
96 -
    pub next: Option<String>,
97 -
}
98 -
99 -
#[derive(serde::Deserialize)]
100 -
struct LoginForm {
101 -
    password: String,
102 -
}
103 -
104 -
// --- Static file handlers ---
105 -
106 -
fn mime_from_path(path: &str) -> &'static str {
107 -
    match path.rsplit('.').next().unwrap_or("") {
108 -
        "css" => "text/css",
109 -
        "js" => "application/javascript",
110 -
        "html" => "text/html",
111 -
        "png" => "image/png",
112 -
        "jpg" | "jpeg" => "image/jpeg",
113 -
        "ico" => "image/x-icon",
114 -
        "svg" => "image/svg+xml",
115 -
        "woff" | "woff2" => "font/woff2",
116 -
        "ttf" => "font/ttf",
117 -
        "otf" => "font/otf",
118 -
        "json" | "webmanifest" => "application/json",
119 -
        _ => "application/octet-stream",
120 -
    }
121 -
}
122 -
123 -
async fn serve_static(Path(path): Path<String>) -> Response {
124 -
    match Static::get(&path) {
125 -
        Some(file) => {
126 -
            let mime = mime_from_path(&path);
127 -
            (
128 -
                StatusCode::OK,
129 -
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
130 -
                file.data.to_vec(),
131 -
            )
132 -
                .into_response()
133 -
        }
134 -
        None => StatusCode::NOT_FOUND.into_response(),
135 -
    }
136 -
}
137 -
138 -
// --- Pentagon SVG ---
139 -
140 -
fn build_pentagon_svg(
141 -
    sweetness: i32,
142 -
    acidity: i32,
143 -
    tannin: i32,
144 -
    alcohol: i32,
145 -
    body: i32,
146 -
    size: f64,
147 -
    show_labels: bool,
148 -
) -> String {
149 -
    let cx = size / 2.0;
150 -
    let cy = size / 2.0;
151 -
    let margin = if show_labels { 30.0 } else { 5.0 };
152 -
    let r = size / 2.0 - margin;
153 -
154 -
    let scores = [sweetness, acidity, tannin, alcohol, body];
155 -
    let labels = ["Sweetness", "Acidity", "Tannin", "Alcohol", "Body"];
156 -
157 -
    let angles: Vec<f64> = (0..5)
158 -
        .map(|i| (-90.0_f64 + 72.0 * i as f64).to_radians())
159 -
        .collect();
160 -
161 -
    let mut svg = format!(
162 -
        r#"<svg viewBox="0 0 {s} {s}" width="100%" xmlns="http://www.w3.org/2000/svg">"#,
163 -
        s = size
164 -
    );
165 -
166 -
    // Grid pentagons at 20%, 40%, 60%, 80%
167 -
    for pct in &[0.2, 0.4, 0.6, 0.8] {
168 -
        let points: String = angles
169 -
            .iter()
170 -
            .map(|a| format!("{:.1},{:.1}", cx + r * pct * a.cos(), cy + r * pct * a.sin()))
171 -
            .collect::<Vec<_>>()
172 -
            .join(" ");
173 -
        svg.push_str(&format!(
174 -
            r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#,
175 -
            points
176 -
        ));
177 -
    }
178 -
179 -
    // Outer pentagon (100%)
180 -
    let outline: String = angles
181 -
        .iter()
182 -
        .map(|a| format!("{:.1},{:.1}", cx + r * a.cos(), cy + r * a.sin()))
183 -
        .collect::<Vec<_>>()
184 -
        .join(" ");
185 -
    svg.push_str(&format!(
186 -
        r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="1"/>"#,
187 -
        outline
188 -
    ));
189 -
190 -
    // Axis lines from center to each vertex
191 -
    for a in &angles {
192 -
        svg.push_str(&format!(
193 -
            r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#,
194 -
            cx, cy, cx + r * a.cos(), cy + r * a.sin()
195 -
        ));
196 -
    }
197 -
198 -
    // Data polygon
199 -
    let data_points: Vec<(f64, f64)> = scores
200 -
        .iter()
201 -
        .zip(&angles)
202 -
        .map(|(s, a)| {
203 -
            let d = (*s as f64 / 5.0) * r;
204 -
            (cx + d * a.cos(), cy + d * a.sin())
205 -
        })
206 -
        .collect();
207 -
208 -
    let data_str: String = data_points
209 -
        .iter()
210 -
        .map(|(x, y)| format!("{:.1},{:.1}", x, y))
211 -
        .collect::<Vec<_>>()
212 -
        .join(" ");
213 -
    svg.push_str(&format!(
214 -
        r#"<polygon points="{}" fill="white" fill-opacity="0.08" stroke="white" stroke-width="1.5"/>"#,
215 -
        data_str
216 -
    ));
217 -
218 -
    // Data dots
219 -
    for (x, y) in &data_points {
220 -
        svg.push_str(&format!(
221 -
            r#"<circle cx="{:.1}" cy="{:.1}" r="2.5" fill="white"/>"#,
222 -
            x, y
223 -
        ));
224 -
    }
225 -
226 -
    // Labels
227 -
    if show_labels {
228 -
        for (i, label) in labels.iter().enumerate() {
229 -
            let a = angles[i];
230 -
            let label_dist = r + 18.0;
231 -
            let lx = cx + label_dist * a.cos();
232 -
            let ly = cy + label_dist * a.sin() + 3.5;
233 -
            svg.push_str(&format!(
234 -
                r#"<text x="{:.1}" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace" text-anchor="middle">{}</text>"#,
235 -
                lx, ly, label
236 -
            ));
237 -
        }
238 -
    }
239 -
240 -
    svg.push_str("</svg>");
241 -
    svg
242 -
}
243 -
244 -
fn build_bars_svg(
245 -
    clarity: i32,
246 -
    color_intensity: i32,
247 -
    aroma_intensity: i32,
248 -
    nose_complexity: i32,
249 -
    width: f64,
250 -
) -> String {
251 -
    let bar_height = 4.0;
252 -
    let row_height = 22.0;
253 -
    let section_gap = 14.0;
254 -
    let label_width = 100.0;
255 -
    let track_left = label_width + 4.0;
256 -
    let track_width = width - track_left - 10.0;
257 -
    let header_size = 9.0;
258 -
259 -
    let sections: &[(&str, &[(&str, i32)])] = &[
260 -
        ("Appearance", &[("Clarity", clarity), ("Intensity", color_intensity)]),
261 -
        ("Nose", &[("Aroma", aroma_intensity), ("Complexity", nose_complexity)]),
262 -
    ];
263 -
264 -
    let total_rows: usize = sections.iter().map(|(_, attrs)| attrs.len()).sum();
265 -
    let total_height = (sections.len() as f64) * (header_size + 8.0)
266 -
        + (total_rows as f64) * row_height
267 -
        + section_gap;
268 -
269 -
    let mut svg = format!(
270 -
        r#"<svg viewBox="0 0 {w} {h}" width="100%" xmlns="http://www.w3.org/2000/svg">"#,
271 -
        w = width,
272 -
        h = total_height
273 -
    );
274 -
275 -
    let mut y = 4.0;
276 -
277 -
    for (si, (section_name, attrs)) in sections.iter().enumerate() {
278 -
        if si > 0 {
279 -
            y += section_gap;
280 -
        }
281 -
282 -
        svg.push_str(&format!(
283 -
            r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.4" font-size="{}" font-family="Commit Mono, monospace" text-transform="uppercase" letter-spacing="1">{}</text>"#,
284 -
            y + header_size, header_size, section_name
285 -
        ));
286 -
        y += header_size + 8.0;
287 -
288 -
        for (label, score) in *attrs {
289 -
            let bar_y = y + (row_height - bar_height) / 2.0;
290 -
            let fill_width = (*score as f64 / 5.0) * track_width;
291 -
292 -
            svg.push_str(&format!(
293 -
                r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace">{}</text>"#,
294 -
                y + row_height / 2.0 + 3.0, label
295 -
            ));
296 -
297 -
            svg.push_str(&format!(
298 -
                r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.08"/>"#,
299 -
                track_left, bar_y, track_width, bar_height
300 -
            ));
301 -
302 -
            if fill_width > 0.0 {
303 -
                svg.push_str(&format!(
304 -
                    r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.6"/>"#,
305 -
                    track_left, bar_y, fill_width, bar_height
306 -
                ));
307 -
308 -
            }
309 -
310 -
            y += row_height;
311 -
        }
312 -
    }
313 -
314 -
    svg.push_str("</svg>");
315 -
    svg
316 -
}
317 -
318 -
// --- Auth handlers ---
319 -
320 -
async fn get_login(Query(q): Query<FlashQuery>) -> Response {
321 -
    WebTemplate(LoginTemplate { error: q.error, next: q.next }).into_response()
322 -
}
323 -
324 -
async fn post_login(
325 -
    Query(q): Query<FlashQuery>,
326 -
    State(state): State<Arc<AppState>>,
327 -
    axum::extract::Form(form): axum::extract::Form<LoginForm>,
328 -
) -> Response {
329 -
    let next = q.next.as_deref().unwrap_or("/admin");
330 -
    if !auth::verify_password(&form.password, &state.app_password) {
331 -
        return Redirect::to(&format!("/admin/login?error=Invalid+password&next={}", urlencoded(next))).into_response();
332 -
    }
333 -
334 -
    let token = auth::generate_session_token();
335 -
336 -
    let expires_at = {
337 -
        use std::time::{SystemTime, UNIX_EPOCH};
338 -
        let secs = SystemTime::now()
339 -
            .duration_since(UNIX_EPOCH)
340 -
            .unwrap()
341 -
            .as_secs()
342 -
            + 7 * 24 * 3600;
343 -
        let days = secs / 86400;
344 -
        let tod = secs % 86400;
345 -
        let (y, m, d) = days_to_ymd(days as i64);
346 -
        format!(
347 -
            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
348 -
            y,
349 -
            m,
350 -
            d,
351 -
            tod / 3600,
352 -
            (tod % 3600) / 60,
353 -
            tod % 60
354 -
        )
355 -
    };
356 -
357 -
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
358 -
        tracing::error!("Failed to create session: {}", e);
359 -
        return Redirect::to("/admin/login?error=Server+error").into_response();
360 -
    }
361 -
362 -
    let _ = db::prune_expired_sessions(&state.db);
363 -
364 -
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
365 -
    // Only allow relative redirects to prevent open redirect
366 -
    let redirect_to = if next.starts_with('/') { next } else { "/admin" };
367 -
    let mut resp = Redirect::to(redirect_to).into_response();
368 -
    resp.headers_mut().insert(
369 -
        axum::http::header::SET_COOKIE,
370 -
        HeaderValue::from_str(&cookie).unwrap(),
371 -
    );
372 -
    resp
373 -
}
374 -
375 -
async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response {
376 -
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
377 -
        for part in cookie_header.split(';') {
378 -
            let part = part.trim();
379 -
            if let Some(val) = part.strip_prefix("session=") {
380 -
                let val = val.trim();
381 -
                if !val.is_empty() {
382 -
                    let _ = db::delete_session(&state.db, val);
383 -
                }
384 -
            }
385 -
        }
386 -
    }
387 -
388 -
    let cookie = auth::clear_session_cookie();
389 -
    let mut resp = Redirect::to("/admin/login").into_response();
390 -
    resp.headers_mut().insert(
391 -
        axum::http::header::SET_COOKIE,
392 -
        HeaderValue::from_str(&cookie).unwrap(),
393 -
    );
394 -
    resp
395 -
}
396 -
397 -
// --- Public handlers ---
398 -
399 -
async fn get_index(State(state): State<Arc<AppState>>) -> Response {
400 -
    match db::get_cellar_wines(&state.db) {
401 -
        Ok(wines) => {
402 -
            let wines: Vec<WineWithSvg> = wines
403 -
                .into_iter()
404 -
                .map(|wine| {
405 -
                    let pentagon_svg = build_pentagon_svg(
406 -
                        wine.sweetness,
407 -
                        wine.acidity,
408 -
                        wine.tannin,
409 -
                        wine.alcohol,
410 -
                        wine.body,
411 -
                        80.0,
412 -
                        false,
413 -
                    );
414 -
                    WineWithSvg { wine, pentagon_svg }
415 -
                })
416 -
                .collect();
417 -
            WebTemplate(IndexTemplate { wines }).into_response()
418 -
        }
419 -
        Err(e) => {
420 -
            tracing::error!("Failed to list wines: {}", e);
421 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
422 -
        }
423 -
    }
424 -
}
425 -
426 -
async fn get_wine_detail(
427 -
    State(state): State<Arc<AppState>>,
428 -
    Path(short_id): Path<String>,
429 -
) -> Response {
430 -
    match db::get_wine_by_short_id(&state.db, &short_id) {
431 -
        Ok(Some(wine)) => {
432 -
            let pentagon_svg = build_pentagon_svg(
433 -
                wine.sweetness,
434 -
                wine.acidity,
435 -
                wine.tannin,
436 -
                wine.alcohol,
437 -
                wine.body,
438 -
                250.0,
439 -
                true,
440 -
            );
441 -
            let bars_svg = build_bars_svg(
442 -
                wine.clarity,
443 -
                wine.color_intensity,
444 -
                wine.aroma_intensity,
445 -
                wine.nose_complexity,
446 -
                250.0,
447 -
            );
448 -
            WebTemplate(WineDetailTemplate { wine, pentagon_svg, bars_svg }).into_response()
449 -
        }
450 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
451 -
        Err(e) => {
452 -
            tracing::error!("Failed to get wine: {}", e);
453 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
454 -
        }
455 -
    }
456 -
}
457 -
458 -
async fn get_wine_image(
459 -
    State(state): State<Arc<AppState>>,
460 -
    Path(short_id): Path<String>,
461 -
) -> Response {
462 -
    match db::get_wine_image(&state.db, &short_id) {
463 -
        Ok(Some((bytes, mime))) => {
464 -
            let content_type = HeaderValue::from_str(&mime).unwrap_or_else(|_| {
465 -
                HeaderValue::from_static("application/octet-stream")
466 -
            });
467 -
            (
468 -
                StatusCode::OK,
469 -
                [(axum::http::header::CONTENT_TYPE, content_type)],
470 -
                bytes,
471 -
            )
472 -
                .into_response()
473 -
        }
474 -
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
475 -
        Err(e) => {
476 -
            tracing::error!("Failed to get wine image: {}", e);
477 -
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
478 -
        }
479 -
    }
480 -
}
481 -
482 -
// --- Admin handlers ---
483 -
484 -
async fn get_admin(
485 -
    _session: auth::AuthSession,
486 -
    State(state): State<Arc<AppState>>,
487 -
) -> Response {
488 -
    match db::get_cellar_wines(&state.db) {
489 -
        Ok(wines) => WebTemplate(AdminTemplate { wines }).into_response(),
490 -
        Err(e) => {
491 -
            tracing::error!("Failed to list wines: {}", e);
492 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
493 -
        }
494 -
    }
495 -
}
496 -
497 -
async fn get_new_wine(
498 -
    _session: auth::AuthSession,
499 -
    State(state): State<Arc<AppState>>,
500 -
    Query(q): Query<FlashQuery>,
501 -
) -> Response {
502 -
    WebTemplate(WineFormTemplate {
503 -
        wine: None,
504 -
        error: q.error,
505 -
        has_anthropic_key: state.anthropic_api_key.is_some(),
506 -
    })
507 -
    .into_response()
508 -
}
509 -
510 -
async fn get_edit_wine(
511 -
    _session: auth::AuthSession,
512 -
    State(state): State<Arc<AppState>>,
513 -
    Path(short_id): Path<String>,
514 -
    Query(q): Query<FlashQuery>,
515 -
) -> Response {
516 -
    match db::get_wine_by_short_id(&state.db, &short_id) {
517 -
        Ok(Some(wine)) => WebTemplate(WineFormTemplate {
518 -
            wine: Some(wine),
519 -
            error: q.error,
520 -
            has_anthropic_key: state.anthropic_api_key.is_some(),
521 -
        })
522 -
        .into_response(),
523 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
524 -
        Err(e) => {
525 -
            tracing::error!("Failed to get wine: {}", e);
526 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
527 -
        }
528 -
    }
529 -
}
530 -
531 -
// --- Image processing ---
532 -
533 -
fn process_image(data: &[u8]) -> Result<Vec<u8>, String> {
534 -
    let reader = image::ImageReader::new(std::io::Cursor::new(data))
535 -
        .with_guessed_format()
536 -
        .map_err(|e| format!("Failed to read image: {}", e))?;
537 -
    let mut decoder = reader
538 -
        .into_decoder()
539 -
        .map_err(|e| format!("Failed to create decoder: {}", e))?;
540 -
    let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms);
541 -
    let mut img = image::DynamicImage::from_decoder(decoder)
542 -
        .map_err(|e| format!("Failed to decode image: {}", e))?;
543 -
    img.apply_orientation(orientation);
544 -
    let mut output = Vec::new();
545 -
    let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 75);
546 -
    img.write_with_encoder(encoder)
547 -
        .map_err(|e| format!("JPEG encoding failed: {}", e))?;
548 -
    Ok(output)
549 -
}
550 -
551 -
// --- Multipart parsing ---
552 -
553 -
struct WineFormData {
554 -
    name: String,
555 -
    origin: String,
556 -
    grape: String,
557 -
    notes: String,
558 -
    background: String,
559 -
    image: Option<Vec<u8>>,
560 -
    image_mime: Option<String>,
561 -
    sweetness: i32,
562 -
    acidity: i32,
563 -
    tannin: i32,
564 -
    alcohol: i32,
565 -
    body: i32,
566 -
    clarity: i32,
567 -
    color_intensity: i32,
568 -
    aroma_intensity: i32,
569 -
    nose_complexity: i32,
570 -
}
571 -
572 -
async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> {
573 -
    let mut name = String::new();
574 -
    let mut origin = String::new();
575 -
    let mut grape = String::new();
576 -
    let mut notes = String::new();
577 -
    let mut background = String::new();
578 -
    let mut image: Option<Vec<u8>> = None;
579 -
    let mut image_mime: Option<String> = None;
580 -
    let mut sweetness = 3;
581 -
    let mut acidity = 3;
582 -
    let mut tannin = 3;
583 -
    let mut alcohol = 3;
584 -
    let mut body = 3;
585 -
    let mut clarity = 3;
586 -
    let mut color_intensity = 3;
587 -
    let mut aroma_intensity = 3;
588 -
    let mut nose_complexity = 3;
589 -
590 -
    while let Ok(Some(field)) = multipart.next_field().await {
591 -
        let field_name = field.name().unwrap_or("").to_string();
592 -
        match field_name.as_str() {
593 -
            "image" => {
594 -
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
595 -
                if !bytes.is_empty() {
596 -
                    let processed = process_image(&bytes)?;
597 -
                    image = Some(processed);
598 -
                    image_mime = Some("image/jpeg".to_string());
599 -
                }
600 -
            }
601 -
            "name" => name = field.text().await.unwrap_or_default(),
602 -
            "origin" => origin = field.text().await.unwrap_or_default(),
603 -
            "grape" => grape = field.text().await.unwrap_or_default(),
604 -
            "notes" => notes = field.text().await.unwrap_or_default(),
605 -
            "background" => background = field.text().await.unwrap_or_default(),
606 -
            "sweetness" => sweetness = field.text().await.unwrap_or_default().parse().unwrap_or(3),
607 -
            "acidity" => acidity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
608 -
            "tannin" => tannin = field.text().await.unwrap_or_default().parse().unwrap_or(3),
609 -
            "alcohol" => alcohol = field.text().await.unwrap_or_default().parse().unwrap_or(3),
610 -
            "body" => body = field.text().await.unwrap_or_default().parse().unwrap_or(3),
611 -
            "clarity" => clarity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
612 -
            "color_intensity" => color_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
613 -
            "aroma_intensity" => aroma_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
614 -
            "nose_complexity" => nose_complexity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
615 -
            _ => {}
616 -
        }
617 -
    }
618 -
619 -
    if name.trim().is_empty() {
620 -
        return Err("Name is required".to_string());
621 -
    }
622 -
623 -
    // Clamp scores to 1-5
624 -
    let clamp = |v: i32| v.max(1).min(5);
625 -
    Ok(WineFormData {
626 -
        name: name.trim().to_string(),
627 -
        origin: origin.trim().to_string(),
628 -
        grape: grape.trim().to_string(),
629 -
        notes: notes.trim().to_string(),
630 -
        background: background.trim().to_string(),
631 -
        image,
632 -
        image_mime,
633 -
        sweetness: clamp(sweetness),
634 -
        acidity: clamp(acidity),
635 -
        tannin: clamp(tannin),
636 -
        alcohol: clamp(alcohol),
637 -
        body: clamp(body),
638 -
        clarity: clamp(clarity),
639 -
        color_intensity: clamp(color_intensity),
640 -
        aroma_intensity: clamp(aroma_intensity),
641 -
        nose_complexity: clamp(nose_complexity),
642 -
    })
643 -
}
644 -
645 -
async fn post_new_wine(
646 -
    _session: auth::AuthSession,
647 -
    State(state): State<Arc<AppState>>,
648 -
    multipart: Multipart,
649 -
) -> Response {
650 -
    let data = match parse_wine_multipart(multipart).await {
651 -
        Ok(data) => data,
652 -
        Err(e) => {
653 -
            return Redirect::to(&format!("/admin/new?error={}", urlencoded(&e))).into_response();
654 -
        }
655 -
    };
656 -
657 -
    match db::create_wine(
658 -
        &state.db,
659 -
        &data.name,
660 -
        &data.origin,
661 -
        &data.grape,
662 -
        &data.notes,
663 -
        data.image.as_deref(),
664 -
        data.image_mime.as_deref(),
665 -
        data.sweetness,
666 -
        data.acidity,
667 -
        data.tannin,
668 -
        data.alcohol,
669 -
        data.body,
670 -
        data.clarity,
671 -
        data.color_intensity,
672 -
        data.aroma_intensity,
673 -
        data.nose_complexity,
674 -
        &data.background,
675 -
        false,
676 -
    ) {
677 -
        Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(),
678 -
        Err(e) => {
679 -
            tracing::error!("Failed to create wine: {}", e);
680 -
            Redirect::to("/admin/new?error=Failed+to+create+wine").into_response()
681 -
        }
682 -
    }
683 -
}
684 -
685 -
async fn post_edit_wine(
686 -
    _session: auth::AuthSession,
687 -
    State(state): State<Arc<AppState>>,
688 -
    Path(short_id): Path<String>,
689 -
    multipart: Multipart,
690 -
) -> Response {
691 -
    let data = match parse_wine_multipart(multipart).await {
692 -
        Ok(data) => data,
693 -
        Err(e) => {
694 -
            return Redirect::to(&format!("/admin/edit/{}?error={}", short_id, urlencoded(&e)))
695 -
                .into_response();
696 -
        }
697 -
    };
698 -
699 -
    match db::update_wine(
700 -
        &state.db,
701 -
        &short_id,
702 -
        &data.name,
703 -
        &data.origin,
704 -
        &data.grape,
705 -
        &data.notes,
706 -
        data.sweetness,
707 -
        data.acidity,
708 -
        data.tannin,
709 -
        data.alcohol,
710 -
        data.body,
711 -
        data.clarity,
712 -
        data.color_intensity,
713 -
        data.aroma_intensity,
714 -
        data.nose_complexity,
715 -
        &data.background,
716 -
    ) {
717 -
        Ok(Some(_)) => {
718 -
            if let Some(image) = &data.image {
719 -
                if let Some(mime) = &data.image_mime {
720 -
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
721 -
                        tracing::error!("Failed to update wine image: {}", e);
722 -
                    }
723 -
                }
724 -
            }
725 -
            Redirect::to(&format!("/wines/{}", short_id)).into_response()
726 -
        }
727 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
728 -
        Err(e) => {
729 -
            tracing::error!("Failed to update wine: {}", e);
730 -
            Redirect::to(&format!(
731 -
                "/admin/edit/{}?error=Failed+to+update+wine",
732 -
                short_id
733 -
            ))
734 -
            .into_response()
735 -
        }
736 -
    }
737 -
}
738 -
739 -
async fn post_delete_wine(
740 -
    _session: auth::AuthSession,
741 -
    State(state): State<Arc<AppState>>,
742 -
    Path(short_id): Path<String>,
743 -
) -> Response {
744 -
    match db::delete_wine(&state.db, &short_id) {
745 -
        Ok(_) => Redirect::to("/admin").into_response(),
746 -
        Err(e) => {
747 -
            tracing::error!("Failed to delete wine: {}", e);
748 -
            Redirect::to("/admin").into_response()
749 -
        }
750 -
    }
751 -
}
752 -
753 -
// --- Wishlist ---
754 -
755 -
struct WishlistFormData {
756 -
    name: String,
757 -
    origin: String,
758 -
    grape: String,
759 -
    notes: String,
760 -
    background: String,
761 -
    image: Option<Vec<u8>>,
762 -
    image_mime: Option<String>,
763 -
}
764 -
765 -
async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> {
766 -
    let mut name = String::new();
767 -
    let mut origin = String::new();
768 -
    let mut grape = String::new();
769 -
    let mut notes = String::new();
770 -
    let mut background = String::new();
771 -
    let mut image: Option<Vec<u8>> = None;
772 -
    let mut image_mime: Option<String> = None;
773 -
774 -
    while let Ok(Some(field)) = multipart.next_field().await {
775 -
        let field_name = field.name().unwrap_or("").to_string();
776 -
        match field_name.as_str() {
777 -
            "image" => {
778 -
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
779 -
                if !bytes.is_empty() {
780 -
                    let processed = process_image(&bytes)?;
781 -
                    image = Some(processed);
782 -
                    image_mime = Some("image/jpeg".to_string());
783 -
                }
784 -
            }
785 -
            "name" => name = field.text().await.unwrap_or_default(),
786 -
            "origin" => origin = field.text().await.unwrap_or_default(),
787 -
            "grape" => grape = field.text().await.unwrap_or_default(),
788 -
            "notes" => notes = field.text().await.unwrap_or_default(),
789 -
            "background" => background = field.text().await.unwrap_or_default(),
790 -
            _ => {}
791 -
        }
792 -
    }
793 -
794 -
    if name.trim().is_empty() {
795 -
        return Err("Name is required".to_string());
796 -
    }
797 -
798 -
    Ok(WishlistFormData {
799 -
        name: name.trim().to_string(),
800 -
        origin: origin.trim().to_string(),
801 -
        grape: grape.trim().to_string(),
802 -
        notes: notes.trim().to_string(),
803 -
        background: background.trim().to_string(),
804 -
        image,
805 -
        image_mime,
806 -
    })
807 -
}
808 -
809 -
async fn get_wishlist(
810 -
    State(state): State<Arc<AppState>>,
811 -
    headers: axum::http::HeaderMap,
812 -
) -> Response {
813 -
    let is_admin = auth::is_authenticated(&state, &headers);
814 -
    match db::get_wishlist_wines(&state.db) {
815 -
        Ok(wines) => WebTemplate(WishlistTemplate { wines, is_admin }).into_response(),
816 -
        Err(e) => {
817 -
            tracing::error!("Failed to list wishlist: {}", e);
818 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
819 -
        }
820 -
    }
821 -
}
822 -
823 -
async fn get_new_wishlist_wine(
824 -
    _session: auth::AuthSession,
825 -
    State(state): State<Arc<AppState>>,
826 -
    Query(q): Query<FlashQuery>,
827 -
) -> Response {
828 -
    WebTemplate(WishlistFormTemplate {
829 -
        wine: None,
830 -
        error: q.error,
831 -
        has_anthropic_key: state.anthropic_api_key.is_some(),
832 -
    })
833 -
    .into_response()
834 -
}
835 -
836 -
async fn get_edit_wishlist_wine(
837 -
    _session: auth::AuthSession,
838 -
    State(state): State<Arc<AppState>>,
839 -
    Path(short_id): Path<String>,
840 -
    Query(q): Query<FlashQuery>,
841 -
) -> Response {
842 -
    match db::get_wine_by_short_id(&state.db, &short_id) {
843 -
        Ok(Some(wine)) => WebTemplate(WishlistFormTemplate {
844 -
            wine: Some(wine),
845 -
            error: q.error,
846 -
            has_anthropic_key: state.anthropic_api_key.is_some(),
847 -
        })
848 -
        .into_response(),
849 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
850 -
        Err(e) => {
851 -
            tracing::error!("Failed to get wine: {}", e);
852 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
853 -
        }
854 -
    }
855 -
}
856 -
857 -
async fn post_new_wishlist_wine(
858 -
    _session: auth::AuthSession,
859 -
    State(state): State<Arc<AppState>>,
860 -
    multipart: Multipart,
861 -
) -> Response {
862 -
    let data = match parse_wishlist_multipart(multipart).await {
863 -
        Ok(data) => data,
864 -
        Err(e) => {
865 -
            return Redirect::to(&format!("/admin/wishlist/new?error={}", urlencoded(&e))).into_response();
866 -
        }
867 -
    };
868 -
869 -
    match db::create_wine(
870 -
        &state.db,
871 -
        &data.name,
872 -
        &data.origin,
873 -
        &data.grape,
874 -
        &data.notes,
875 -
        data.image.as_deref(),
876 -
        data.image_mime.as_deref(),
877 -
        3, 3, 3, 3, 3, 3, 3, 3, 3,
878 -
        &data.background,
879 -
        true,
880 -
    ) {
881 -
        Ok(_) => Redirect::to("/wishlist").into_response(),
882 -
        Err(e) => {
883 -
            tracing::error!("Failed to create wishlist wine: {}", e);
884 -
            Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response()
885 -
        }
886 -
    }
887 -
}
888 -
889 -
async fn post_edit_wishlist_wine(
890 -
    _session: auth::AuthSession,
891 -
    State(state): State<Arc<AppState>>,
892 -
    Path(short_id): Path<String>,
893 -
    multipart: Multipart,
894 -
) -> Response {
895 -
    let data = match parse_wishlist_multipart(multipart).await {
896 -
        Ok(data) => data,
897 -
        Err(e) => {
898 -
            return Redirect::to(&format!("/admin/wishlist/edit/{}?error={}", short_id, urlencoded(&e)))
899 -
                .into_response();
900 -
        }
901 -
    };
902 -
903 -
    match db::update_wishlist_wine(
904 -
        &state.db,
905 -
        &short_id,
906 -
        &data.name,
907 -
        &data.origin,
908 -
        &data.grape,
909 -
        &data.notes,
910 -
        &data.background,
911 -
    ) {
912 -
        Ok(Some(_)) => {
913 -
            if let Some(image) = &data.image {
914 -
                if let Some(mime) = &data.image_mime {
915 -
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
916 -
                        tracing::error!("Failed to update wine image: {}", e);
917 -
                    }
918 -
                }
919 -
            }
920 -
            Redirect::to("/wishlist").into_response()
921 -
        }
922 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
923 -
        Err(e) => {
924 -
            tracing::error!("Failed to update wishlist wine: {}", e);
925 -
            Redirect::to(&format!(
926 -
                "/admin/wishlist/edit/{}?error=Failed+to+update+wine",
927 -
                short_id
928 -
            ))
929 -
            .into_response()
930 -
        }
931 -
    }
932 -
}
933 -
934 -
async fn post_delete_wishlist_wine(
935 -
    _session: auth::AuthSession,
936 -
    State(state): State<Arc<AppState>>,
937 -
    Path(short_id): Path<String>,
938 -
) -> Response {
939 -
    match db::delete_wine(&state.db, &short_id) {
940 -
        Ok(_) => Redirect::to("/wishlist").into_response(),
941 -
        Err(e) => {
942 -
            tracing::error!("Failed to delete wine: {}", e);
943 -
            Redirect::to("/wishlist").into_response()
944 -
        }
945 -
    }
946 -
}
947 -
948 -
async fn post_promote_wine(
949 -
    _session: auth::AuthSession,
950 -
    State(state): State<Arc<AppState>>,
951 -
    Path(short_id): Path<String>,
952 -
) -> Response {
953 -
    match db::promote_wine(&state.db, &short_id) {
954 -
        Ok(true) => Redirect::to(&format!("/admin/edit/{}", short_id)).into_response(),
955 -
        Ok(false) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
956 -
        Err(e) => {
957 -
            tracing::error!("Failed to promote wine: {}", e);
958 -
            Redirect::to("/wishlist").into_response()
959 -
        }
960 -
    }
961 -
}
962 -
963 -
// --- Claude vision handler ---
964 -
965 -
async fn post_analyze_image(
966 -
    _session: auth::AuthSession,
967 -
    State(state): State<Arc<AppState>>,
968 -
    mut multipart: Multipart,
969 -
) -> Response {
970 -
    let api_key = match &state.anthropic_api_key {
971 -
        Some(key) => key.clone(),
972 -
        None => {
973 -
            return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "No API key configured"})))
974 -
                .into_response();
975 -
        }
976 -
    };
977 -
978 -
    let mut image_bytes: Option<Vec<u8>> = None;
979 -
    let mut media_type = String::from("image/jpeg");
980 -
981 -
    while let Ok(Some(field)) = multipart.next_field().await {
982 -
        if field.name() == Some("image") {
983 -
            media_type = field.content_type().unwrap_or("image/jpeg").to_string();
984 -
            if let Ok(bytes) = field.bytes().await {
985 -
                if !bytes.is_empty() {
986 -
                    image_bytes = Some(bytes.to_vec());
987 -
                }
988 -
            }
989 -
        }
990 -
    }
991 -
992 -
    let image_bytes = match image_bytes {
993 -
        Some(bytes) => bytes,
994 -
        None => {
995 -
            return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "No image provided"})))
996 -
                .into_response();
997 -
        }
998 -
    };
999 -
1000 -
    match claude::analyze_wine_image(&api_key, &image_bytes, &media_type).await {
1001 -
        Ok(result) => (StatusCode::OK, Json(result)).into_response(),
1002 -
        Err(e) => {
1003 -
            tracing::error!("Claude analysis failed: {}", e);
1004 -
            (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": e})))
1005 -
                .into_response()
1006 -
        }
1007 -
    }
1008 -
}
1009 -
1010 -
// --- Helpers ---
1011 -
1012 -
fn urlencoded(s: &str) -> String {
1013 -
    s.replace(' ', "+")
1014 -
        .replace('&', "%26")
1015 -
        .replace('=', "%3D")
1016 -
}
1017 -
1018 -
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
1019 -
    days += 719468;
1020 -
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
1021 -
    let doe = (days - era * 146097) as u32;
1022 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1023 -
    let y = yoe as i64 + era * 400;
1024 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1025 -
    let mp = (5 * doy + 2) / 153;
1026 -
    let d = doy - (153 * mp + 2) / 5 + 1;
1027 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1028 -
    let y = if m <= 2 { y + 1 } else { y };
1029 -
    (y, m as i64, d as i64)
1030 -
}
1031 -
1032 -
// --- Router ---
1033 -
1034 -
pub async fn run(host: String, port: u16) {
1035 -
    dotenvy::dotenv().ok();
1036 -
1037 -
    let db = db::init_db();
1038 -
1039 -
    if let Err(e) = db::prune_expired_sessions(&db) {
1040 -
        tracing::warn!("Failed to prune sessions: {}", e);
1041 -
    }
1042 -
1043 -
    let app_password = std::env::var("CELLAR_PASSWORD").unwrap_or_else(|_| {
1044 -
        tracing::warn!("CELLAR_PASSWORD not set, using default 'changeme'");
1045 -
        "changeme".to_string()
1046 -
    });
1047 -
1048 -
    let cookie_secure = std::env::var("COOKIE_SECURE")
1049 -
        .map(|v| v == "true")
1050 -
        .unwrap_or(false);
1051 -
1052 -
    let anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty());
1053 -
1054 -
    let state = Arc::new(AppState {
1055 -
        db,
1056 -
        app_password,
1057 -
        cookie_secure,
1058 -
        anthropic_api_key,
1059 -
    });
1060 -
1061 -
    let app = Router::new()
1062 -
        // Public routes
1063 -
        .route("/", get(get_index))
1064 -
        .route("/wines/{short_id}", get(get_wine_detail))
1065 -
        .route("/wines/{short_id}/image", get(get_wine_image))
1066 -
        // Admin auth routes
1067 -
        .route("/admin/login", get(get_login).post(post_login))
1068 -
        .route("/admin/logout", get(get_logout))
1069 -
        // Admin protected routes
1070 -
        .route("/admin", get(get_admin))
1071 -
        .route("/admin/new", get(get_new_wine).post(post_new_wine))
1072 -
        .route("/admin/edit/{short_id}", get(get_edit_wine).post(post_edit_wine))
1073 -
        .route("/admin/delete/{short_id}", post(post_delete_wine))
1074 -
        // Wishlist public (admin actions inline when authenticated)
1075 -
        .route("/wishlist", get(get_wishlist))
1076 -
        .route("/admin/wishlist/new", get(get_new_wishlist_wine).post(post_new_wishlist_wine))
1077 -
        .route("/admin/wishlist/edit/{short_id}", get(get_edit_wishlist_wine).post(post_edit_wishlist_wine))
1078 -
        .route("/admin/wishlist/delete/{short_id}", post(post_delete_wishlist_wine))
1079 -
        .route("/admin/wishlist/promote/{short_id}", post(post_promote_wine))
1080 -
        // Claude vision
1081 -
        .route("/admin/analyze-image", post(post_analyze_image))
1082 -
        // Static assets
1083 -
        .route("/static/{*path}", get(serve_static))
1084 -
        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
1085 -
        .with_state(state);
1086 -
1087 -
    let addr = format!("{}:{}", host, port);
1088 -
    tracing::info!("Listening on http://{}", addr);
1089 -
1090 -
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
1091 -
    axum::serve(listener, app).await.unwrap();
1092 -
}
apps/cellar/src/server/handlers/admin.rs (added) +440 −0
1 +
use askama_web::WebTemplate;
2 +
use axum::{
3 +
    extract::{Multipart, Path, Query, State},
4 +
    http::{HeaderValue, StatusCode},
5 +
    response::{Html, IntoResponse, Json, Redirect, Response},
6 +
};
7 +
use std::sync::Arc;
8 +
9 +
use super::super::*;
10 +
use crate::{auth, claude, db};
11 +
12 +
// --- Auth handlers ---
13 +
14 +
pub async fn get_login(Query(q): Query<FlashQuery>) -> Response {
15 +
    WebTemplate(LoginTemplate { error: q.error, next: q.next }).into_response()
16 +
}
17 +
18 +
pub async fn post_login(
19 +
    Query(q): Query<FlashQuery>,
20 +
    State(state): State<Arc<AppState>>,
21 +
    axum::extract::Form(form): axum::extract::Form<LoginForm>,
22 +
) -> Response {
23 +
    let next = q.next.as_deref().unwrap_or("/admin");
24 +
    if !auth::verify_password(&form.password, &state.app_password) {
25 +
        return Redirect::to(&format!(
26 +
            "/admin/login?error=Invalid+password&next={}",
27 +
            urlencoded(next)
28 +
        ))
29 +
        .into_response();
30 +
    }
31 +
32 +
    let token = auth::generate_session_token();
33 +
34 +
    let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600);
35 +
36 +
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
37 +
        tracing::error!("Failed to create session: {}", e);
38 +
        return Redirect::to("/admin/login?error=Server+error").into_response();
39 +
    }
40 +
41 +
    let _ = db::prune_expired_sessions(&state.db);
42 +
43 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
44 +
    let redirect_to = if next.starts_with('/') { next } else { "/admin" };
45 +
    let mut resp = Redirect::to(redirect_to).into_response();
46 +
    resp.headers_mut().insert(
47 +
        axum::http::header::SET_COOKIE,
48 +
        HeaderValue::from_str(&cookie).unwrap(),
49 +
    );
50 +
    resp
51 +
}
52 +
53 +
pub async fn get_logout(
54 +
    State(state): State<Arc<AppState>>,
55 +
    headers: axum::http::HeaderMap,
56 +
) -> Response {
57 +
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
58 +
        for part in cookie_header.split(';') {
59 +
            let part = part.trim();
60 +
            if let Some(val) = part.strip_prefix("session=") {
61 +
                let val = val.trim();
62 +
                if !val.is_empty() {
63 +
                    let _ = db::delete_session(&state.db, val);
64 +
                }
65 +
            }
66 +
        }
67 +
    }
68 +
69 +
    let cookie = auth::clear_session_cookie();
70 +
    let mut resp = Redirect::to("/admin/login").into_response();
71 +
    resp.headers_mut().insert(
72 +
        axum::http::header::SET_COOKIE,
73 +
        HeaderValue::from_str(&cookie).unwrap(),
74 +
    );
75 +
    resp
76 +
}
77 +
78 +
// --- Admin wine handlers ---
79 +
80 +
pub async fn get_admin(
81 +
    _session: auth::AuthSession,
82 +
    State(state): State<Arc<AppState>>,
83 +
) -> Response {
84 +
    match db::get_cellar_wines(&state.db) {
85 +
        Ok(wines) => WebTemplate(AdminTemplate { wines }).into_response(),
86 +
        Err(e) => {
87 +
            tracing::error!("Failed to list wines: {}", e);
88 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
89 +
        }
90 +
    }
91 +
}
92 +
93 +
pub async fn get_new_wine(
94 +
    _session: auth::AuthSession,
95 +
    State(state): State<Arc<AppState>>,
96 +
    Query(q): Query<FlashQuery>,
97 +
) -> Response {
98 +
    WebTemplate(WineFormTemplate {
99 +
        wine: None,
100 +
        error: q.error,
101 +
        has_anthropic_key: state.anthropic_api_key.is_some(),
102 +
    })
103 +
    .into_response()
104 +
}
105 +
106 +
pub async fn get_edit_wine(
107 +
    _session: auth::AuthSession,
108 +
    State(state): State<Arc<AppState>>,
109 +
    Path(short_id): Path<String>,
110 +
    Query(q): Query<FlashQuery>,
111 +
) -> Response {
112 +
    match db::get_wine_by_short_id(&state.db, &short_id) {
113 +
        Ok(Some(wine)) => WebTemplate(WineFormTemplate {
114 +
            wine: Some(wine),
115 +
            error: q.error,
116 +
            has_anthropic_key: state.anthropic_api_key.is_some(),
117 +
        })
118 +
        .into_response(),
119 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
120 +
        Err(e) => {
121 +
            tracing::error!("Failed to get wine: {}", e);
122 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
123 +
        }
124 +
    }
125 +
}
126 +
127 +
pub async fn post_new_wine(
128 +
    _session: auth::AuthSession,
129 +
    State(state): State<Arc<AppState>>,
130 +
    multipart: Multipart,
131 +
) -> Response {
132 +
    let data = match parse_wine_multipart(multipart).await {
133 +
        Ok(data) => data,
134 +
        Err(e) => {
135 +
            return Redirect::to(&format!("/admin/new?error={}", urlencoded(&e))).into_response();
136 +
        }
137 +
    };
138 +
139 +
    match db::create_wine(
140 +
        &state.db,
141 +
        &data.name,
142 +
        &data.origin,
143 +
        &data.grape,
144 +
        &data.notes,
145 +
        data.image.as_deref(),
146 +
        data.image_mime.as_deref(),
147 +
        data.sweetness,
148 +
        data.acidity,
149 +
        data.tannin,
150 +
        data.alcohol,
151 +
        data.body,
152 +
        data.clarity,
153 +
        data.color_intensity,
154 +
        data.aroma_intensity,
155 +
        data.nose_complexity,
156 +
        &data.background,
157 +
        false,
158 +
    ) {
159 +
        Ok(wine) => Redirect::to(&format!("/wines/{}", wine.short_id)).into_response(),
160 +
        Err(e) => {
161 +
            tracing::error!("Failed to create wine: {}", e);
162 +
            Redirect::to("/admin/new?error=Failed+to+create+wine").into_response()
163 +
        }
164 +
    }
165 +
}
166 +
167 +
pub async fn post_edit_wine(
168 +
    _session: auth::AuthSession,
169 +
    State(state): State<Arc<AppState>>,
170 +
    Path(short_id): Path<String>,
171 +
    multipart: Multipart,
172 +
) -> Response {
173 +
    let data = match parse_wine_multipart(multipart).await {
174 +
        Ok(data) => data,
175 +
        Err(e) => {
176 +
            return Redirect::to(&format!(
177 +
                "/admin/edit/{}?error={}",
178 +
                short_id,
179 +
                urlencoded(&e)
180 +
            ))
181 +
            .into_response();
182 +
        }
183 +
    };
184 +
185 +
    match db::update_wine(
186 +
        &state.db,
187 +
        &short_id,
188 +
        &data.name,
189 +
        &data.origin,
190 +
        &data.grape,
191 +
        &data.notes,
192 +
        data.sweetness,
193 +
        data.acidity,
194 +
        data.tannin,
195 +
        data.alcohol,
196 +
        data.body,
197 +
        data.clarity,
198 +
        data.color_intensity,
199 +
        data.aroma_intensity,
200 +
        data.nose_complexity,
201 +
        &data.background,
202 +
    ) {
203 +
        Ok(Some(_)) => {
204 +
            if let Some(image) = &data.image {
205 +
                if let Some(mime) = &data.image_mime {
206 +
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
207 +
                        tracing::error!("Failed to update wine image: {}", e);
208 +
                    }
209 +
                }
210 +
            }
211 +
            Redirect::to(&format!("/wines/{}", short_id)).into_response()
212 +
        }
213 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
214 +
        Err(e) => {
215 +
            tracing::error!("Failed to update wine: {}", e);
216 +
            Redirect::to(&format!(
217 +
                "/admin/edit/{}?error=Failed+to+update+wine",
218 +
                short_id
219 +
            ))
220 +
            .into_response()
221 +
        }
222 +
    }
223 +
}
224 +
225 +
pub async fn post_delete_wine(
226 +
    _session: auth::AuthSession,
227 +
    State(state): State<Arc<AppState>>,
228 +
    Path(short_id): Path<String>,
229 +
) -> Response {
230 +
    match db::delete_wine(&state.db, &short_id) {
231 +
        Ok(_) => Redirect::to("/admin").into_response(),
232 +
        Err(e) => {
233 +
            tracing::error!("Failed to delete wine: {}", e);
234 +
            Redirect::to("/admin").into_response()
235 +
        }
236 +
    }
237 +
}
238 +
239 +
// --- Wishlist handlers ---
240 +
241 +
pub async fn get_new_wishlist_wine(
242 +
    _session: auth::AuthSession,
243 +
    State(state): State<Arc<AppState>>,
244 +
    Query(q): Query<FlashQuery>,
245 +
) -> Response {
246 +
    WebTemplate(WishlistFormTemplate {
247 +
        wine: None,
248 +
        error: q.error,
249 +
        has_anthropic_key: state.anthropic_api_key.is_some(),
250 +
    })
251 +
    .into_response()
252 +
}
253 +
254 +
pub async fn get_edit_wishlist_wine(
255 +
    _session: auth::AuthSession,
256 +
    State(state): State<Arc<AppState>>,
257 +
    Path(short_id): Path<String>,
258 +
    Query(q): Query<FlashQuery>,
259 +
) -> Response {
260 +
    match db::get_wine_by_short_id(&state.db, &short_id) {
261 +
        Ok(Some(wine)) => WebTemplate(WishlistFormTemplate {
262 +
            wine: Some(wine),
263 +
            error: q.error,
264 +
            has_anthropic_key: state.anthropic_api_key.is_some(),
265 +
        })
266 +
        .into_response(),
267 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
268 +
        Err(e) => {
269 +
            tracing::error!("Failed to get wine: {}", e);
270 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
271 +
        }
272 +
    }
273 +
}
274 +
275 +
pub async fn post_new_wishlist_wine(
276 +
    _session: auth::AuthSession,
277 +
    State(state): State<Arc<AppState>>,
278 +
    multipart: Multipart,
279 +
) -> Response {
280 +
    let data = match parse_wishlist_multipart(multipart).await {
281 +
        Ok(data) => data,
282 +
        Err(e) => {
283 +
            return Redirect::to(&format!("/admin/wishlist/new?error={}", urlencoded(&e)))
284 +
                .into_response();
285 +
        }
286 +
    };
287 +
288 +
    match db::create_wine(
289 +
        &state.db,
290 +
        &data.name,
291 +
        &data.origin,
292 +
        &data.grape,
293 +
        &data.notes,
294 +
        data.image.as_deref(),
295 +
        data.image_mime.as_deref(),
296 +
        3, 3, 3, 3, 3, 3, 3, 3, 3,
297 +
        &data.background,
298 +
        true,
299 +
    ) {
300 +
        Ok(_) => Redirect::to("/wishlist").into_response(),
301 +
        Err(e) => {
302 +
            tracing::error!("Failed to create wishlist wine: {}", e);
303 +
            Redirect::to("/admin/wishlist/new?error=Failed+to+create+wine").into_response()
304 +
        }
305 +
    }
306 +
}
307 +
308 +
pub async fn post_edit_wishlist_wine(
309 +
    _session: auth::AuthSession,
310 +
    State(state): State<Arc<AppState>>,
311 +
    Path(short_id): Path<String>,
312 +
    multipart: Multipart,
313 +
) -> Response {
314 +
    let data = match parse_wishlist_multipart(multipart).await {
315 +
        Ok(data) => data,
316 +
        Err(e) => {
317 +
            return Redirect::to(&format!(
318 +
                "/admin/wishlist/edit/{}?error={}",
319 +
                short_id,
320 +
                urlencoded(&e)
321 +
            ))
322 +
            .into_response();
323 +
        }
324 +
    };
325 +
326 +
    match db::update_wishlist_wine(
327 +
        &state.db,
328 +
        &short_id,
329 +
        &data.name,
330 +
        &data.origin,
331 +
        &data.grape,
332 +
        &data.notes,
333 +
        &data.background,
334 +
    ) {
335 +
        Ok(Some(_)) => {
336 +
            if let Some(image) = &data.image {
337 +
                if let Some(mime) = &data.image_mime {
338 +
                    if let Err(e) = db::update_wine_image(&state.db, &short_id, image, mime) {
339 +
                        tracing::error!("Failed to update wine image: {}", e);
340 +
                    }
341 +
                }
342 +
            }
343 +
            Redirect::to("/wishlist").into_response()
344 +
        }
345 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
346 +
        Err(e) => {
347 +
            tracing::error!("Failed to update wishlist wine: {}", e);
348 +
            Redirect::to(&format!(
349 +
                "/admin/wishlist/edit/{}?error=Failed+to+update+wine",
350 +
                short_id
351 +
            ))
352 +
            .into_response()
353 +
        }
354 +
    }
355 +
}
356 +
357 +
pub async fn post_delete_wishlist_wine(
358 +
    _session: auth::AuthSession,
359 +
    State(state): State<Arc<AppState>>,
360 +
    Path(short_id): Path<String>,
361 +
) -> Response {
362 +
    match db::delete_wine(&state.db, &short_id) {
363 +
        Ok(_) => Redirect::to("/wishlist").into_response(),
364 +
        Err(e) => {
365 +
            tracing::error!("Failed to delete wine: {}", e);
366 +
            Redirect::to("/wishlist").into_response()
367 +
        }
368 +
    }
369 +
}
370 +
371 +
pub async fn post_promote_wine(
372 +
    _session: auth::AuthSession,
373 +
    State(state): State<Arc<AppState>>,
374 +
    Path(short_id): Path<String>,
375 +
) -> Response {
376 +
    match db::promote_wine(&state.db, &short_id) {
377 +
        Ok(true) => Redirect::to(&format!("/admin/edit/{}", short_id)).into_response(),
378 +
        Ok(false) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
379 +
        Err(e) => {
380 +
            tracing::error!("Failed to promote wine: {}", e);
381 +
            Redirect::to("/wishlist").into_response()
382 +
        }
383 +
    }
384 +
}
385 +
386 +
// --- Claude vision handler ---
387 +
388 +
pub async fn post_analyze_image(
389 +
    _session: auth::AuthSession,
390 +
    State(state): State<Arc<AppState>>,
391 +
    mut multipart: Multipart,
392 +
) -> Response {
393 +
    let api_key = match &state.anthropic_api_key {
394 +
        Some(key) => key.clone(),
395 +
        None => {
396 +
            return (
397 +
                StatusCode::BAD_REQUEST,
398 +
                Json(serde_json::json!({"error": "No API key configured"})),
399 +
            )
400 +
                .into_response();
401 +
        }
402 +
    };
403 +
404 +
    let mut image_bytes: Option<Vec<u8>> = None;
405 +
    let mut media_type = String::from("image/jpeg");
406 +
407 +
    while let Ok(Some(field)) = multipart.next_field().await {
408 +
        if field.name() == Some("image") {
409 +
            media_type = field.content_type().unwrap_or("image/jpeg").to_string();
410 +
            if let Ok(bytes) = field.bytes().await {
411 +
                if !bytes.is_empty() {
412 +
                    image_bytes = Some(bytes.to_vec());
413 +
                }
414 +
            }
415 +
        }
416 +
    }
417 +
418 +
    let image_bytes = match image_bytes {
419 +
        Some(bytes) => bytes,
420 +
        None => {
421 +
            return (
422 +
                StatusCode::BAD_REQUEST,
423 +
                Json(serde_json::json!({"error": "No image provided"})),
424 +
            )
425 +
                .into_response();
426 +
        }
427 +
    };
428 +
429 +
    match claude::analyze_wine_image(&api_key, &image_bytes, &media_type).await {
430 +
        Ok(result) => (StatusCode::OK, Json(result)).into_response(),
431 +
        Err(e) => {
432 +
            tracing::error!("Claude analysis failed: {}", e);
433 +
            (
434 +
                StatusCode::INTERNAL_SERVER_ERROR,
435 +
                Json(serde_json::json!({"error": e})),
436 +
            )
437 +
                .into_response()
438 +
        }
439 +
    }
440 +
}
apps/cellar/src/server/handlers/mod.rs (added) +2 −0
1 +
pub mod admin;
2 +
pub mod public;
apps/cellar/src/server/handlers/public.rs (added) +121 −0
1 +
use askama_web::WebTemplate;
2 +
use axum::{
3 +
    extract::{Path, State},
4 +
    http::{HeaderValue, StatusCode},
5 +
    response::{Html, IntoResponse, Response},
6 +
};
7 +
use std::sync::Arc;
8 +
9 +
use super::super::*;
10 +
use crate::{auth, db};
11 +
12 +
pub async fn serve_static(Path(path): Path<String>) -> Response {
13 +
    match Static::get(&path) {
14 +
        Some(file) => {
15 +
            let mime = mime_from_path(&path);
16 +
            (
17 +
                StatusCode::OK,
18 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
19 +
                file.data.to_vec(),
20 +
            )
21 +
                .into_response()
22 +
        }
23 +
        None => StatusCode::NOT_FOUND.into_response(),
24 +
    }
25 +
}
26 +
27 +
pub async fn get_index(State(state): State<Arc<AppState>>) -> Response {
28 +
    match db::get_cellar_wines(&state.db) {
29 +
        Ok(wines) => {
30 +
            let wines: Vec<WineWithSvg> = wines
31 +
                .into_iter()
32 +
                .map(|wine| {
33 +
                    let pentagon_svg = build_pentagon_svg(
34 +
                        wine.sweetness,
35 +
                        wine.acidity,
36 +
                        wine.tannin,
37 +
                        wine.alcohol,
38 +
                        wine.body,
39 +
                        80.0,
40 +
                        false,
41 +
                    );
42 +
                    WineWithSvg { wine, pentagon_svg }
43 +
                })
44 +
                .collect();
45 +
            WebTemplate(IndexTemplate { wines }).into_response()
46 +
        }
47 +
        Err(e) => {
48 +
            tracing::error!("Failed to list wines: {}", e);
49 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
50 +
        }
51 +
    }
52 +
}
53 +
54 +
pub async fn get_wine_detail(
55 +
    State(state): State<Arc<AppState>>,
56 +
    Path(short_id): Path<String>,
57 +
) -> Response {
58 +
    match db::get_wine_by_short_id(&state.db, &short_id) {
59 +
        Ok(Some(wine)) => {
60 +
            let pentagon_svg = build_pentagon_svg(
61 +
                wine.sweetness,
62 +
                wine.acidity,
63 +
                wine.tannin,
64 +
                wine.alcohol,
65 +
                wine.body,
66 +
                250.0,
67 +
                true,
68 +
            );
69 +
            let bars_svg = build_bars_svg(
70 +
                wine.clarity,
71 +
                wine.color_intensity,
72 +
                wine.aroma_intensity,
73 +
                wine.nose_complexity,
74 +
                250.0,
75 +
            );
76 +
            WebTemplate(WineDetailTemplate { wine, pentagon_svg, bars_svg }).into_response()
77 +
        }
78 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Wine not found".to_string())).into_response(),
79 +
        Err(e) => {
80 +
            tracing::error!("Failed to get wine: {}", e);
81 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
82 +
        }
83 +
    }
84 +
}
85 +
86 +
pub async fn get_wine_image(
87 +
    State(state): State<Arc<AppState>>,
88 +
    Path(short_id): Path<String>,
89 +
) -> Response {
90 +
    match db::get_wine_image(&state.db, &short_id) {
91 +
        Ok(Some((bytes, mime))) => {
92 +
            let content_type = HeaderValue::from_str(&mime)
93 +
                .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
94 +
            (
95 +
                StatusCode::OK,
96 +
                [(axum::http::header::CONTENT_TYPE, content_type)],
97 +
                bytes,
98 +
            )
99 +
                .into_response()
100 +
        }
101 +
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
102 +
        Err(e) => {
103 +
            tracing::error!("Failed to get wine image: {}", e);
104 +
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
105 +
        }
106 +
    }
107 +
}
108 +
109 +
pub async fn get_wishlist(
110 +
    State(state): State<Arc<AppState>>,
111 +
    headers: axum::http::HeaderMap,
112 +
) -> Response {
113 +
    let is_admin = auth::is_authenticated(&state, &headers);
114 +
    match db::get_wishlist_wines(&state.db) {
115 +
        Ok(wines) => WebTemplate(WishlistTemplate { wines, is_admin }).into_response(),
116 +
        Err(e) => {
117 +
            tracing::error!("Failed to list wishlist: {}", e);
118 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
119 +
        }
120 +
    }
121 +
}
apps/cellar/src/server/mod.rs (added) +545 −0
1 +
use askama::Template;
2 +
use axum::{
3 +
    extract::{DefaultBodyLimit, Multipart},
4 +
    routing::{get, post},
5 +
    Router,
6 +
};
7 +
use image::ImageDecoder;
8 +
use rust_embed::Embed;
9 +
use std::sync::Arc;
10 +
11 +
use crate::db::{self, Db, Wine};
12 +
13 +
mod handlers;
14 +
15 +
#[derive(Clone)]
16 +
pub struct AppState {
17 +
    pub db: Db,
18 +
    pub app_password: String,
19 +
    pub cookie_secure: bool,
20 +
    pub anthropic_api_key: Option<String>,
21 +
}
22 +
23 +
#[derive(Embed)]
24 +
#[folder = "static/"]
25 +
struct Static;
26 +
27 +
// --- Templates ---
28 +
29 +
#[derive(Template)]
30 +
#[template(path = "base.html")]
31 +
struct BaseTemplate;
32 +
33 +
#[derive(Template)]
34 +
#[template(path = "login.html")]
35 +
struct LoginTemplate {
36 +
    error: Option<String>,
37 +
    next: Option<String>,
38 +
}
39 +
40 +
struct WineWithSvg {
41 +
    wine: Wine,
42 +
    pentagon_svg: String,
43 +
}
44 +
45 +
#[derive(Template)]
46 +
#[template(path = "index.html")]
47 +
struct IndexTemplate {
48 +
    wines: Vec<WineWithSvg>,
49 +
}
50 +
51 +
#[derive(Template)]
52 +
#[template(path = "wine.html")]
53 +
struct WineDetailTemplate {
54 +
    wine: Wine,
55 +
    pentagon_svg: String,
56 +
    bars_svg: String,
57 +
}
58 +
59 +
#[derive(Template)]
60 +
#[template(path = "admin.html")]
61 +
struct AdminTemplate {
62 +
    wines: Vec<Wine>,
63 +
}
64 +
65 +
#[derive(Template)]
66 +
#[template(path = "wine_form.html")]
67 +
struct WineFormTemplate {
68 +
    wine: Option<Wine>,
69 +
    error: Option<String>,
70 +
    has_anthropic_key: bool,
71 +
}
72 +
73 +
#[derive(Template)]
74 +
#[template(path = "wishlist.html")]
75 +
struct WishlistTemplate {
76 +
    wines: Vec<Wine>,
77 +
    is_admin: bool,
78 +
}
79 +
80 +
#[derive(Template)]
81 +
#[template(path = "wishlist_form.html")]
82 +
struct WishlistFormTemplate {
83 +
    wine: Option<Wine>,
84 +
    error: Option<String>,
85 +
    has_anthropic_key: bool,
86 +
}
87 +
88 +
// --- Query/Form structs ---
89 +
90 +
#[derive(serde::Deserialize, Default)]
91 +
pub struct FlashQuery {
92 +
    pub error: Option<String>,
93 +
    pub next: Option<String>,
94 +
}
95 +
96 +
#[derive(serde::Deserialize)]
97 +
struct LoginForm {
98 +
    password: String,
99 +
}
100 +
101 +
// --- Helpers ---
102 +
103 +
fn mime_from_path(path: &str) -> &'static str {
104 +
    match path.rsplit('.').next().unwrap_or("") {
105 +
        "css" => "text/css",
106 +
        "js" => "application/javascript",
107 +
        "html" => "text/html",
108 +
        "png" => "image/png",
109 +
        "jpg" | "jpeg" => "image/jpeg",
110 +
        "ico" => "image/x-icon",
111 +
        "svg" => "image/svg+xml",
112 +
        "woff" | "woff2" => "font/woff2",
113 +
        "ttf" => "font/ttf",
114 +
        "otf" => "font/otf",
115 +
        "json" | "webmanifest" => "application/json",
116 +
        _ => "application/octet-stream",
117 +
    }
118 +
}
119 +
120 +
fn urlencoded(s: &str) -> String {
121 +
    s.replace(' ', "+")
122 +
        .replace('&', "%26")
123 +
        .replace('=', "%3D")
124 +
}
125 +
126 +
// --- Pentagon SVG ---
127 +
128 +
fn build_pentagon_svg(
129 +
    sweetness: i32,
130 +
    acidity: i32,
131 +
    tannin: i32,
132 +
    alcohol: i32,
133 +
    body: i32,
134 +
    size: f64,
135 +
    show_labels: bool,
136 +
) -> String {
137 +
    let cx = size / 2.0;
138 +
    let cy = size / 2.0;
139 +
    let margin = if show_labels { 30.0 } else { 5.0 };
140 +
    let r = size / 2.0 - margin;
141 +
142 +
    let scores = [sweetness, acidity, tannin, alcohol, body];
143 +
    let labels = ["Sweetness", "Acidity", "Tannin", "Alcohol", "Body"];
144 +
145 +
    let angles: Vec<f64> = (0..5)
146 +
        .map(|i| (-90.0_f64 + 72.0 * i as f64).to_radians())
147 +
        .collect();
148 +
149 +
    let mut svg = format!(
150 +
        r#"<svg viewBox="0 0 {s} {s}" width="100%" xmlns="http://www.w3.org/2000/svg">"#,
151 +
        s = size
152 +
    );
153 +
154 +
    for pct in &[0.2, 0.4, 0.6, 0.8] {
155 +
        let points: String = angles
156 +
            .iter()
157 +
            .map(|a| format!("{:.1},{:.1}", cx + r * pct * a.cos(), cy + r * pct * a.sin()))
158 +
            .collect::<Vec<_>>()
159 +
            .join(" ");
160 +
        svg.push_str(&format!(
161 +
            r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#,
162 +
            points
163 +
        ));
164 +
    }
165 +
166 +
    let outline: String = angles
167 +
        .iter()
168 +
        .map(|a| format!("{:.1},{:.1}", cx + r * a.cos(), cy + r * a.sin()))
169 +
        .collect::<Vec<_>>()
170 +
        .join(" ");
171 +
    svg.push_str(&format!(
172 +
        r#"<polygon points="{}" fill="none" stroke="white" stroke-opacity="0.25" stroke-width="1"/>"#,
173 +
        outline
174 +
    ));
175 +
176 +
    for a in &angles {
177 +
        svg.push_str(&format!(
178 +
            r#"<line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="white" stroke-opacity="0.12" stroke-width="0.75"/>"#,
179 +
            cx, cy, cx + r * a.cos(), cy + r * a.sin()
180 +
        ));
181 +
    }
182 +
183 +
    let data_points: Vec<(f64, f64)> = scores
184 +
        .iter()
185 +
        .zip(&angles)
186 +
        .map(|(s, a)| {
187 +
            let d = (*s as f64 / 5.0) * r;
188 +
            (cx + d * a.cos(), cy + d * a.sin())
189 +
        })
190 +
        .collect();
191 +
192 +
    let data_str: String = data_points
193 +
        .iter()
194 +
        .map(|(x, y)| format!("{:.1},{:.1}", x, y))
195 +
        .collect::<Vec<_>>()
196 +
        .join(" ");
197 +
    svg.push_str(&format!(
198 +
        r#"<polygon points="{}" fill="white" fill-opacity="0.08" stroke="white" stroke-width="1.5"/>"#,
199 +
        data_str
200 +
    ));
201 +
202 +
    for (x, y) in &data_points {
203 +
        svg.push_str(&format!(
204 +
            r#"<circle cx="{:.1}" cy="{:.1}" r="2.5" fill="white"/>"#,
205 +
            x, y
206 +
        ));
207 +
    }
208 +
209 +
    if show_labels {
210 +
        for (i, label) in labels.iter().enumerate() {
211 +
            let a = angles[i];
212 +
            let label_dist = r + 18.0;
213 +
            let lx = cx + label_dist * a.cos();
214 +
            let ly = cy + label_dist * a.sin() + 3.5;
215 +
            svg.push_str(&format!(
216 +
                r#"<text x="{:.1}" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace" text-anchor="middle">{}</text>"#,
217 +
                lx, ly, label
218 +
            ));
219 +
        }
220 +
    }
221 +
222 +
    svg.push_str("</svg>");
223 +
    svg
224 +
}
225 +
226 +
fn build_bars_svg(
227 +
    clarity: i32,
228 +
    color_intensity: i32,
229 +
    aroma_intensity: i32,
230 +
    nose_complexity: i32,
231 +
    width: f64,
232 +
) -> String {
233 +
    let bar_height = 4.0;
234 +
    let row_height = 22.0;
235 +
    let section_gap = 14.0;
236 +
    let label_width = 100.0;
237 +
    let track_left = label_width + 4.0;
238 +
    let track_width = width - track_left - 10.0;
239 +
    let header_size = 9.0;
240 +
241 +
    let sections: &[(&str, &[(&str, i32)])] = &[
242 +
        ("Appearance", &[("Clarity", clarity), ("Intensity", color_intensity)]),
243 +
        ("Nose", &[("Aroma", aroma_intensity), ("Complexity", nose_complexity)]),
244 +
    ];
245 +
246 +
    let total_rows: usize = sections.iter().map(|(_, attrs)| attrs.len()).sum();
247 +
    let total_height = (sections.len() as f64) * (header_size + 8.0)
248 +
        + (total_rows as f64) * row_height
249 +
        + section_gap;
250 +
251 +
    let mut svg = format!(
252 +
        r#"<svg viewBox="0 0 {w} {h}" width="100%" xmlns="http://www.w3.org/2000/svg">"#,
253 +
        w = width,
254 +
        h = total_height
255 +
    );
256 +
257 +
    let mut y = 4.0;
258 +
259 +
    for (si, (section_name, attrs)) in sections.iter().enumerate() {
260 +
        if si > 0 {
261 +
            y += section_gap;
262 +
        }
263 +
264 +
        svg.push_str(&format!(
265 +
            r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.4" font-size="{}" font-family="Commit Mono, monospace" text-transform="uppercase" letter-spacing="1">{}</text>"#,
266 +
            y + header_size, header_size, section_name
267 +
        ));
268 +
        y += header_size + 8.0;
269 +
270 +
        for (label, score) in *attrs {
271 +
            let bar_y = y + (row_height - bar_height) / 2.0;
272 +
            let fill_width = (*score as f64 / 5.0) * track_width;
273 +
274 +
            svg.push_str(&format!(
275 +
                r#"<text x="0" y="{:.1}" fill="white" fill-opacity="0.5" font-size="9" font-family="Commit Mono, monospace">{}</text>"#,
276 +
                y + row_height / 2.0 + 3.0, label
277 +
            ));
278 +
279 +
            svg.push_str(&format!(
280 +
                r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.08"/>"#,
281 +
                track_left, bar_y, track_width, bar_height
282 +
            ));
283 +
284 +
            if fill_width > 0.0 {
285 +
                svg.push_str(&format!(
286 +
                    r#"<rect x="{:.1}" y="{:.1}" width="{:.1}" height="{:.1}" rx="2" fill="white" fill-opacity="0.6"/>"#,
287 +
                    track_left, bar_y, fill_width, bar_height
288 +
                ));
289 +
            }
290 +
291 +
            y += row_height;
292 +
        }
293 +
    }
294 +
295 +
    svg.push_str("</svg>");
296 +
    svg
297 +
}
298 +
299 +
// --- Image processing ---
300 +
301 +
fn process_image(data: &[u8]) -> Result<Vec<u8>, String> {
302 +
    let reader = image::ImageReader::new(std::io::Cursor::new(data))
303 +
        .with_guessed_format()
304 +
        .map_err(|e| format!("Failed to read image: {}", e))?;
305 +
    let mut decoder = reader
306 +
        .into_decoder()
307 +
        .map_err(|e| format!("Failed to create decoder: {}", e))?;
308 +
    let orientation = decoder
309 +
        .orientation()
310 +
        .unwrap_or(image::metadata::Orientation::NoTransforms);
311 +
    let mut img = image::DynamicImage::from_decoder(decoder)
312 +
        .map_err(|e| format!("Failed to decode image: {}", e))?;
313 +
    img.apply_orientation(orientation);
314 +
    let mut output = Vec::new();
315 +
    let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 75);
316 +
    img.write_with_encoder(encoder)
317 +
        .map_err(|e| format!("JPEG encoding failed: {}", e))?;
318 +
    Ok(output)
319 +
}
320 +
321 +
// --- Multipart parsing ---
322 +
323 +
struct WineFormData {
324 +
    name: String,
325 +
    origin: String,
326 +
    grape: String,
327 +
    notes: String,
328 +
    background: String,
329 +
    image: Option<Vec<u8>>,
330 +
    image_mime: Option<String>,
331 +
    sweetness: i32,
332 +
    acidity: i32,
333 +
    tannin: i32,
334 +
    alcohol: i32,
335 +
    body: i32,
336 +
    clarity: i32,
337 +
    color_intensity: i32,
338 +
    aroma_intensity: i32,
339 +
    nose_complexity: i32,
340 +
}
341 +
342 +
async fn parse_wine_multipart(mut multipart: Multipart) -> Result<WineFormData, String> {
343 +
    let mut name = String::new();
344 +
    let mut origin = String::new();
345 +
    let mut grape = String::new();
346 +
    let mut notes = String::new();
347 +
    let mut background = String::new();
348 +
    let mut image: Option<Vec<u8>> = None;
349 +
    let mut image_mime: Option<String> = None;
350 +
    let mut sweetness = 3;
351 +
    let mut acidity = 3;
352 +
    let mut tannin = 3;
353 +
    let mut alcohol = 3;
354 +
    let mut body = 3;
355 +
    let mut clarity = 3;
356 +
    let mut color_intensity = 3;
357 +
    let mut aroma_intensity = 3;
358 +
    let mut nose_complexity = 3;
359 +
360 +
    while let Ok(Some(field)) = multipart.next_field().await {
361 +
        let field_name = field.name().unwrap_or("").to_string();
362 +
        match field_name.as_str() {
363 +
            "image" => {
364 +
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
365 +
                if !bytes.is_empty() {
366 +
                    let processed = process_image(&bytes)?;
367 +
                    image = Some(processed);
368 +
                    image_mime = Some("image/jpeg".to_string());
369 +
                }
370 +
            }
371 +
            "name" => name = field.text().await.unwrap_or_default(),
372 +
            "origin" => origin = field.text().await.unwrap_or_default(),
373 +
            "grape" => grape = field.text().await.unwrap_or_default(),
374 +
            "notes" => notes = field.text().await.unwrap_or_default(),
375 +
            "background" => background = field.text().await.unwrap_or_default(),
376 +
            "sweetness" => sweetness = field.text().await.unwrap_or_default().parse().unwrap_or(3),
377 +
            "acidity" => acidity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
378 +
            "tannin" => tannin = field.text().await.unwrap_or_default().parse().unwrap_or(3),
379 +
            "alcohol" => alcohol = field.text().await.unwrap_or_default().parse().unwrap_or(3),
380 +
            "body" => body = field.text().await.unwrap_or_default().parse().unwrap_or(3),
381 +
            "clarity" => clarity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
382 +
            "color_intensity" => color_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
383 +
            "aroma_intensity" => aroma_intensity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
384 +
            "nose_complexity" => nose_complexity = field.text().await.unwrap_or_default().parse().unwrap_or(3),
385 +
            _ => {}
386 +
        }
387 +
    }
388 +
389 +
    if name.trim().is_empty() {
390 +
        return Err("Name is required".to_string());
391 +
    }
392 +
393 +
    let clamp = |v: i32| v.max(1).min(5);
394 +
    Ok(WineFormData {
395 +
        name: name.trim().to_string(),
396 +
        origin: origin.trim().to_string(),
397 +
        grape: grape.trim().to_string(),
398 +
        notes: notes.trim().to_string(),
399 +
        background: background.trim().to_string(),
400 +
        image,
401 +
        image_mime,
402 +
        sweetness: clamp(sweetness),
403 +
        acidity: clamp(acidity),
404 +
        tannin: clamp(tannin),
405 +
        alcohol: clamp(alcohol),
406 +
        body: clamp(body),
407 +
        clarity: clamp(clarity),
408 +
        color_intensity: clamp(color_intensity),
409 +
        aroma_intensity: clamp(aroma_intensity),
410 +
        nose_complexity: clamp(nose_complexity),
411 +
    })
412 +
}
413 +
414 +
struct WishlistFormData {
415 +
    name: String,
416 +
    origin: String,
417 +
    grape: String,
418 +
    notes: String,
419 +
    background: String,
420 +
    image: Option<Vec<u8>>,
421 +
    image_mime: Option<String>,
422 +
}
423 +
424 +
async fn parse_wishlist_multipart(mut multipart: Multipart) -> Result<WishlistFormData, String> {
425 +
    let mut name = String::new();
426 +
    let mut origin = String::new();
427 +
    let mut grape = String::new();
428 +
    let mut notes = String::new();
429 +
    let mut background = String::new();
430 +
    let mut image: Option<Vec<u8>> = None;
431 +
    let mut image_mime: Option<String> = None;
432 +
433 +
    while let Ok(Some(field)) = multipart.next_field().await {
434 +
        let field_name = field.name().unwrap_or("").to_string();
435 +
        match field_name.as_str() {
436 +
            "image" => {
437 +
                let bytes = field.bytes().await.map_err(|e| format!("Failed to read image: {}", e))?;
438 +
                if !bytes.is_empty() {
439 +
                    let processed = process_image(&bytes)?;
440 +
                    image = Some(processed);
441 +
                    image_mime = Some("image/jpeg".to_string());
442 +
                }
443 +
            }
444 +
            "name" => name = field.text().await.unwrap_or_default(),
445 +
            "origin" => origin = field.text().await.unwrap_or_default(),
446 +
            "grape" => grape = field.text().await.unwrap_or_default(),
447 +
            "notes" => notes = field.text().await.unwrap_or_default(),
448 +
            "background" => background = field.text().await.unwrap_or_default(),
449 +
            _ => {}
450 +
        }
451 +
    }
452 +
453 +
    if name.trim().is_empty() {
454 +
        return Err("Name is required".to_string());
455 +
    }
456 +
457 +
    Ok(WishlistFormData {
458 +
        name: name.trim().to_string(),
459 +
        origin: origin.trim().to_string(),
460 +
        grape: grape.trim().to_string(),
461 +
        notes: notes.trim().to_string(),
462 +
        background: background.trim().to_string(),
463 +
        image,
464 +
        image_mime,
465 +
    })
466 +
}
467 +
468 +
// --- Router ---
469 +
470 +
pub async fn run(host: String, port: u16) {
471 +
    use handlers::{admin, public};
472 +
473 +
    dotenvy::dotenv().ok();
474 +
475 +
    let db = db::init_db();
476 +
477 +
    if let Err(e) = db::prune_expired_sessions(&db) {
478 +
        tracing::warn!("Failed to prune sessions: {}", e);
479 +
    }
480 +
481 +
    let app_password = std::env::var("CELLAR_PASSWORD").unwrap_or_else(|_| {
482 +
        tracing::warn!("CELLAR_PASSWORD not set, using default 'changeme'");
483 +
        "changeme".to_string()
484 +
    });
485 +
486 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
487 +
        .map(|v| v == "true")
488 +
        .unwrap_or(false);
489 +
490 +
    let anthropic_api_key = std::env::var("ANTHROPIC_API_KEY").ok().filter(|k| !k.is_empty());
491 +
492 +
    let state = Arc::new(AppState {
493 +
        db,
494 +
        app_password,
495 +
        cookie_secure,
496 +
        anthropic_api_key,
497 +
    });
498 +
499 +
    let app = Router::new()
500 +
        // Public routes
501 +
        .route("/", get(public::get_index))
502 +
        .route("/wines/{short_id}", get(public::get_wine_detail))
503 +
        .route("/wines/{short_id}/image", get(public::get_wine_image))
504 +
        // Admin auth routes
505 +
        .route("/admin/login", get(admin::get_login).post(admin::post_login))
506 +
        .route("/admin/logout", get(admin::get_logout))
507 +
        // Admin protected routes
508 +
        .route("/admin", get(admin::get_admin))
509 +
        .route("/admin/new", get(admin::get_new_wine).post(admin::post_new_wine))
510 +
        .route(
511 +
            "/admin/edit/{short_id}",
512 +
            get(admin::get_edit_wine).post(admin::post_edit_wine),
513 +
        )
514 +
        .route("/admin/delete/{short_id}", post(admin::post_delete_wine))
515 +
        // Wishlist
516 +
        .route("/wishlist", get(public::get_wishlist))
517 +
        .route(
518 +
            "/admin/wishlist/new",
519 +
            get(admin::get_new_wishlist_wine).post(admin::post_new_wishlist_wine),
520 +
        )
521 +
        .route(
522 +
            "/admin/wishlist/edit/{short_id}",
523 +
            get(admin::get_edit_wishlist_wine).post(admin::post_edit_wishlist_wine),
524 +
        )
525 +
        .route(
526 +
            "/admin/wishlist/delete/{short_id}",
527 +
            post(admin::post_delete_wishlist_wine),
528 +
        )
529 +
        .route(
530 +
            "/admin/wishlist/promote/{short_id}",
531 +
            post(admin::post_promote_wine),
532 +
        )
533 +
        // Claude vision
534 +
        .route("/admin/analyze-image", post(admin::post_analyze_image))
535 +
        // Static assets
536 +
        .route("/static/{*path}", get(public::serve_static))
537 +
        .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
538 +
        .with_state(state);
539 +
540 +
    let addr = format!("{}:{}", host, port);
541 +
    tracing::info!("Listening on http://{}", addr);
542 +
543 +
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
544 +
    axum::serve(listener, app).await.unwrap();
545 +
}
apps/jotts/src/auth.rs +1 −37
33 33
34 34
fn is_valid_session(state: &AppState, token: &str) -> bool {
35 35
    match db::get_session_expiry(&state.db, token) {
36 -
        Ok(Some(expires_at)) => {
37 -
            let now = chrono_now();
38 -
            expires_at > now
39 -
        }
36 +
        Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(),
40 37
        _ => false,
41 38
    }
42 39
}
43 -
44 -
fn chrono_now() -> String {
45 -
    use std::time::{SystemTime, UNIX_EPOCH};
46 -
    let secs = SystemTime::now()
47 -
        .duration_since(UNIX_EPOCH)
48 -
        .unwrap()
49 -
        .as_secs();
50 -
    let days_since_epoch = secs / 86400;
51 -
    let time_of_day = secs % 86400;
52 -
    let hours = time_of_day / 3600;
53 -
    let minutes = (time_of_day % 3600) / 60;
54 -
    let seconds = time_of_day % 60;
55 -
56 -
    let (year, month, day) = days_to_ymd(days_since_epoch as i64);
57 -
    format!(
58 -
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
59 -
        year, month, day, hours, minutes, seconds
60 -
    )
61 -
}
62 -
63 -
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
64 -
    days += 719468;
65 -
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
66 -
    let doe = (days - era * 146097) as u32;
67 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
68 -
    let y = yoe as i64 + era * 400;
69 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
70 -
    let mp = (5 * doy + 2) / 153;
71 -
    let d = doy - (153 * mp + 2) / 5 + 1;
72 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
73 -
    let y = if m <= 2 { y + 1 } else { y };
74 -
    (y, m as i64, d as i64)
75 -
}
apps/jotts/src/server.rs +1 −36
242 242
243 243
    // Session expires in 7 days
244 244
    // We need to compute a datetime 7 days from now
245 -
    let expires_at = {
246 -
        use std::time::{SystemTime, UNIX_EPOCH};
247 -
        let secs = SystemTime::now()
248 -
            .duration_since(UNIX_EPOCH)
249 -
            .unwrap()
250 -
            .as_secs()
251 -
            + 7 * 24 * 3600;
252 -
        let days = secs / 86400;
253 -
        let tod = secs % 86400;
254 -
        let (y, m, d) = days_to_ymd(days as i64);
255 -
        format!(
256 -
            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
257 -
            y,
258 -
            m,
259 -
            d,
260 -
            tod / 3600,
261 -
            (tod % 3600) / 60,
262 -
            tod % 60
263 -
        )
264 -
    };
245 +
    let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600);
265 246
266 247
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
267 248
        tracing::error!("Failed to create session: {}", e);
431 412
            Redirect::to("/").into_response()
432 413
        }
433 414
    }
434 -
}
435 -
436 -
// --- Date helper (same algorithm as auth.rs) ---
437 -
438 -
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
439 -
    days += 719468;
440 -
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
441 -
    let doe = (days - era * 146097) as u32;
442 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
443 -
    let y = yoe as i64 + era * 400;
444 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
445 -
    let mp = (5 * doy + 2) / 153;
446 -
    let d = doy - (153 * mp + 2) / 5 + 1;
447 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
448 -
    let y = if m <= 2 { y + 1 } else { y };
449 -
    (y, m as i64, d as i64)
450 415
}
451 416
452 417
// --- Router ---
apps/parcels/src/auth.rs +2 −108
12 12
    verify_password,
13 13
};
14 14
15 -
// ── Session Token ──────────────────────────────────────────────────────────
16 -
17 15
/// Return an ISO datetime string 7 days from now.
18 16
pub fn session_expiry_at() -> String {
19 -
    use std::time::{SystemTime, UNIX_EPOCH};
20 -
    let secs = SystemTime::now()
21 -
        .duration_since(UNIX_EPOCH)
22 -
        .unwrap()
23 -
        .as_secs()
24 -
        + 7 * 24 * 3600;
25 -
    let dt = secs;
26 -
    let s = dt % 60;
27 -
    let m = (dt / 60) % 60;
28 -
    let h = (dt / 3600) % 24;
29 -
    let days_since_epoch = dt / 86400;
30 -
    format_unix_to_datetime(days_since_epoch, h, m, s)
31 -
}
32 -
33 -
fn format_unix_to_datetime(days: u64, h: u64, m: u64, s: u64) -> String {
34 -
    // https://howardhinnant.github.io/date_algorithms.html
35 -
    let z = days as i64 + 719468;
36 -
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
37 -
    let doe = (z - era * 146097) as u64;
38 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
39 -
    let y = yoe as i64 + era * 400;
40 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
41 -
    let mp = (5 * doy + 2) / 153;
42 -
    let d = doy - (153 * mp + 2) / 5 + 1;
43 -
    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
44 -
    let y = if mo <= 2 { y + 1 } else { y };
45 -
    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, mo, d, h, m, s)
46 -
}
47 -
48 -
pub fn format_unix_to_datetime_pub(days: u64, h: u64, m: u64, s: u64) -> String {
49 -
    format_unix_to_datetime(days, h, m, s)
17 +
    andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600)
50 18
}
51 19
52 20
pub fn extract_session_token(headers: &axum::http::HeaderMap) -> Option<String> {
53 21
    extract_session_cookie(headers)
54 22
}
55 -
56 -
// ── Axum Extractor ─────────────────────────────────────────────────────────
57 23
58 24
/// Authenticated session guard. Extract from request; redirects to /login if not valid.
59 25
pub struct AuthSession;
81 47
82 48
async fn is_valid_session(state: &AppState, token: &str) -> bool {
83 49
    match crate::db::get_session_expiry(&state.db, token) {
84 -
        Ok(Some(expires_at)) => {
85 -
            use std::time::{SystemTime, UNIX_EPOCH};
86 -
            let now_secs = SystemTime::now()
87 -
                .duration_since(UNIX_EPOCH)
88 -
                .unwrap()
89 -
                .as_secs();
90 -
            let now_str = {
91 -
                let days = now_secs / 86400;
92 -
                let h = (now_secs / 3600) % 24;
93 -
                let m = (now_secs / 60) % 60;
94 -
                let s = now_secs % 60;
95 -
                format_unix_to_datetime(days, h, m, s)
96 -
            };
97 -
            expires_at > now_str
98 -
        }
50 +
        Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(),
99 51
        _ => false,
100 52
    }
101 53
}
102 -
103 -
#[cfg(test)]
104 -
mod tests {
105 -
    use super::*;
106 -
107 -
    #[test]
108 -
    fn format_unix_epoch() {
109 -
        assert_eq!(format_unix_to_datetime(0, 0, 0, 0), "1970-01-01 00:00:00");
110 -
    }
111 -
112 -
    #[test]
113 -
    fn format_unix_known_date() {
114 -
        // 2024-01-15 = day 19737 since epoch
115 -
        assert_eq!(
116 -
            format_unix_to_datetime(19737, 12, 30, 45),
117 -
            "2024-01-15 12:30:45"
118 -
        );
119 -
    }
120 -
121 -
    #[test]
122 -
    fn format_unix_y2k() {
123 -
        // 2000-01-01 = day 10957
124 -
        assert_eq!(
125 -
            format_unix_to_datetime(10957, 0, 0, 0),
126 -
            "2000-01-01 00:00:00"
127 -
        );
128 -
    }
129 -
130 -
    #[test]
131 -
    fn format_unix_leap_day() {
132 -
        // 2024-02-29 = day 19782
133 -
        assert_eq!(
134 -
            format_unix_to_datetime(19782, 23, 59, 59),
135 -
            "2024-02-29 23:59:59"
136 -
        );
137 -
    }
138 -
139 -
    #[test]
140 -
    fn format_unix_end_of_year() {
141 -
        // 2023-12-31 = day 19722
142 -
        assert_eq!(
143 -
            format_unix_to_datetime(19722, 23, 59, 59),
144 -
            "2023-12-31 23:59:59"
145 -
        );
146 -
    }
147 -
148 -
    #[test]
149 -
    fn session_expiry_at_is_valid_format() {
150 -
        let expiry = session_expiry_at();
151 -
        // Should match YYYY-MM-DD HH:MM:SS
152 -
        assert_eq!(expiry.len(), 19);
153 -
        assert_eq!(&expiry[4..5], "-");
154 -
        assert_eq!(&expiry[7..8], "-");
155 -
        assert_eq!(&expiry[10..11], " ");
156 -
        assert_eq!(&expiry[13..14], ":");
157 -
        assert_eq!(&expiry[16..17], ":");
158 -
    }
159 -
}
apps/parcels/src/main.rs +1 −9
139 139
140 140
    let detail = usps::fetch_tracking(&state.http_client, &token, &package.tracking_number).await?;
141 141
142 -
    use std::time::{SystemTime, UNIX_EPOCH};
143 -
    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
144 -
    let refreshed_at = {
145 -
        let days = now / 86400;
146 -
        let h = (now / 3600) % 24;
147 -
        let m = (now / 60) % 60;
148 -
        let s = now % 60;
149 -
        auth::format_unix_to_datetime_pub(days, h, m, s)
150 -
    };
142 +
    let refreshed_at = andromeda_auth::datetime::now_datetime_string();
151 143
152 144
    let expected_delivery = detail
153 145
        .delivery_date_expectation
apps/posts/src/auth.rs +1 −37
33 33
34 34
fn is_valid_session(state: &AppState, token: &str) -> bool {
35 35
    match db::get_session_expiry(&state.db, token) {
36 -
        Ok(Some(expires_at)) => {
37 -
            let now = chrono_now();
38 -
            expires_at > now
39 -
        }
36 +
        Ok(Some(expires_at)) => expires_at > andromeda_auth::datetime::now_datetime_string(),
40 37
        _ => false,
41 38
    }
42 39
}
43 -
44 -
fn chrono_now() -> String {
45 -
    use std::time::{SystemTime, UNIX_EPOCH};
46 -
    let secs = SystemTime::now()
47 -
        .duration_since(UNIX_EPOCH)
48 -
        .unwrap()
49 -
        .as_secs();
50 -
    let days_since_epoch = secs / 86400;
51 -
    let time_of_day = secs % 86400;
52 -
    let hours = time_of_day / 3600;
53 -
    let minutes = (time_of_day % 3600) / 60;
54 -
    let seconds = time_of_day % 60;
55 -
56 -
    let (year, month, day) = days_to_ymd(days_since_epoch as i64);
57 -
    format!(
58 -
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
59 -
        year, month, day, hours, minutes, seconds
60 -
    )
61 -
}
62 -
63 -
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
64 -
    days += 719468;
65 -
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
66 -
    let doe = (days - era * 146097) as u32;
67 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
68 -
    let y = yoe as i64 + era * 400;
69 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
70 -
    let mp = (5 * doy + 2) / 153;
71 -
    let d = doy - (153 * mp + 2) / 5 + 1;
72 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
73 -
    let y = if m <= 2 { y + 1 } else { y };
74 -
    (y, m as i64, d as i64)
75 -
}
apps/posts/src/server.rs (deleted) +0 −1497
1 -
use askama::Template;
2 -
use askama_web::WebTemplate;
3 -
use axum::{
4 -
    extract::{DefaultBodyLimit, Form, Multipart, Path, Query, State},
5 -
    http::{HeaderValue, StatusCode, Uri},
6 -
    response::{Html, IntoResponse, Redirect, Response},
7 -
    routing::{get, post},
8 -
    Router,
9 -
};
10 -
use pulldown_cmark::{Options, Parser, html};
11 -
use rust_embed::Embed;
12 -
use std::sync::Arc;
13 -
14 -
use crate::auth;
15 -
use crate::db::{self, Db, Page, Post, UploadedFile};
16 -
17 -
#[derive(Debug, Clone)]
18 -
pub struct NavLink {
19 -
    pub label: String,
20 -
    pub url: String,
21 -
}
22 -
23 -
#[derive(Clone)]
24 -
pub struct AppState {
25 -
    pub db: Db,
26 -
    pub app_password: String,
27 -
    pub cookie_secure: bool,
28 -
    pub uploads_dir: String,
29 -
    pub site_url: String,
30 -
}
31 -
32 -
#[derive(Embed)]
33 -
#[folder = "static/"]
34 -
struct Static;
35 -
36 -
// --- Templates ---
37 -
38 -
#[derive(Template)]
39 -
#[template(path = "base.html")]
40 -
struct BaseTemplate {
41 -
    blog_title: String,
42 -
    nav_links: Vec<NavLink>,
43 -
    favicon_url: String,
44 -
    og_image_url: String,
45 -
    header_html: String,
46 -
    footer_html: String,
47 -
}
48 -
49 -
#[derive(Template)]
50 -
#[template(path = "admin_base.html")]
51 -
struct AdminBaseTemplate;
52 -
53 -
#[derive(Template)]
54 -
#[template(path = "login.html")]
55 -
struct LoginTemplate {
56 -
    error: Option<String>,
57 -
}
58 -
59 -
#[derive(Template)]
60 -
#[template(path = "index.html")]
61 -
struct IndexTemplate {
62 -
    blog_title: String,
63 -
    blog_description: String,
64 -
    intro_html: String,
65 -
    posts: Vec<Post>,
66 -
    nav_links: Vec<NavLink>,
67 -
    favicon_url: String,
68 -
    og_image_url: String,
69 -
    site_url: String,
70 -
    header_html: String,
71 -
    footer_html: String,
72 -
}
73 -
74 -
#[derive(Template)]
75 -
#[template(path = "post.html")]
76 -
struct PostTemplate {
77 -
    blog_title: String,
78 -
    nav_links: Vec<NavLink>,
79 -
    post: Post,
80 -
    rendered_content: String,
81 -
    favicon_url: String,
82 -
    og_image_url: String,
83 -
    site_url: String,
84 -
    header_html: String,
85 -
    footer_html: String,
86 -
}
87 -
88 -
#[derive(Template)]
89 -
#[template(path = "page.html")]
90 -
struct PageTemplate {
91 -
    blog_title: String,
92 -
    nav_links: Vec<NavLink>,
93 -
    page: Page,
94 -
    rendered_content: String,
95 -
    favicon_url: String,
96 -
    og_image_url: String,
97 -
    site_url: String,
98 -
    header_html: String,
99 -
    footer_html: String,
100 -
}
101 -
102 -
#[derive(Template)]
103 -
#[template(path = "admin_index.html")]
104 -
struct AdminIndexTemplate {
105 -
    posts: Vec<Post>,
106 -
}
107 -
108 -
#[derive(Template)]
109 -
#[template(path = "admin_post_form.html")]
110 -
struct AdminPostFormTemplate {
111 -
    post: Option<Post>,
112 -
    error: Option<String>,
113 -
}
114 -
115 -
#[derive(Template)]
116 -
#[template(path = "admin_pages.html")]
117 -
struct AdminPagesTemplate {
118 -
    pages: Vec<Page>,
119 -
}
120 -
121 -
#[derive(Template)]
122 -
#[template(path = "admin_page_form.html")]
123 -
struct AdminPageFormTemplate {
124 -
    page: Option<Page>,
125 -
    error: Option<String>,
126 -
}
127 -
128 -
#[derive(Template)]
129 -
#[template(path = "admin_settings.html")]
130 -
struct AdminSettingsTemplate {
131 -
    blog_title: String,
132 -
    blog_description: String,
133 -
    intro_content: String,
134 -
    nav_links: String,
135 -
    custom_css: String,
136 -
    default_css: String,
137 -
    favicon_url: String,
138 -
    og_image_url: String,
139 -
    custom_header: String,
140 -
    custom_footer: String,
141 -
    success: bool,
142 -
}
143 -
144 -
#[derive(Template)]
145 -
#[template(path = "posts.html")]
146 -
struct PostsListTemplate {
147 -
    blog_title: String,
148 -
    nav_links: Vec<NavLink>,
149 -
    posts: Vec<Post>,
150 -
    favicon_url: String,
151 -
    og_image_url: String,
152 -
    site_url: String,
153 -
    header_html: String,
154 -
    footer_html: String,
155 -
}
156 -
157 -
#[derive(Template)]
158 -
#[template(path = "admin_files.html")]
159 -
struct AdminFilesTemplate {
160 -
    files: Vec<UploadedFile>,
161 -
    site_url: String,
162 -
    error: Option<String>,
163 -
    success: bool,
164 -
}
165 -
166 -
// --- Query/Form structs ---
167 -
168 -
#[derive(serde::Deserialize, Default)]
169 -
pub struct FlashQuery {
170 -
    pub error: Option<String>,
171 -
    #[serde(default)]
172 -
    pub success: bool,
173 -
}
174 -
175 -
#[derive(serde::Deserialize)]
176 -
struct LoginForm {
177 -
    password: String,
178 -
}
179 -
180 -
#[derive(serde::Deserialize)]
181 -
struct PostForm {
182 -
    attributes: String,
183 -
    content: String,
184 -
    #[serde(default)]
185 -
    action: String,
186 -
}
187 -
188 -
struct ParsedAttributes {
189 -
    title: String,
190 -
    slug: String,
191 -
    alias: String,
192 -
    published_date: String,
193 -
    meta_description: String,
194 -
    meta_image: String,
195 -
    lang: String,
196 -
    tags: String,
197 -
}
198 -
199 -
fn parse_attributes(text: &str) -> ParsedAttributes {
200 -
    let mut attrs = ParsedAttributes {
201 -
        title: String::new(),
202 -
        slug: String::new(),
203 -
        alias: String::new(),
204 -
        published_date: String::new(),
205 -
        meta_description: String::new(),
206 -
        meta_image: String::new(),
207 -
        lang: String::new(),
208 -
        tags: String::new(),
209 -
    };
210 -
    for line in text.lines() {
211 -
        if let Some((key, value)) = line.split_once(':') {
212 -
            let key = key.trim().to_lowercase();
213 -
            let value = value.trim().to_string();
214 -
            match key.as_str() {
215 -
                "title" => attrs.title = value,
216 -
                "slug" => attrs.slug = value,
217 -
                "alias" => attrs.alias = value,
218 -
                "published_date" => attrs.published_date = value,
219 -
                "description" | "meta_description" => attrs.meta_description = value,
220 -
                "meta_image" => attrs.meta_image = value,
221 -
                "lang" => attrs.lang = value,
222 -
                "tags" => attrs.tags = value,
223 -
                _ => {} // ignore unknown keys (including canonical_url)
224 -
            }
225 -
        }
226 -
    }
227 -
    attrs
228 -
}
229 -
230 -
#[derive(serde::Deserialize)]
231 -
struct PageForm {
232 -
    attributes: String,
233 -
    content: String,
234 -
}
235 -
236 -
struct ParsedPageAttributes {
237 -
    title: String,
238 -
    slug: String,
239 -
    is_published: bool,
240 -
}
241 -
242 -
fn parse_page_attributes(text: &str) -> ParsedPageAttributes {
243 -
    let mut attrs = ParsedPageAttributes {
244 -
        title: String::new(),
245 -
        slug: String::new(),
246 -
        is_published: false,
247 -
    };
248 -
    for line in text.lines() {
249 -
        if let Some((key, value)) = line.split_once(':') {
250 -
            let key = key.trim().to_lowercase();
251 -
            let value = value.trim().to_string();
252 -
            match key.as_str() {
253 -
                "title" => attrs.title = value,
254 -
                "slug" => attrs.slug = value,
255 -
                "published" => attrs.is_published = value == "true",
256 -
                _ => {}
257 -
            }
258 -
        }
259 -
    }
260 -
    attrs
261 -
}
262 -
263 -
#[derive(serde::Deserialize)]
264 -
struct SettingsForm {
265 -
    blog_title: String,
266 -
    blog_description: String,
267 -
    intro_content: String,
268 -
    nav_links: String,
269 -
    custom_css: String,
270 -
    favicon_url: String,
271 -
    og_image_url: String,
272 -
    custom_header: String,
273 -
    custom_footer: String,
274 -
}
275 -
276 -
// --- Helpers ---
277 -
278 -
fn mime_from_path(path: &str) -> &'static str {
279 -
    match path.rsplit('.').next().unwrap_or("") {
280 -
        "css" => "text/css",
281 -
        "js" => "application/javascript",
282 -
        "html" => "text/html",
283 -
        "png" => "image/png",
284 -
        "jpg" | "jpeg" => "image/jpeg",
285 -
        "gif" => "image/gif",
286 -
        "webp" => "image/webp",
287 -
        "ico" => "image/x-icon",
288 -
        "svg" => "image/svg+xml",
289 -
        "woff" | "woff2" => "font/woff2",
290 -
        "ttf" => "font/ttf",
291 -
        "otf" => "font/otf",
292 -
        "json" | "webmanifest" => "application/json",
293 -
        "pdf" => "application/pdf",
294 -
        "mp4" => "video/mp4",
295 -
        "webm" => "video/webm",
296 -
        _ => "application/octet-stream",
297 -
    }
298 -
}
299 -
300 -
fn get_header_footer_html(db: &db::Db) -> (String, String) {
301 -
    let custom_header = db::get_setting(db, "custom_header")
302 -
        .ok()
303 -
        .flatten()
304 -
        .unwrap_or_default();
305 -
    let custom_footer = db::get_setting(db, "custom_footer")
306 -
        .ok()
307 -
        .flatten()
308 -
        .unwrap_or_default();
309 -
    let header_html = render_markdown(&custom_header);
310 -
    let footer_html = render_markdown(&custom_footer);
311 -
    (header_html, footer_html)
312 -
}
313 -
314 -
fn render_markdown(content: &str) -> String {
315 -
    let mut options = Options::empty();
316 -
    options.insert(Options::ENABLE_STRIKETHROUGH);
317 -
    options.insert(Options::ENABLE_TABLES);
318 -
    options.insert(Options::ENABLE_TASKLISTS);
319 -
    options.insert(Options::ENABLE_FOOTNOTES);
320 -
    let parser = Parser::new_ext(content, options);
321 -
    let mut html_output = String::new();
322 -
    html::push_html(&mut html_output, parser);
323 -
    html_output
324 -
}
325 -
326 -
fn now_datetime() -> String {
327 -
    use std::time::{SystemTime, UNIX_EPOCH};
328 -
    let secs = SystemTime::now()
329 -
        .duration_since(UNIX_EPOCH)
330 -
        .unwrap()
331 -
        .as_secs();
332 -
    let days = secs / 86400;
333 -
    let tod = secs % 86400;
334 -
    let (y, m, d) = days_to_ymd(days as i64);
335 -
    format!(
336 -
        "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
337 -
        y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60
338 -
    )
339 -
}
340 -
341 -
fn slugify(s: &str) -> String {
342 -
    s.to_lowercase()
343 -
        .chars()
344 -
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
345 -
        .collect::<String>()
346 -
        .split('-')
347 -
        .filter(|s| !s.is_empty())
348 -
        .collect::<Vec<_>>()
349 -
        .join("-")
350 -
}
351 -
352 -
fn opt_str(s: &str) -> Option<&str> {
353 -
    let trimmed = s.trim();
354 -
    if trimmed.is_empty() { None } else { Some(trimmed) }
355 -
}
356 -
357 -
fn get_blog_title(db: &Db) -> String {
358 -
    db::get_setting(db, "blog_title")
359 -
        .ok()
360 -
        .flatten()
361 -
        .unwrap_or_else(|| "My Blog".to_string())
362 -
}
363 -
364 -
fn parse_nav_links(input: &str) -> Vec<NavLink> {
365 -
    let mut links = Vec::new();
366 -
    let mut chars = input.chars().peekable();
367 -
    while let Some(c) = chars.next() {
368 -
        if c == '[' {
369 -
            let label: String = chars.by_ref().take_while(|&ch| ch != ']').collect();
370 -
            if chars.peek() == Some(&'(') {
371 -
                chars.next();
372 -
                let url: String = chars.by_ref().take_while(|&ch| ch != ')').collect();
373 -
                if !label.is_empty() && !url.is_empty() {
374 -
                    links.push(NavLink { label, url });
375 -
                }
376 -
            }
377 -
        }
378 -
    }
379 -
    links
380 -
}
381 -
382 -
fn get_nav_links(db: &Db) -> Vec<NavLink> {
383 -
    let raw = db::get_setting(db, "nav_links")
384 -
        .ok()
385 -
        .flatten()
386 -
        .unwrap_or_default();
387 -
    parse_nav_links(&raw)
388 -
}
389 -
390 -
fn get_favicon_url(db: &Db) -> String {
391 -
    db::get_setting(db, "favicon_url")
392 -
        .ok()
393 -
        .flatten()
394 -
        .unwrap_or_default()
395 -
}
396 -
397 -
fn get_og_image_url(db: &Db) -> String {
398 -
    db::get_setting(db, "og_image_url")
399 -
        .ok()
400 -
        .flatten()
401 -
        .unwrap_or_default()
402 -
}
403 -
404 -
fn render_latest_posts_embed(posts: &[&Post]) -> String {
405 -
    let mut html = String::from("<div class=\"post-list\">");
406 -
    for post in posts {
407 -
        html.push_str(&format!(
408 -
            r#"<a href="/posts/{slug}" class="post-item"><div class="post-item-info"><span class="post-title">{title}</span>"#,
409 -
            slug = post.slug,
410 -
            title = post.title,
411 -
        ));
412 -
        if let Some(ref tags) = post.tags {
413 -
            if !tags.is_empty() {
414 -
                html.push_str(r#"<span class="post-tags">"#);
415 -
                for tag in tags.split(',') {
416 -
                    let tag = tag.trim();
417 -
                    if !tag.is_empty() {
418 -
                        html.push_str(&format!(r#"<span class="tag">{}</span>"#, tag));
419 -
                    }
420 -
                }
421 -
                html.push_str("</span>");
422 -
            }
423 -
        }
424 -
        html.push_str("</div>");
425 -
        if let Some(ref date) = post.published_date {
426 -
            html.push_str(&format!(r#"<time class="post-date">{}</time>"#, date));
427 -
        }
428 -
        html.push_str("</a>");
429 -
    }
430 -
    html.push_str("</div>");
431 -
    html
432 -
}
433 -
434 -
// --- Static file handler ---
435 -
436 -
async fn serve_static(Path(path): Path<String>) -> Response {
437 -
    match Static::get(&path) {
438 -
        Some(file) => {
439 -
            let mime = mime_from_path(&path);
440 -
            (
441 -
                StatusCode::OK,
442 -
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
443 -
                file.data.to_vec(),
444 -
            )
445 -
                .into_response()
446 -
        }
447 -
        None => StatusCode::NOT_FOUND.into_response(),
448 -
    }
449 -
}
450 -
451 -
// --- Auth handlers ---
452 -
453 -
async fn get_login(Query(q): Query<FlashQuery>) -> Response {
454 -
    WebTemplate(LoginTemplate { error: q.error }).into_response()
455 -
}
456 -
457 -
async fn post_login(
458 -
    State(state): State<Arc<AppState>>,
459 -
    Form(form): Form<LoginForm>,
460 -
) -> Response {
461 -
    if !auth::verify_password(&form.password, &state.app_password) {
462 -
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
463 -
    }
464 -
465 -
    let token = auth::generate_session_token();
466 -
467 -
    let expires_at = {
468 -
        use std::time::{SystemTime, UNIX_EPOCH};
469 -
        let secs = SystemTime::now()
470 -
            .duration_since(UNIX_EPOCH)
471 -
            .unwrap()
472 -
            .as_secs()
473 -
            + 7 * 24 * 3600;
474 -
        let days = secs / 86400;
475 -
        let tod = secs % 86400;
476 -
        let (y, m, d) = days_to_ymd(days as i64);
477 -
        format!(
478 -
            "{:04}-{:02}-{:02} {:02}:{:02}:{:02}",
479 -
            y, m, d, tod / 3600, (tod % 3600) / 60, tod % 60
480 -
        )
481 -
    };
482 -
483 -
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
484 -
        tracing::error!("Failed to create session: {}", e);
485 -
        return Redirect::to("/admin/login?error=Server+error").into_response();
486 -
    }
487 -
488 -
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
489 -
    let mut resp = Redirect::to("/admin").into_response();
490 -
    resp.headers_mut().insert(
491 -
        axum::http::header::SET_COOKIE,
492 -
        HeaderValue::from_str(&cookie).unwrap(),
493 -
    );
494 -
    resp
495 -
}
496 -
497 -
async fn get_logout(State(state): State<Arc<AppState>>, headers: axum::http::HeaderMap) -> Response {
498 -
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
499 -
        for part in cookie_header.split(';') {
500 -
            let part = part.trim();
501 -
            if let Some(val) = part.strip_prefix("session=") {
502 -
                let val = val.trim();
503 -
                if !val.is_empty() {
504 -
                    let _ = db::delete_session(&state.db, val);
505 -
                }
506 -
            }
507 -
        }
508 -
    }
509 -
510 -
    let cookie = auth::clear_session_cookie();
511 -
    let mut resp = Redirect::to("/admin/login").into_response();
512 -
    resp.headers_mut().insert(
513 -
        axum::http::header::SET_COOKIE,
514 -
        HeaderValue::from_str(&cookie).unwrap(),
515 -
    );
516 -
    resp
517 -
}
518 -
519 -
// --- Public handlers ---
520 -
521 -
async fn public_index(State(state): State<Arc<AppState>>) -> Response {
522 -
    let blog_title = get_blog_title(&state.db);
523 -
    let blog_description = db::get_setting(&state.db, "blog_description")
524 -
        .ok()
525 -
        .flatten()
526 -
        .unwrap_or_default();
527 -
    let intro_content = db::get_setting(&state.db, "intro_content")
528 -
        .ok()
529 -
        .flatten()
530 -
        .unwrap_or_default();
531 -
    let nav_links = get_nav_links(&state.db);
532 -
533 -
    match db::get_published_posts(&state.db) {
534 -
        Ok(posts) => {
535 -
            let mut intro_html = render_markdown(&intro_content);
536 -
537 -
            if intro_content.contains("{{latest_posts}}") {
538 -
                let latest: Vec<&Post> = posts.iter().take(5).collect();
539 -
                let embed_html = render_latest_posts_embed(&latest);
540 -
                intro_html = intro_html.replace("<p>{{latest_posts}}</p>", &embed_html);
541 -
                intro_html = intro_html.replace("{{latest_posts}}", &embed_html);
542 -
            }
543 -
544 -
            let favicon_url = get_favicon_url(&state.db);
545 -
            let og_image_url = get_og_image_url(&state.db);
546 -
            let (header_html, footer_html) = get_header_footer_html(&state.db);
547 -
            WebTemplate(IndexTemplate {
548 -
                blog_title,
549 -
                blog_description,
550 -
                intro_html,
551 -
                posts,
552 -
                nav_links,
553 -
                favicon_url,
554 -
                og_image_url,
555 -
                site_url: state.site_url.clone(),
556 -
                header_html,
557 -
                footer_html,
558 -
            })
559 -
            .into_response()
560 -
        }
561 -
        Err(e) => {
562 -
            tracing::error!("Failed to list posts: {}", e);
563 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
564 -
        }
565 -
    }
566 -
}
567 -
568 -
async fn public_post(
569 -
    State(state): State<Arc<AppState>>,
570 -
    Path(slug): Path<String>,
571 -
) -> Response {
572 -
    match db::get_post_by_slug(&state.db, &slug) {
573 -
        Ok(Some(post)) if post.status == "published" => {
574 -
            let rendered_content = render_markdown(&post.content);
575 -
            let blog_title = get_blog_title(&state.db);
576 -
            let nav_links = get_nav_links(&state.db);
577 -
            let favicon_url = get_favicon_url(&state.db);
578 -
            let og_image_url = get_og_image_url(&state.db);
579 -
            let (header_html, footer_html) = get_header_footer_html(&state.db);
580 -
            WebTemplate(PostTemplate {
581 -
                blog_title,
582 -
                nav_links,
583 -
                post,
584 -
                rendered_content,
585 -
                favicon_url,
586 -
                og_image_url,
587 -
                site_url: state.site_url.clone(),
588 -
                header_html,
589 -
                footer_html,
590 -
            })
591 -
            .into_response()
592 -
        }
593 -
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
594 -
        Err(e) => {
595 -
            tracing::error!("Failed to get post: {}", e);
596 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
597 -
        }
598 -
    }
599 -
}
600 -
601 -
async fn public_page(
602 -
    State(state): State<Arc<AppState>>,
603 -
    Path(slug): Path<String>,
604 -
) -> Response {
605 -
    match db::get_page_by_slug(&state.db, &slug) {
606 -
        Ok(Some(page)) if page.is_published => {
607 -
            let rendered_content = render_markdown(&page.content);
608 -
            let blog_title = get_blog_title(&state.db);
609 -
            let nav_links = get_nav_links(&state.db);
610 -
            let favicon_url = get_favicon_url(&state.db);
611 -
            let og_image_url = get_og_image_url(&state.db);
612 -
            let (header_html, footer_html) = get_header_footer_html(&state.db);
613 -
            WebTemplate(PageTemplate {
614 -
                blog_title,
615 -
                nav_links,
616 -
                page,
617 -
                rendered_content,
618 -
                favicon_url,
619 -
                og_image_url,
620 -
                site_url: state.site_url.clone(),
621 -
                header_html,
622 -
                footer_html,
623 -
            })
624 -
            .into_response()
625 -
        }
626 -
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
627 -
        Err(e) => {
628 -
            tracing::error!("Failed to get page: {}", e);
629 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
630 -
        }
631 -
    }
632 -
}
633 -
634 -
async fn public_posts_list(State(state): State<Arc<AppState>>) -> Response {
635 -
    let blog_title = get_blog_title(&state.db);
636 -
    let nav_links = get_nav_links(&state.db);
637 -
    let favicon_url = get_favicon_url(&state.db);
638 -
    let og_image_url = get_og_image_url(&state.db);
639 -
640 -
    let (header_html, footer_html) = get_header_footer_html(&state.db);
641 -
642 -
    match db::get_published_posts(&state.db) {
643 -
        Ok(posts) => WebTemplate(PostsListTemplate {
644 -
            blog_title,
645 -
            nav_links,
646 -
            posts,
647 -
            favicon_url,
648 -
            og_image_url,
649 -
            site_url: state.site_url.clone(),
650 -
            header_html,
651 -
            footer_html,
652 -
        })
653 -
        .into_response(),
654 -
        Err(e) => {
655 -
            tracing::error!("Failed to list posts: {}", e);
656 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
657 -
        }
658 -
    }
659 -
}
660 -
661 -
async fn serve_custom_css(State(state): State<Arc<AppState>>) -> Response {
662 -
    let css = db::get_setting(&state.db, "custom_css")
663 -
        .ok()
664 -
        .flatten()
665 -
        .unwrap_or_default();
666 -
    (
667 -
        StatusCode::OK,
668 -
        [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
669 -
        css,
670 -
    )
671 -
        .into_response()
672 -
}
673 -
674 -
async fn fallback_handler(
675 -
    State(state): State<Arc<AppState>>,
676 -
    uri: Uri,
677 -
) -> Response {
678 -
    let path = uri.path().trim_start_matches('/');
679 -
    if let Ok(Some(redirect_to)) = db::find_alias_redirect(&state.db, path) {
680 -
        return Redirect::permanent(&redirect_to).into_response();
681 -
    }
682 -
    (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response()
683 -
}
684 -
685 -
// --- Admin post handlers ---
686 -
687 -
async fn admin_index(
688 -
    _session: auth::AuthSession,
689 -
    State(state): State<Arc<AppState>>,
690 -
) -> Response {
691 -
    match db::get_all_posts(&state.db) {
692 -
        Ok(posts) => WebTemplate(AdminIndexTemplate { posts }).into_response(),
693 -
        Err(e) => {
694 -
            tracing::error!("Failed to list posts: {}", e);
695 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
696 -
        }
697 -
    }
698 -
}
699 -
700 -
async fn admin_new_post(
701 -
    _session: auth::AuthSession,
702 -
    Query(q): Query<FlashQuery>,
703 -
) -> Response {
704 -
    WebTemplate(AdminPostFormTemplate {
705 -
        post: None,
706 -
        error: q.error,
707 -
    })
708 -
    .into_response()
709 -
}
710 -
711 -
async fn admin_create_post(
712 -
    _session: auth::AuthSession,
713 -
    State(state): State<Arc<AppState>>,
714 -
    Form(form): Form<PostForm>,
715 -
) -> Response {
716 -
    let attrs = parse_attributes(&form.attributes);
717 -
    let title = attrs.title.trim();
718 -
    if title.is_empty() {
719 -
        return Redirect::to("/admin/posts/new?error=Title+is+required").into_response();
720 -
    }
721 -
    let slug = if attrs.slug.trim().is_empty() {
722 -
        slugify(title)
723 -
    } else {
724 -
        attrs.slug.trim().to_string()
725 -
    };
726 -
727 -
    let status = if form.action == "publish" { "published" } else { "draft" };
728 -
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
729 -
    let published_date = if attrs.published_date.trim().is_empty() {
730 -
        now_datetime()
731 -
    } else {
732 -
        attrs.published_date.trim().to_string()
733 -
    };
734 -
735 -
    match db::create_post(
736 -
        &state.db,
737 -
        title,
738 -
        &slug,
739 -
        &form.content,
740 -
        status,
741 -
        opt_str(&attrs.alias),
742 -
        None,
743 -
        Some(&published_date),
744 -
        opt_str(&attrs.meta_description),
745 -
        opt_str(&attrs.meta_image),
746 -
        lang,
747 -
        opt_str(&attrs.tags),
748 -
    ) {
749 -
        Ok(_) => Redirect::to("/admin").into_response(),
750 -
        Err(e) => {
751 -
            tracing::error!("Failed to create post: {}", e);
752 -
            Redirect::to("/admin/posts/new?error=Failed+to+create+post").into_response()
753 -
        }
754 -
    }
755 -
}
756 -
757 -
async fn admin_edit_post(
758 -
    _session: auth::AuthSession,
759 -
    State(state): State<Arc<AppState>>,
760 -
    Path(short_id): Path<String>,
761 -
    Query(q): Query<FlashQuery>,
762 -
) -> Response {
763 -
    match db::get_post_by_short_id(&state.db, &short_id) {
764 -
        Ok(Some(post)) => WebTemplate(AdminPostFormTemplate {
765 -
            post: Some(post),
766 -
            error: q.error,
767 -
        })
768 -
        .into_response(),
769 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
770 -
        Err(e) => {
771 -
            tracing::error!("Failed to get post: {}", e);
772 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
773 -
        }
774 -
    }
775 -
}
776 -
777 -
async fn admin_update_post(
778 -
    _session: auth::AuthSession,
779 -
    State(state): State<Arc<AppState>>,
780 -
    Path(short_id): Path<String>,
781 -
    Form(form): Form<PostForm>,
782 -
) -> Response {
783 -
    let attrs = parse_attributes(&form.attributes);
784 -
    let title = attrs.title.trim();
785 -
    if title.is_empty() {
786 -
        return Redirect::to(&format!("/admin/posts/{}/edit?error=Title+is+required", short_id))
787 -
            .into_response();
788 -
    }
789 -
    let slug = if attrs.slug.trim().is_empty() {
790 -
        slugify(title)
791 -
    } else {
792 -
        attrs.slug.trim().to_string()
793 -
    };
794 -
795 -
    let status = if form.action == "publish" { "published" } else { "draft" };
796 -
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
797 -
    let published_date = if attrs.published_date.trim().is_empty() {
798 -
        None
799 -
    } else {
800 -
        Some(attrs.published_date.trim().to_string())
801 -
    };
802 -
803 -
    match db::update_post(
804 -
        &state.db,
805 -
        &short_id,
806 -
        title,
807 -
        &slug,
808 -
        &form.content,
809 -
        status,
810 -
        opt_str(&attrs.alias),
811 -
        None,
812 -
        published_date.as_deref(),
813 -
        opt_str(&attrs.meta_description),
814 -
        opt_str(&attrs.meta_image),
815 -
        lang,
816 -
        opt_str(&attrs.tags),
817 -
    ) {
818 -
        Ok(Some(_)) => Redirect::to("/admin").into_response(),
819 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
820 -
        Err(e) => {
821 -
            tracing::error!("Failed to update post: {}", e);
822 -
            Redirect::to(&format!("/admin/posts/{}/edit?error=Failed+to+update", short_id))
823 -
                .into_response()
824 -
        }
825 -
    }
826 -
}
827 -
828 -
async fn admin_delete_post(
829 -
    _session: auth::AuthSession,
830 -
    State(state): State<Arc<AppState>>,
831 -
    Path(short_id): Path<String>,
832 -
) -> Response {
833 -
    match db::delete_post(&state.db, &short_id) {
834 -
        Ok(_) => Redirect::to("/admin").into_response(),
835 -
        Err(e) => {
836 -
            tracing::error!("Failed to delete post: {}", e);
837 -
            Redirect::to("/admin").into_response()
838 -
        }
839 -
    }
840 -
}
841 -
842 -
async fn admin_toggle_publish(
843 -
    _session: auth::AuthSession,
844 -
    State(state): State<Arc<AppState>>,
845 -
    Path(short_id): Path<String>,
846 -
) -> Response {
847 -
    match db::toggle_post_status(&state.db, &short_id) {
848 -
        Ok(_) => Redirect::to("/admin").into_response(),
849 -
        Err(e) => {
850 -
            tracing::error!("Failed to toggle post status: {}", e);
851 -
            Redirect::to("/admin").into_response()
852 -
        }
853 -
    }
854 -
}
855 -
856 -
// --- Admin page handlers ---
857 -
858 -
async fn admin_pages(
859 -
    _session: auth::AuthSession,
860 -
    State(state): State<Arc<AppState>>,
861 -
) -> Response {
862 -
    match db::get_all_pages(&state.db) {
863 -
        Ok(pages) => WebTemplate(AdminPagesTemplate { pages }).into_response(),
864 -
        Err(e) => {
865 -
            tracing::error!("Failed to list pages: {}", e);
866 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
867 -
        }
868 -
    }
869 -
}
870 -
871 -
async fn admin_new_page(
872 -
    _session: auth::AuthSession,
873 -
    Query(q): Query<FlashQuery>,
874 -
) -> Response {
875 -
    WebTemplate(AdminPageFormTemplate {
876 -
        page: None,
877 -
        error: q.error,
878 -
    })
879 -
    .into_response()
880 -
}
881 -
882 -
const RESERVED_PAGE_SLUGS: &[&str] = &[
883 -
    "posts", "admin", "feed.xml", "custom-styles.css", "static", "files",
884 -
];
885 -
886 -
fn is_reserved_page_slug(slug: &str) -> bool {
887 -
    RESERVED_PAGE_SLUGS.contains(&slug)
888 -
}
889 -
890 -
async fn admin_create_page(
891 -
    _session: auth::AuthSession,
892 -
    State(state): State<Arc<AppState>>,
893 -
    Form(form): Form<PageForm>,
894 -
) -> Response {
895 -
    let attrs = parse_page_attributes(&form.attributes);
896 -
    let title = attrs.title.trim().to_string();
897 -
    let slug = attrs.slug.trim().to_string();
898 -
    if title.is_empty() || slug.is_empty() {
899 -
        return Redirect::to("/admin/pages/new?error=Title+and+slug+are+required").into_response();
900 -
    }
901 -
    if is_reserved_page_slug(&slug) {
902 -
        return Redirect::to("/admin/pages/new?error=That+slug+is+reserved").into_response();
903 -
    }
904 -
905 -
    match db::create_page(&state.db, &title, &slug, &form.content, attrs.is_published, 0) {
906 -
        Ok(_) => Redirect::to("/admin/pages").into_response(),
907 -
        Err(e) => {
908 -
            tracing::error!("Failed to create page: {}", e);
909 -
            Redirect::to("/admin/pages/new?error=Failed+to+create+page").into_response()
910 -
        }
911 -
    }
912 -
}
913 -
914 -
async fn admin_edit_page(
915 -
    _session: auth::AuthSession,
916 -
    State(state): State<Arc<AppState>>,
917 -
    Path(short_id): Path<String>,
918 -
    Query(q): Query<FlashQuery>,
919 -
) -> Response {
920 -
    match db::get_page_by_short_id(&state.db, &short_id) {
921 -
        Ok(Some(page)) => WebTemplate(AdminPageFormTemplate {
922 -
            page: Some(page),
923 -
            error: q.error,
924 -
        })
925 -
        .into_response(),
926 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
927 -
        Err(e) => {
928 -
            tracing::error!("Failed to get page: {}", e);
929 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
930 -
        }
931 -
    }
932 -
}
933 -
934 -
async fn admin_update_page(
935 -
    _session: auth::AuthSession,
936 -
    State(state): State<Arc<AppState>>,
937 -
    Path(short_id): Path<String>,
938 -
    Form(form): Form<PageForm>,
939 -
) -> Response {
940 -
    let attrs = parse_page_attributes(&form.attributes);
941 -
    let title = attrs.title.trim().to_string();
942 -
    let slug = attrs.slug.trim().to_string();
943 -
    if title.is_empty() || slug.is_empty() {
944 -
        return Redirect::to(&format!("/admin/pages/{}/edit?error=Title+and+slug+are+required", short_id))
945 -
            .into_response();
946 -
    }
947 -
    if is_reserved_page_slug(&slug) {
948 -
        return Redirect::to(&format!("/admin/pages/{}/edit?error=That+slug+is+reserved", short_id))
949 -
            .into_response();
950 -
    }
951 -
952 -
    match db::update_page(&state.db, &short_id, &title, &slug, &form.content, attrs.is_published, 0) {
953 -
        Ok(Some(_)) => Redirect::to("/admin/pages").into_response(),
954 -
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
955 -
        Err(e) => {
956 -
            tracing::error!("Failed to update page: {}", e);
957 -
            Redirect::to(&format!("/admin/pages/{}/edit?error=Failed+to+update", short_id))
958 -
                .into_response()
959 -
        }
960 -
    }
961 -
}
962 -
963 -
async fn admin_delete_page(
964 -
    _session: auth::AuthSession,
965 -
    State(state): State<Arc<AppState>>,
966 -
    Path(short_id): Path<String>,
967 -
) -> Response {
968 -
    match db::delete_page(&state.db, &short_id) {
969 -
        Ok(_) => Redirect::to("/admin/pages").into_response(),
970 -
        Err(e) => {
971 -
            tracing::error!("Failed to delete page: {}", e);
972 -
            Redirect::to("/admin/pages").into_response()
973 -
        }
974 -
    }
975 -
}
976 -
977 -
// --- Admin settings handlers ---
978 -
979 -
async fn admin_get_settings(
980 -
    _session: auth::AuthSession,
981 -
    State(state): State<Arc<AppState>>,
982 -
    Query(q): Query<FlashQuery>,
983 -
) -> Response {
984 -
    let blog_title = db::get_setting(&state.db, "blog_title").ok().flatten().unwrap_or_default();
985 -
    let blog_description = db::get_setting(&state.db, "blog_description").ok().flatten().unwrap_or_default();
986 -
    let intro_content = db::get_setting(&state.db, "intro_content").ok().flatten().unwrap_or_default();
987 -
    let nav_links = db::get_setting(&state.db, "nav_links").ok().flatten().unwrap_or_default();
988 -
    let custom_css = db::get_setting(&state.db, "custom_css").ok().flatten().unwrap_or_default();
989 -
    let favicon_url = db::get_setting(&state.db, "favicon_url").ok().flatten().unwrap_or_default();
990 -
    let og_image_url = db::get_setting(&state.db, "og_image_url").ok().flatten().unwrap_or_default();
991 -
    let custom_header = db::get_setting(&state.db, "custom_header").ok().flatten().unwrap_or_default();
992 -
    let custom_footer = db::get_setting(&state.db, "custom_footer").ok().flatten().unwrap_or_default();
993 -
    let default_css = Static::get("styles.css")
994 -
        .map(|f| String::from_utf8_lossy(&f.data).into_owned())
995 -
        .unwrap_or_default();
996 -
997 -
    WebTemplate(AdminSettingsTemplate {
998 -
        blog_title,
999 -
        blog_description,
1000 -
        intro_content,
1001 -
        nav_links,
1002 -
        custom_css,
1003 -
        default_css,
1004 -
        favicon_url,
1005 -
        og_image_url,
1006 -
        custom_header,
1007 -
        custom_footer,
1008 -
        success: q.success,
1009 -
    })
1010 -
    .into_response()
1011 -
}
1012 -
1013 -
async fn admin_post_settings(
1014 -
    _session: auth::AuthSession,
1015 -
    State(state): State<Arc<AppState>>,
1016 -
    Form(form): Form<SettingsForm>,
1017 -
) -> Response {
1018 -
    let _ = db::set_setting(&state.db, "blog_title", form.blog_title.trim());
1019 -
    let _ = db::set_setting(&state.db, "blog_description", form.blog_description.trim());
1020 -
    let _ = db::set_setting(&state.db, "intro_content", &form.intro_content);
1021 -
    let _ = db::set_setting(&state.db, "nav_links", &form.nav_links);
1022 -
    let _ = db::set_setting(&state.db, "custom_css", &form.custom_css);
1023 -
    let _ = db::set_setting(&state.db, "favicon_url", form.favicon_url.trim());
1024 -
    let _ = db::set_setting(&state.db, "og_image_url", form.og_image_url.trim());
1025 -
    let _ = db::set_setting(&state.db, "custom_header", &form.custom_header);
1026 -
    let _ = db::set_setting(&state.db, "custom_footer", &form.custom_footer);
1027 -
    Redirect::to("/admin/settings?success=true").into_response()
1028 -
}
1029 -
1030 -
// --- Admin file handlers ---
1031 -
1032 -
async fn admin_files(
1033 -
    _session: auth::AuthSession,
1034 -
    State(state): State<Arc<AppState>>,
1035 -
    Query(q): Query<FlashQuery>,
1036 -
) -> Response {
1037 -
    match db::get_all_files(&state.db) {
1038 -
        Ok(files) => WebTemplate(AdminFilesTemplate {
1039 -
            files,
1040 -
            site_url: state.site_url.clone(),
1041 -
            error: q.error,
1042 -
            success: q.success,
1043 -
        })
1044 -
        .into_response(),
1045 -
        Err(e) => {
1046 -
            tracing::error!("Failed to list files: {}", e);
1047 -
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
1048 -
        }
1049 -
    }
1050 -
}
1051 -
1052 -
async fn admin_upload_file(
1053 -
    _session: auth::AuthSession,
1054 -
    State(state): State<Arc<AppState>>,
1055 -
    mut multipart: Multipart,
1056 -
) -> Response {
1057 -
    let mut file_data: Option<(String, String, Vec<u8>)> = None;
1058 -
1059 -
    while let Ok(Some(field)) = multipart.next_field().await {
1060 -
        if field.name() == Some("file") {
1061 -
            let original_name = field
1062 -
                .file_name()
1063 -
                .unwrap_or("upload")
1064 -
                .to_string();
1065 -
            let content_type = field
1066 -
                .content_type()
1067 -
                .unwrap_or("application/octet-stream")
1068 -
                .to_string();
1069 -
            match field.bytes().await {
1070 -
                Ok(bytes) => {
1071 -
                    file_data = Some((original_name, content_type, bytes.to_vec()));
1072 -
                }
1073 -
                Err(e) => {
1074 -
                    tracing::error!("Failed to read upload: {}", e);
1075 -
                    return Redirect::to("/admin/files?error=Failed+to+read+upload").into_response();
1076 -
                }
1077 -
            }
1078 -
        }
1079 -
    }
1080 -
1081 -
    let (original_name, content_type, bytes) = match file_data {
1082 -
        Some(d) => d,
1083 -
        None => return Redirect::to("/admin/files?error=No+file+provided").into_response(),
1084 -
    };
1085 -
1086 -
    let max_size: usize = 10 * 1024 * 1024;
1087 -
    if bytes.len() > max_size {
1088 -
        return Redirect::to("/admin/files?error=File+exceeds+10MB+limit").into_response();
1089 -
    }
1090 -
1091 -
    let ext = original_name
1092 -
        .rsplit('.')
1093 -
        .next()
1094 -
        .filter(|e| !e.is_empty() && *e != original_name)
1095 -
        .unwrap_or("");
1096 -
    let id = nanoid::nanoid!(10);
1097 -
    let stored_name = if ext.is_empty() {
1098 -
        id
1099 -
    } else {
1100 -
        format!("{}.{}", id, ext)
1101 -
    };
1102 -
1103 -
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
1104 -
    if let Err(e) = tokio::fs::write(&path, &bytes).await {
1105 -
        tracing::error!("Failed to write file: {}", e);
1106 -
        return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
1107 -
    }
1108 -
1109 -
    match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64) {
1110 -
        Ok(_) => Redirect::to("/admin/files?success=true").into_response(),
1111 -
        Err(e) => {
1112 -
            tracing::error!("Failed to record file: {}", e);
1113 -
            let _ = tokio::fs::remove_file(&path).await;
1114 -
            Redirect::to("/admin/files?error=Failed+to+record+file").into_response()
1115 -
        }
1116 -
    }
1117 -
}
1118 -
1119 -
async fn admin_delete_file(
1120 -
    _session: auth::AuthSession,
1121 -
    State(state): State<Arc<AppState>>,
1122 -
    Path(short_id): Path<String>,
1123 -
) -> Response {
1124 -
    match db::delete_file(&state.db, &short_id) {
1125 -
        Ok(Some(file)) => {
1126 -
            let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename);
1127 -
            if let Err(e) = tokio::fs::remove_file(&path).await {
1128 -
                tracing::warn!("Failed to delete file from disk: {}", e);
1129 -
            }
1130 -
            Redirect::to("/admin/files").into_response()
1131 -
        }
1132 -
        Ok(None) => Redirect::to("/admin/files").into_response(),
1133 -
        Err(e) => {
1134 -
            tracing::error!("Failed to delete file: {}", e);
1135 -
            Redirect::to("/admin/files").into_response()
1136 -
        }
1137 -
    }
1138 -
}
1139 -
1140 -
async fn serve_uploaded_file(
1141 -
    State(state): State<Arc<AppState>>,
1142 -
    Path(filename): Path<String>,
1143 -
) -> Response {
1144 -
    if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
1145 -
        return StatusCode::NOT_FOUND.into_response();
1146 -
    }
1147 -
1148 -
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename);
1149 -
    match tokio::fs::read(&path).await {
1150 -
        Ok(bytes) => {
1151 -
            let mime = mime_from_path(&filename);
1152 -
            (
1153 -
                StatusCode::OK,
1154 -
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
1155 -
                bytes,
1156 -
            )
1157 -
                .into_response()
1158 -
        }
1159 -
        Err(_) => StatusCode::NOT_FOUND.into_response(),
1160 -
    }
1161 -
}
1162 -
1163 -
// --- RSS feed handler ---
1164 -
1165 -
fn xml_escape(s: &str) -> String {
1166 -
    s.replace('&', "&amp;")
1167 -
        .replace('<', "&lt;")
1168 -
        .replace('>', "&gt;")
1169 -
        .replace('"', "&quot;")
1170 -
        .replace('\'', "&apos;")
1171 -
}
1172 -
1173 -
async fn rss_feed(State(state): State<Arc<AppState>>) -> Response {
1174 -
    let blog_title = get_blog_title(&state.db);
1175 -
    let blog_description = db::get_setting(&state.db, "blog_description")
1176 -
        .ok()
1177 -
        .flatten()
1178 -
        .unwrap_or_default();
1179 -
    let site_url = &state.site_url;
1180 -
1181 -
    let posts = match db::get_published_posts(&state.db) {
1182 -
        Ok(posts) => posts,
1183 -
        Err(e) => {
1184 -
            tracing::error!("Failed to get posts for RSS: {}", e);
1185 -
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
1186 -
        }
1187 -
    };
1188 -
1189 -
    let mut items = String::new();
1190 -
    for post in &posts {
1191 -
        let link = format!("{}/posts/{}", site_url, xml_escape(&post.slug));
1192 -
        let title = xml_escape(&post.title);
1193 -
        let description = match &post.meta_description {
1194 -
            Some(d) if !d.is_empty() => xml_escape(d),
1195 -
            _ => {
1196 -
                let plain: String = post.content.chars().take(200).collect();
1197 -
                xml_escape(&plain)
1198 -
            }
1199 -
        };
1200 -
        let pub_date = post.published_date.as_deref().unwrap_or(&post.created_at);
1201 -
        let guid = format!("{}/posts/{}", site_url, xml_escape(&post.slug));
1202 -
1203 -
        items.push_str(&format!(
1204 -
            "    <item>\n      <title>{title}</title>\n      <link>{link}</link>\n      <guid>{guid}</guid>\n      <description>{description}</description>\n      <pubDate>{pub_date}</pubDate>\n    </item>\n"
1205 -
        ));
1206 -
    }
1207 -
1208 -
    let last_build = posts
1209 -
        .first()
1210 -
        .and_then(|p| p.published_date.as_deref())
1211 -
        .unwrap_or("");
1212 -
1213 -
    let xml = format!(
1214 -
        r#"<?xml version="1.0" encoding="UTF-8"?>
1215 -
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
1216 -
  <channel>
1217 -
    <title>{title}</title>
1218 -
    <link>{site_url}</link>
1219 -
    <description>{desc}</description>
1220 -
    <lastBuildDate>{last_build}</lastBuildDate>
1221 -
    <atom:link href="{site_url}/feed.xml" rel="self" type="application/rss+xml"/>
1222 -
{items}  </channel>
1223 -
</rss>"#,
1224 -
        title = xml_escape(&blog_title),
1225 -
        site_url = site_url,
1226 -
        desc = xml_escape(&blog_description),
1227 -
        last_build = last_build,
1228 -
        items = items,
1229 -
    );
1230 -
1231 -
    (
1232 -
        StatusCode::OK,
1233 -
        [(
1234 -
            axum::http::header::CONTENT_TYPE,
1235 -
            HeaderValue::from_static("application/rss+xml; charset=utf-8"),
1236 -
        )],
1237 -
        xml,
1238 -
    )
1239 -
        .into_response()
1240 -
}
1241 -
1242 -
// --- Download/export handlers ---
1243 -
1244 -
async fn admin_download_posts(
1245 -
    _session: auth::AuthSession,
1246 -
    State(state): State<Arc<AppState>>,
1247 -
) -> Response {
1248 -
    let posts = match db::get_all_posts(&state.db) {
1249 -
        Ok(posts) => posts,
1250 -
        Err(e) => {
1251 -
            tracing::error!("Failed to get posts for export: {}", e);
1252 -
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
1253 -
        }
1254 -
    };
1255 -
1256 -
    let result = tokio::task::spawn_blocking(move || {
1257 -
        let mut buf = std::io::Cursor::new(Vec::new());
1258 -
        {
1259 -
            let mut zip = zip::ZipWriter::new(&mut buf);
1260 -
            let options = zip::write::SimpleFileOptions::default()
1261 -
                .compression_method(zip::CompressionMethod::Deflated);
1262 -
            for post in &posts {
1263 -
                let filename = format!("{}.md", post.slug);
1264 -
                let mut frontmatter = format!(
1265 -
                    "---\ntitle: {}\nslug: {}\nstatus: {}",
1266 -
                    post.title, post.slug, post.status
1267 -
                );
1268 -
                if let Some(ref pd) = post.published_date {
1269 -
                    frontmatter.push_str(&format!("\npublished_date: {}", pd));
1270 -
                }
1271 -
                if let Some(ref tags) = post.tags {
1272 -
                    frontmatter.push_str(&format!("\ntags: {}", tags));
1273 -
                }
1274 -
                frontmatter.push_str(&format!("\nlang: {}", post.lang));
1275 -
                if let Some(ref alias) = post.alias {
1276 -
                    frontmatter.push_str(&format!("\nalias: {}", alias));
1277 -
                }
1278 -
                if let Some(ref meta_image) = post.meta_image {
1279 -
                    frontmatter.push_str(&format!("\nmeta_image: {}", meta_image));
1280 -
                }
1281 -
                if let Some(ref meta_desc) = post.meta_description {
1282 -
                    frontmatter.push_str(&format!("\ndescription: {}", meta_desc));
1283 -
                }
1284 -
                frontmatter.push_str("\n---\n\n");
1285 -
                let content = format!("{}{}", frontmatter, post.content);
1286 -
                if let Err(e) = zip.start_file(&filename, options) {
1287 -
                    tracing::warn!("Failed to add {} to zip: {}", filename, e);
1288 -
                    continue;
1289 -
                }
1290 -
                if let Err(e) = std::io::Write::write_all(&mut zip, content.as_bytes()) {
1291 -
                    tracing::warn!("Failed to write {} to zip: {}", filename, e);
1292 -
                }
1293 -
            }
1294 -
            let _ = zip.finish();
1295 -
        }
1296 -
        buf.into_inner()
1297 -
    })
1298 -
    .await;
1299 -
1300 -
    match result {
1301 -
        Ok(bytes) => (
1302 -
            StatusCode::OK,
1303 -
            [
1304 -
                (axum::http::header::CONTENT_TYPE, "application/zip"),
1305 -
                (
1306 -
                    axum::http::header::CONTENT_DISPOSITION,
1307 -
                    "attachment; filename=\"posts.zip\"",
1308 -
                ),
1309 -
            ],
1310 -
            bytes,
1311 -
        )
1312 -
            .into_response(),
1313 -
        Err(e) => {
1314 -
            tracing::error!("Failed to create posts zip: {}", e);
1315 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
1316 -
        }
1317 -
    }
1318 -
}
1319 -
1320 -
async fn admin_download_uploads(
1321 -
    _session: auth::AuthSession,
1322 -
    State(state): State<Arc<AppState>>,
1323 -
) -> Response {
1324 -
    let files = match db::get_all_files(&state.db) {
1325 -
        Ok(files) => files,
1326 -
        Err(e) => {
1327 -
            tracing::error!("Failed to get files for export: {}", e);
1328 -
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
1329 -
        }
1330 -
    };
1331 -
1332 -
    let uploads_dir = state.uploads_dir.clone();
1333 -
    let mut file_data: Vec<(String, Vec<u8>)> = Vec::new();
1334 -
    let mut seen_names = std::collections::HashSet::new();
1335 -
    for file in &files {
1336 -
        let path = std::path::PathBuf::from(&uploads_dir).join(&file.filename);
1337 -
        match tokio::fs::read(&path).await {
1338 -
            Ok(bytes) => {
1339 -
                let name = if seen_names.contains(&file.original_name) {
1340 -
                    format!("{}_{}", file.short_id, file.original_name)
1341 -
                } else {
1342 -
                    file.original_name.clone()
1343 -
                };
1344 -
                seen_names.insert(file.original_name.clone());
1345 -
                file_data.push((name, bytes));
1346 -
            }
1347 -
            Err(e) => {
1348 -
                tracing::warn!("Skipping file {} ({}): {}", file.original_name, file.filename, e);
1349 -
            }
1350 -
        }
1351 -
    }
1352 -
1353 -
    let result = tokio::task::spawn_blocking(move || {
1354 -
        let mut buf = std::io::Cursor::new(Vec::new());
1355 -
        {
1356 -
            let mut zip = zip::ZipWriter::new(&mut buf);
1357 -
            let options = zip::write::SimpleFileOptions::default()
1358 -
                .compression_method(zip::CompressionMethod::Stored);
1359 -
            for (name, bytes) in &file_data {
1360 -
                if let Err(e) = zip.start_file(name, options) {
1361 -
                    tracing::warn!("Failed to add {} to zip: {}", name, e);
1362 -
                    continue;
1363 -
                }
1364 -
                if let Err(e) = std::io::Write::write_all(&mut zip, bytes) {
1365 -
                    tracing::warn!("Failed to write {} to zip: {}", name, e);
1366 -
                }
1367 -
            }
1368 -
            let _ = zip.finish();
1369 -
        }
1370 -
        buf.into_inner()
1371 -
    })
1372 -
    .await;
1373 -
1374 -
    match result {
1375 -
        Ok(bytes) => (
1376 -
            StatusCode::OK,
1377 -
            [
1378 -
                (axum::http::header::CONTENT_TYPE, "application/zip"),
1379 -
                (
1380 -
                    axum::http::header::CONTENT_DISPOSITION,
1381 -
                    "attachment; filename=\"uploads.zip\"",
1382 -
                ),
1383 -
            ],
1384 -
            bytes,
1385 -
        )
1386 -
            .into_response(),
1387 -
        Err(e) => {
1388 -
            tracing::error!("Failed to create uploads zip: {}", e);
1389 -
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
1390 -
        }
1391 -
    }
1392 -
}
1393 -
1394 -
// --- Date helper ---
1395 -
1396 -
fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
1397 -
    days += 719468;
1398 -
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
1399 -
    let doe = (days - era * 146097) as u32;
1400 -
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1401 -
    let y = yoe as i64 + era * 400;
1402 -
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1403 -
    let mp = (5 * doy + 2) / 153;
1404 -
    let d = doy - (153 * mp + 2) / 5 + 1;
1405 -
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1406 -
    let y = if m <= 2 { y + 1 } else { y };
1407 -
    (y, m as i64, d as i64)
1408 -
}
1409 -
1410 -
// --- Router ---
1411 -
1412 -
pub async fn run(host: String, port: u16) {
1413 -
    dotenvy::dotenv().ok();
1414 -
1415 -
    let db = db::init_db();
1416 -
1417 -
    if let Err(e) = db::prune_expired_sessions(&db) {
1418 -
        tracing::warn!("Failed to prune sessions: {}", e);
1419 -
    }
1420 -
1421 -
    let app_password = std::env::var("POSTS_PASSWORD").unwrap_or_else(|_| {
1422 -
        tracing::warn!("POSTS_PASSWORD not set, using default 'changeme'");
1423 -
        "changeme".to_string()
1424 -
    });
1425 -
1426 -
    let cookie_secure = std::env::var("COOKIE_SECURE")
1427 -
        .map(|v| v == "true")
1428 -
        .unwrap_or(false);
1429 -
1430 -
    let uploads_dir = std::env::var("UPLOADS_DIR").unwrap_or_else(|_| "uploads".to_string());
1431 -
    tokio::fs::create_dir_all(&uploads_dir)
1432 -
        .await
1433 -
        .expect("Failed to create uploads directory");
1434 -
1435 -
    let site_url = std::env::var("SITE_URL")
1436 -
        .unwrap_or_else(|_| "http://localhost:3000".to_string())
1437 -
        .trim_end_matches('/')
1438 -
        .to_string();
1439 -
1440 -
    let state = Arc::new(AppState {
1441 -
        db,
1442 -
        app_password,
1443 -
        cookie_secure,
1444 -
        uploads_dir,
1445 -
        site_url,
1446 -
    });
1447 -
1448 -
    let app = Router::new()
1449 -
        // Public routes
1450 -
        .route("/", get(public_index))
1451 -
        .route("/posts", get(public_posts_list))
1452 -
        .route("/posts/{slug}", get(public_post))
1453 -
        .route("/custom-styles.css", get(serve_custom_css))
1454 -
        .route("/{slug}", get(public_page))
1455 -
        .route("/feed.xml", get(rss_feed))
1456 -
        // Admin auth
1457 -
        .route("/admin/login", get(get_login).post(post_login))
1458 -
        .route("/admin/logout", get(get_logout))
1459 -
        // Admin posts
1460 -
        .route("/admin", get(admin_index))
1461 -
        .route("/admin/posts/new", get(admin_new_post))
1462 -
        .route("/admin/posts", post(admin_create_post))
1463 -
        .route("/admin/posts/{id}/edit", get(admin_edit_post))
1464 -
        .route("/admin/posts/{id}", post(admin_update_post))
1465 -
        .route("/admin/posts/{id}/delete", post(admin_delete_post))
1466 -
        .route("/admin/posts/{id}/publish", post(admin_toggle_publish))
1467 -
        // Admin pages
1468 -
        .route("/admin/pages", get(admin_pages))
1469 -
        .route("/admin/pages/new", get(admin_new_page))
1470 -
        .route("/admin/pages/create", post(admin_create_page))
1471 -
        .route("/admin/pages/{id}/edit", get(admin_edit_page))
1472 -
        .route("/admin/pages/{id}", post(admin_update_page))
1473 -
        .route("/admin/pages/{id}/delete", post(admin_delete_page))
1474 -
        // Admin settings
1475 -
        .route("/admin/settings", get(admin_get_settings).post(admin_post_settings))
1476 -
        // Admin downloads
1477 -
        .route("/admin/downloads/posts", get(admin_download_posts))
1478 -
        .route("/admin/downloads/uploads", get(admin_download_uploads))
1479 -
        // Admin files
1480 -
        .route("/admin/files", get(admin_files))
1481 -
        .route("/admin/files/upload", post(admin_upload_file))
1482 -
        .route("/admin/files/{id}/delete", post(admin_delete_file))
1483 -
        // Public files
1484 -
        .route("/files/{filename}", get(serve_uploaded_file))
1485 -
        // Static assets
1486 -
        .route("/static/{*path}", get(serve_static))
1487 -
        // Fallback
1488 -
        .fallback(get(fallback_handler))
1489 -
        .with_state(state)
1490 -
        .layer(DefaultBodyLimit::max(11 * 1024 * 1024));
1491 -
1492 -
    let addr = format!("{}:{}", host, port);
1493 -
    tracing::info!("Listening on http://{}", addr);
1494 -
1495 -
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
1496 -
    axum::serve(listener, app).await.unwrap();
1497 -
}
apps/posts/src/server/handlers/admin.rs (added) +680 −0
1 +
use askama_web::WebTemplate;
2 +
use axum::{
3 +
    extract::{Form, Multipart, Path, Query, State},
4 +
    http::{HeaderValue, StatusCode},
5 +
    response::{Html, IntoResponse, Redirect, Response},
6 +
};
7 +
use std::sync::Arc;
8 +
9 +
use super::super::*;
10 +
use crate::{auth, db};
11 +
12 +
// --- Auth handlers ---
13 +
14 +
pub async fn get_login(Query(q): Query<FlashQuery>) -> Response {
15 +
    WebTemplate(LoginTemplate { error: q.error }).into_response()
16 +
}
17 +
18 +
pub async fn post_login(
19 +
    State(state): State<Arc<AppState>>,
20 +
    Form(form): Form<LoginForm>,
21 +
) -> Response {
22 +
    if !auth::verify_password(&form.password, &state.app_password) {
23 +
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
24 +
    }
25 +
26 +
    let token = auth::generate_session_token();
27 +
28 +
    let expires_at = andromeda_auth::datetime::expiry_datetime_string(7 * 24 * 3600);
29 +
30 +
    if let Err(e) = db::insert_session(&state.db, &token, &expires_at) {
31 +
        tracing::error!("Failed to create session: {}", e);
32 +
        return Redirect::to("/admin/login?error=Server+error").into_response();
33 +
    }
34 +
35 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
36 +
    let mut resp = Redirect::to("/admin").into_response();
37 +
    resp.headers_mut().insert(
38 +
        axum::http::header::SET_COOKIE,
39 +
        HeaderValue::from_str(&cookie).unwrap(),
40 +
    );
41 +
    resp
42 +
}
43 +
44 +
pub async fn get_logout(
45 +
    State(state): State<Arc<AppState>>,
46 +
    headers: axum::http::HeaderMap,
47 +
) -> Response {
48 +
    if let Some(cookie_header) = headers.get("cookie").and_then(|v| v.to_str().ok()) {
49 +
        for part in cookie_header.split(';') {
50 +
            let part = part.trim();
51 +
            if let Some(val) = part.strip_prefix("session=") {
52 +
                let val = val.trim();
53 +
                if !val.is_empty() {
54 +
                    let _ = db::delete_session(&state.db, val);
55 +
                }
56 +
            }
57 +
        }
58 +
    }
59 +
60 +
    let cookie = auth::clear_session_cookie();
61 +
    let mut resp = Redirect::to("/admin/login").into_response();
62 +
    resp.headers_mut().insert(
63 +
        axum::http::header::SET_COOKIE,
64 +
        HeaderValue::from_str(&cookie).unwrap(),
65 +
    );
66 +
    resp
67 +
}
68 +
69 +
// --- Admin post handlers ---
70 +
71 +
pub async fn admin_index(
72 +
    _session: auth::AuthSession,
73 +
    State(state): State<Arc<AppState>>,
74 +
) -> Response {
75 +
    match db::get_all_posts(&state.db) {
76 +
        Ok(posts) => WebTemplate(AdminIndexTemplate { posts }).into_response(),
77 +
        Err(e) => {
78 +
            tracing::error!("Failed to list posts: {}", e);
79 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
80 +
        }
81 +
    }
82 +
}
83 +
84 +
pub async fn admin_new_post(
85 +
    _session: auth::AuthSession,
86 +
    Query(q): Query<FlashQuery>,
87 +
) -> Response {
88 +
    WebTemplate(AdminPostFormTemplate {
89 +
        post: None,
90 +
        error: q.error,
91 +
    })
92 +
    .into_response()
93 +
}
94 +
95 +
pub async fn admin_create_post(
96 +
    _session: auth::AuthSession,
97 +
    State(state): State<Arc<AppState>>,
98 +
    Form(form): Form<PostForm>,
99 +
) -> Response {
100 +
    let attrs = parse_attributes(&form.attributes);
101 +
    let title = attrs.title.trim();
102 +
    if title.is_empty() {
103 +
        return Redirect::to("/admin/posts/new?error=Title+is+required").into_response();
104 +
    }
105 +
    let slug = if attrs.slug.trim().is_empty() {
106 +
        slugify(title)
107 +
    } else {
108 +
        attrs.slug.trim().to_string()
109 +
    };
110 +
111 +
    let status = if form.action == "publish" { "published" } else { "draft" };
112 +
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
113 +
    let published_date = if attrs.published_date.trim().is_empty() {
114 +
        now_datetime()
115 +
    } else {
116 +
        attrs.published_date.trim().to_string()
117 +
    };
118 +
119 +
    match db::create_post(
120 +
        &state.db,
121 +
        title,
122 +
        &slug,
123 +
        &form.content,
124 +
        status,
125 +
        opt_str(&attrs.alias),
126 +
        None,
127 +
        Some(&published_date),
128 +
        opt_str(&attrs.meta_description),
129 +
        opt_str(&attrs.meta_image),
130 +
        lang,
131 +
        opt_str(&attrs.tags),
132 +
    ) {
133 +
        Ok(_) => Redirect::to("/admin").into_response(),
134 +
        Err(e) => {
135 +
            tracing::error!("Failed to create post: {}", e);
136 +
            Redirect::to("/admin/posts/new?error=Failed+to+create+post").into_response()
137 +
        }
138 +
    }
139 +
}
140 +
141 +
pub async fn admin_edit_post(
142 +
    _session: auth::AuthSession,
143 +
    State(state): State<Arc<AppState>>,
144 +
    Path(short_id): Path<String>,
145 +
    Query(q): Query<FlashQuery>,
146 +
) -> Response {
147 +
    match db::get_post_by_short_id(&state.db, &short_id) {
148 +
        Ok(Some(post)) => WebTemplate(AdminPostFormTemplate {
149 +
            post: Some(post),
150 +
            error: q.error,
151 +
        })
152 +
        .into_response(),
153 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
154 +
        Err(e) => {
155 +
            tracing::error!("Failed to get post: {}", e);
156 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
157 +
        }
158 +
    }
159 +
}
160 +
161 +
pub async fn admin_update_post(
162 +
    _session: auth::AuthSession,
163 +
    State(state): State<Arc<AppState>>,
164 +
    Path(short_id): Path<String>,
165 +
    Form(form): Form<PostForm>,
166 +
) -> Response {
167 +
    let attrs = parse_attributes(&form.attributes);
168 +
    let title = attrs.title.trim();
169 +
    if title.is_empty() {
170 +
        return Redirect::to(&format!("/admin/posts/{}/edit?error=Title+is+required", short_id))
171 +
            .into_response();
172 +
    }
173 +
    let slug = if attrs.slug.trim().is_empty() {
174 +
        slugify(title)
175 +
    } else {
176 +
        attrs.slug.trim().to_string()
177 +
    };
178 +
179 +
    let status = if form.action == "publish" { "published" } else { "draft" };
180 +
    let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() };
181 +
    let published_date = if attrs.published_date.trim().is_empty() {
182 +
        None
183 +
    } else {
184 +
        Some(attrs.published_date.trim().to_string())
185 +
    };
186 +
187 +
    match db::update_post(
188 +
        &state.db,
189 +
        &short_id,
190 +
        title,
191 +
        &slug,
192 +
        &form.content,
193 +
        status,
194 +
        opt_str(&attrs.alias),
195 +
        None,
196 +
        published_date.as_deref(),
197 +
        opt_str(&attrs.meta_description),
198 +
        opt_str(&attrs.meta_image),
199 +
        lang,
200 +
        opt_str(&attrs.tags),
201 +
    ) {
202 +
        Ok(Some(_)) => Redirect::to("/admin").into_response(),
203 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Post not found".to_string())).into_response(),
204 +
        Err(e) => {
205 +
            tracing::error!("Failed to update post: {}", e);
206 +
            Redirect::to(&format!("/admin/posts/{}/edit?error=Failed+to+update", short_id))
207 +
                .into_response()
208 +
        }
209 +
    }
210 +
}
211 +
212 +
pub async fn admin_delete_post(
213 +
    _session: auth::AuthSession,
214 +
    State(state): State<Arc<AppState>>,
215 +
    Path(short_id): Path<String>,
216 +
) -> Response {
217 +
    match db::delete_post(&state.db, &short_id) {
218 +
        Ok(_) => Redirect::to("/admin").into_response(),
219 +
        Err(e) => {
220 +
            tracing::error!("Failed to delete post: {}", e);
221 +
            Redirect::to("/admin").into_response()
222 +
        }
223 +
    }
224 +
}
225 +
226 +
pub async fn admin_toggle_publish(
227 +
    _session: auth::AuthSession,
228 +
    State(state): State<Arc<AppState>>,
229 +
    Path(short_id): Path<String>,
230 +
) -> Response {
231 +
    match db::toggle_post_status(&state.db, &short_id) {
232 +
        Ok(_) => Redirect::to("/admin").into_response(),
233 +
        Err(e) => {
234 +
            tracing::error!("Failed to toggle post status: {}", e);
235 +
            Redirect::to("/admin").into_response()
236 +
        }
237 +
    }
238 +
}
239 +
240 +
// --- Admin page handlers ---
241 +
242 +
pub async fn admin_pages(
243 +
    _session: auth::AuthSession,
244 +
    State(state): State<Arc<AppState>>,
245 +
) -> Response {
246 +
    match db::get_all_pages(&state.db) {
247 +
        Ok(pages) => WebTemplate(AdminPagesTemplate { pages }).into_response(),
248 +
        Err(e) => {
249 +
            tracing::error!("Failed to list pages: {}", e);
250 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
251 +
        }
252 +
    }
253 +
}
254 +
255 +
pub async fn admin_new_page(
256 +
    _session: auth::AuthSession,
257 +
    Query(q): Query<FlashQuery>,
258 +
) -> Response {
259 +
    WebTemplate(AdminPageFormTemplate {
260 +
        page: None,
261 +
        error: q.error,
262 +
    })
263 +
    .into_response()
264 +
}
265 +
266 +
const RESERVED_PAGE_SLUGS: &[&str] = &[
267 +
    "posts", "admin", "feed.xml", "custom-styles.css", "static", "files",
268 +
];
269 +
270 +
fn is_reserved_page_slug(slug: &str) -> bool {
271 +
    RESERVED_PAGE_SLUGS.contains(&slug)
272 +
}
273 +
274 +
pub async fn admin_create_page(
275 +
    _session: auth::AuthSession,
276 +
    State(state): State<Arc<AppState>>,
277 +
    Form(form): Form<PageForm>,
278 +
) -> Response {
279 +
    let attrs = parse_page_attributes(&form.attributes);
280 +
    let title = attrs.title.trim().to_string();
281 +
    let slug = attrs.slug.trim().to_string();
282 +
    if title.is_empty() || slug.is_empty() {
283 +
        return Redirect::to("/admin/pages/new?error=Title+and+slug+are+required").into_response();
284 +
    }
285 +
    if is_reserved_page_slug(&slug) {
286 +
        return Redirect::to("/admin/pages/new?error=That+slug+is+reserved").into_response();
287 +
    }
288 +
289 +
    match db::create_page(&state.db, &title, &slug, &form.content, attrs.is_published, 0) {
290 +
        Ok(_) => Redirect::to("/admin/pages").into_response(),
291 +
        Err(e) => {
292 +
            tracing::error!("Failed to create page: {}", e);
293 +
            Redirect::to("/admin/pages/new?error=Failed+to+create+page").into_response()
294 +
        }
295 +
    }
296 +
}
297 +
298 +
pub async fn admin_edit_page(
299 +
    _session: auth::AuthSession,
300 +
    State(state): State<Arc<AppState>>,
301 +
    Path(short_id): Path<String>,
302 +
    Query(q): Query<FlashQuery>,
303 +
) -> Response {
304 +
    match db::get_page_by_short_id(&state.db, &short_id) {
305 +
        Ok(Some(page)) => WebTemplate(AdminPageFormTemplate {
306 +
            page: Some(page),
307 +
            error: q.error,
308 +
        })
309 +
        .into_response(),
310 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
311 +
        Err(e) => {
312 +
            tracing::error!("Failed to get page: {}", e);
313 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
314 +
        }
315 +
    }
316 +
}
317 +
318 +
pub async fn admin_update_page(
319 +
    _session: auth::AuthSession,
320 +
    State(state): State<Arc<AppState>>,
321 +
    Path(short_id): Path<String>,
322 +
    Form(form): Form<PageForm>,
323 +
) -> Response {
324 +
    let attrs = parse_page_attributes(&form.attributes);
325 +
    let title = attrs.title.trim().to_string();
326 +
    let slug = attrs.slug.trim().to_string();
327 +
    if title.is_empty() || slug.is_empty() {
328 +
        return Redirect::to(&format!(
329 +
            "/admin/pages/{}/edit?error=Title+and+slug+are+required",
330 +
            short_id
331 +
        ))
332 +
        .into_response();
333 +
    }
334 +
    if is_reserved_page_slug(&slug) {
335 +
        return Redirect::to(&format!(
336 +
            "/admin/pages/{}/edit?error=That+slug+is+reserved",
337 +
            short_id
338 +
        ))
339 +
        .into_response();
340 +
    }
341 +
342 +
    match db::update_page(&state.db, &short_id, &title, &slug, &form.content, attrs.is_published, 0) {
343 +
        Ok(Some(_)) => Redirect::to("/admin/pages").into_response(),
344 +
        Ok(None) => (StatusCode::NOT_FOUND, Html("Page not found".to_string())).into_response(),
345 +
        Err(e) => {
346 +
            tracing::error!("Failed to update page: {}", e);
347 +
            Redirect::to(&format!("/admin/pages/{}/edit?error=Failed+to+update", short_id))
348 +
                .into_response()
349 +
        }
350 +
    }
351 +
}
352 +
353 +
pub async fn admin_delete_page(
354 +
    _session: auth::AuthSession,
355 +
    State(state): State<Arc<AppState>>,
356 +
    Path(short_id): Path<String>,
357 +
) -> Response {
358 +
    match db::delete_page(&state.db, &short_id) {
359 +
        Ok(_) => Redirect::to("/admin/pages").into_response(),
360 +
        Err(e) => {
361 +
            tracing::error!("Failed to delete page: {}", e);
362 +
            Redirect::to("/admin/pages").into_response()
363 +
        }
364 +
    }
365 +
}
366 +
367 +
// --- Admin settings handlers ---
368 +
369 +
pub async fn admin_get_settings(
370 +
    _session: auth::AuthSession,
371 +
    State(state): State<Arc<AppState>>,
372 +
    Query(q): Query<FlashQuery>,
373 +
) -> Response {
374 +
    let blog_title = db::get_setting(&state.db, "blog_title").ok().flatten().unwrap_or_default();
375 +
    let blog_description = db::get_setting(&state.db, "blog_description").ok().flatten().unwrap_or_default();
376 +
    let intro_content = db::get_setting(&state.db, "intro_content").ok().flatten().unwrap_or_default();
377 +
    let nav_links = db::get_setting(&state.db, "nav_links").ok().flatten().unwrap_or_default();
378 +
    let custom_css = db::get_setting(&state.db, "custom_css").ok().flatten().unwrap_or_default();
379 +
    let favicon_url = db::get_setting(&state.db, "favicon_url").ok().flatten().unwrap_or_default();
380 +
    let og_image_url = db::get_setting(&state.db, "og_image_url").ok().flatten().unwrap_or_default();
381 +
    let custom_header = db::get_setting(&state.db, "custom_header").ok().flatten().unwrap_or_default();
382 +
    let custom_footer = db::get_setting(&state.db, "custom_footer").ok().flatten().unwrap_or_default();
383 +
    let default_css = Static::get("styles.css")
384 +
        .map(|f| String::from_utf8_lossy(&f.data).into_owned())
385 +
        .unwrap_or_default();
386 +
387 +
    WebTemplate(AdminSettingsTemplate {
388 +
        blog_title,
389 +
        blog_description,
390 +
        intro_content,
391 +
        nav_links,
392 +
        custom_css,
393 +
        default_css,
394 +
        favicon_url,
395 +
        og_image_url,
396 +
        custom_header,
397 +
        custom_footer,
398 +
        success: q.success,
399 +
    })
400 +
    .into_response()
401 +
}
402 +
403 +
pub async fn admin_post_settings(
404 +
    _session: auth::AuthSession,
405 +
    State(state): State<Arc<AppState>>,
406 +
    Form(form): Form<SettingsForm>,
407 +
) -> Response {
408 +
    let _ = db::set_setting(&state.db, "blog_title", form.blog_title.trim());
409 +
    let _ = db::set_setting(&state.db, "blog_description", form.blog_description.trim());
410 +
    let _ = db::set_setting(&state.db, "intro_content", &form.intro_content);
411 +
    let _ = db::set_setting(&state.db, "nav_links", &form.nav_links);
412 +
    let _ = db::set_setting(&state.db, "custom_css", &form.custom_css);
413 +
    let _ = db::set_setting(&state.db, "favicon_url", form.favicon_url.trim());
414 +
    let _ = db::set_setting(&state.db, "og_image_url", form.og_image_url.trim());
415 +
    let _ = db::set_setting(&state.db, "custom_header", &form.custom_header);
416 +
    let _ = db::set_setting(&state.db, "custom_footer", &form.custom_footer);
417 +
    Redirect::to("/admin/settings?success=true").into_response()
418 +
}
419 +
420 +
// --- Admin file handlers ---
421 +
422 +
pub async fn admin_files(
423 +
    _session: auth::AuthSession,
424 +
    State(state): State<Arc<AppState>>,
425 +
    Query(q): Query<FlashQuery>,
426 +
) -> Response {
427 +
    match db::get_all_files(&state.db) {
428 +
        Ok(files) => WebTemplate(AdminFilesTemplate {
429 +
            files,
430 +
            site_url: state.site_url.clone(),
431 +
            error: q.error,
432 +
            success: q.success,
433 +
        })
434 +
        .into_response(),
435 +
        Err(e) => {
436 +
            tracing::error!("Failed to list files: {}", e);
437 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
438 +
        }
439 +
    }
440 +
}
441 +
442 +
pub async fn admin_upload_file(
443 +
    _session: auth::AuthSession,
444 +
    State(state): State<Arc<AppState>>,
445 +
    mut multipart: Multipart,
446 +
) -> Response {
447 +
    let mut file_data: Option<(String, String, Vec<u8>)> = None;
448 +
449 +
    while let Ok(Some(field)) = multipart.next_field().await {
450 +
        if field.name() == Some("file") {
451 +
            let original_name = field
452 +
                .file_name()
453 +
                .unwrap_or("upload")
454 +
                .to_string();
455 +
            let content_type = field
456 +
                .content_type()
457 +
                .unwrap_or("application/octet-stream")
458 +
                .to_string();
459 +
            match field.bytes().await {
460 +
                Ok(bytes) => {
461 +
                    file_data = Some((original_name, content_type, bytes.to_vec()));
462 +
                }
463 +
                Err(e) => {
464 +
                    tracing::error!("Failed to read upload: {}", e);
465 +
                    return Redirect::to("/admin/files?error=Failed+to+read+upload").into_response();
466 +
                }
467 +
            }
468 +
        }
469 +
    }
470 +
471 +
    let (original_name, content_type, bytes) = match file_data {
472 +
        Some(d) => d,
473 +
        None => return Redirect::to("/admin/files?error=No+file+provided").into_response(),
474 +
    };
475 +
476 +
    let max_size: usize = 10 * 1024 * 1024;
477 +
    if bytes.len() > max_size {
478 +
        return Redirect::to("/admin/files?error=File+exceeds+10MB+limit").into_response();
479 +
    }
480 +
481 +
    let ext = original_name
482 +
        .rsplit('.')
483 +
        .next()
484 +
        .filter(|e| !e.is_empty() && *e != original_name)
485 +
        .unwrap_or("");
486 +
    let id = nanoid::nanoid!(10);
487 +
    let stored_name = if ext.is_empty() {
488 +
        id
489 +
    } else {
490 +
        format!("{}.{}", id, ext)
491 +
    };
492 +
493 +
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&stored_name);
494 +
    if let Err(e) = tokio::fs::write(&path, &bytes).await {
495 +
        tracing::error!("Failed to write file: {}", e);
496 +
        return Redirect::to("/admin/files?error=Failed+to+save+file").into_response();
497 +
    }
498 +
499 +
    match db::create_file(&state.db, &stored_name, &original_name, &content_type, bytes.len() as i64) {
500 +
        Ok(_) => Redirect::to("/admin/files?success=true").into_response(),
501 +
        Err(e) => {
502 +
            tracing::error!("Failed to record file: {}", e);
503 +
            let _ = tokio::fs::remove_file(&path).await;
504 +
            Redirect::to("/admin/files?error=Failed+to+record+file").into_response()
505 +
        }
506 +
    }
507 +
}
508 +
509 +
pub async fn admin_delete_file(
510 +
    _session: auth::AuthSession,
511 +
    State(state): State<Arc<AppState>>,
512 +
    Path(short_id): Path<String>,
513 +
) -> Response {
514 +
    match db::delete_file(&state.db, &short_id) {
515 +
        Ok(Some(file)) => {
516 +
            let path = std::path::PathBuf::from(&state.uploads_dir).join(&file.filename);
517 +
            if let Err(e) = tokio::fs::remove_file(&path).await {
518 +
                tracing::warn!("Failed to delete file from disk: {}", e);
519 +
            }
520 +
            Redirect::to("/admin/files").into_response()
521 +
        }
522 +
        Ok(None) => Redirect::to("/admin/files").into_response(),
523 +
        Err(e) => {
524 +
            tracing::error!("Failed to delete file: {}", e);
525 +
            Redirect::to("/admin/files").into_response()
526 +
        }
527 +
    }
528 +
}
529 +
530 +
// --- Download/export handlers ---
531 +
532 +
pub async fn admin_download_posts(
533 +
    _session: auth::AuthSession,
534 +
    State(state): State<Arc<AppState>>,
535 +
) -> Response {
536 +
    let posts = match db::get_all_posts(&state.db) {
537 +
        Ok(posts) => posts,
538 +
        Err(e) => {
539 +
            tracing::error!("Failed to get posts for export: {}", e);
540 +
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
541 +
        }
542 +
    };
543 +
544 +
    let result = tokio::task::spawn_blocking(move || {
545 +
        let mut buf = std::io::Cursor::new(Vec::new());
546 +
        {
547 +
            let mut zip = zip::ZipWriter::new(&mut buf);
548 +
            let options = zip::write::SimpleFileOptions::default()
549 +
                .compression_method(zip::CompressionMethod::Deflated);
550 +
            for post in &posts {
551 +
                let filename = format!("{}.md", post.slug);
552 +
                let mut frontmatter = format!(
553 +
                    "---\ntitle: {}\nslug: {}\nstatus: {}",
554 +
                    post.title, post.slug, post.status
555 +
                );
556 +
                if let Some(ref pd) = post.published_date {
557 +
                    frontmatter.push_str(&format!("\npublished_date: {}", pd));
558 +
                }
559 +
                if let Some(ref tags) = post.tags {
560 +
                    frontmatter.push_str(&format!("\ntags: {}", tags));
561 +
                }
562 +
                frontmatter.push_str(&format!("\nlang: {}", post.lang));
563 +
                if let Some(ref alias) = post.alias {
564 +
                    frontmatter.push_str(&format!("\nalias: {}", alias));
565 +
                }
566 +
                if let Some(ref meta_image) = post.meta_image {
567 +
                    frontmatter.push_str(&format!("\nmeta_image: {}", meta_image));
568 +
                }
569 +
                if let Some(ref meta_desc) = post.meta_description {
570 +
                    frontmatter.push_str(&format!("\ndescription: {}", meta_desc));
571 +
                }
572 +
                frontmatter.push_str("\n---\n\n");
573 +
                let content = format!("{}{}", frontmatter, post.content);
574 +
                if let Err(e) = zip.start_file(&filename, options) {
575 +
                    tracing::warn!("Failed to add {} to zip: {}", filename, e);
576 +
                    continue;
577 +
                }
578 +
                if let Err(e) = std::io::Write::write_all(&mut zip, content.as_bytes()) {
579 +
                    tracing::warn!("Failed to write {} to zip: {}", filename, e);
580 +
                }
581 +
            }
582 +
            let _ = zip.finish();
583 +
        }
584 +
        buf.into_inner()
585 +
    })
586 +
    .await;
587 +
588 +
    match result {
589 +
        Ok(bytes) => (
590 +
            StatusCode::OK,
591 +
            [
592 +
                (axum::http::header::CONTENT_TYPE, "application/zip"),
593 +
                (
594 +
                    axum::http::header::CONTENT_DISPOSITION,
595 +
                    "attachment; filename=\"posts.zip\"",
596 +
                ),
597 +
            ],
598 +
            bytes,
599 +
        )
600 +
            .into_response(),
601 +
        Err(e) => {
602 +
            tracing::error!("Failed to create posts zip: {}", e);
603 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
604 +
        }
605 +
    }
606 +
}
607 +
608 +
pub async fn admin_download_uploads(
609 +
    _session: auth::AuthSession,
610 +
    State(state): State<Arc<AppState>>,
611 +
) -> Response {
612 +
    let files = match db::get_all_files(&state.db) {
613 +
        Ok(files) => files,
614 +
        Err(e) => {
615 +
            tracing::error!("Failed to get files for export: {}", e);
616 +
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
617 +
        }
618 +
    };
619 +
620 +
    let uploads_dir = state.uploads_dir.clone();
621 +
    let mut file_data: Vec<(String, Vec<u8>)> = Vec::new();
622 +
    let mut seen_names = std::collections::HashSet::new();
623 +
    for file in &files {
624 +
        let path = std::path::PathBuf::from(&uploads_dir).join(&file.filename);
625 +
        match tokio::fs::read(&path).await {
626 +
            Ok(bytes) => {
627 +
                let name = if seen_names.contains(&file.original_name) {
628 +
                    format!("{}_{}", file.short_id, file.original_name)
629 +
                } else {
630 +
                    file.original_name.clone()
631 +
                };
632 +
                seen_names.insert(file.original_name.clone());
633 +
                file_data.push((name, bytes));
634 +
            }
635 +
            Err(e) => {
636 +
                tracing::warn!("Skipping file {} ({}): {}", file.original_name, file.filename, e);
637 +
            }
638 +
        }
639 +
    }
640 +
641 +
    let result = tokio::task::spawn_blocking(move || {
642 +
        let mut buf = std::io::Cursor::new(Vec::new());
643 +
        {
644 +
            let mut zip = zip::ZipWriter::new(&mut buf);
645 +
            let options = zip::write::SimpleFileOptions::default()
646 +
                .compression_method(zip::CompressionMethod::Stored);
647 +
            for (name, bytes) in &file_data {
648 +
                if let Err(e) = zip.start_file(name, options) {
649 +
                    tracing::warn!("Failed to add {} to zip: {}", name, e);
650 +
                    continue;
651 +
                }
652 +
                if let Err(e) = std::io::Write::write_all(&mut zip, bytes) {
653 +
                    tracing::warn!("Failed to write {} to zip: {}", name, e);
654 +
                }
655 +
            }
656 +
            let _ = zip.finish();
657 +
        }
658 +
        buf.into_inner()
659 +
    })
660 +
    .await;
661 +
662 +
    match result {
663 +
        Ok(bytes) => (
664 +
            StatusCode::OK,
665 +
            [
666 +
                (axum::http::header::CONTENT_TYPE, "application/zip"),
667 +
                (
668 +
                    axum::http::header::CONTENT_DISPOSITION,
669 +
                    "attachment; filename=\"uploads.zip\"",
670 +
                ),
671 +
            ],
672 +
            bytes,
673 +
        )
674 +
            .into_response(),
675 +
        Err(e) => {
676 +
            tracing::error!("Failed to create uploads zip: {}", e);
677 +
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
678 +
        }
679 +
    }
680 +
}
apps/posts/src/server/handlers/mod.rs (added) +2 −0
1 +
pub mod admin;
2 +
pub mod public;
apps/posts/src/server/handlers/public.rs (added) +289 −0
1 +
use askama_web::WebTemplate;
2 +
use axum::{
3 +
    extract::{Path, State},
4 +
    http::{HeaderValue, StatusCode, Uri},
5 +
    response::{Html, IntoResponse, Redirect, Response},
6 +
};
7 +
use std::sync::Arc;
8 +
9 +
use super::super::*;
10 +
use crate::db;
11 +
12 +
pub async fn serve_static(Path(path): Path<String>) -> Response {
13 +
    match Static::get(&path) {
14 +
        Some(file) => {
15 +
            let mime = mime_from_path(&path);
16 +
            (
17 +
                StatusCode::OK,
18 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
19 +
                file.data.to_vec(),
20 +
            )
21 +
                .into_response()
22 +
        }
23 +
        None => StatusCode::NOT_FOUND.into_response(),
24 +
    }
25 +
}
26 +
27 +
pub async fn public_index(State(state): State<Arc<AppState>>) -> Response {
28 +
    let blog_title = get_blog_title(&state.db);
29 +
    let blog_description = db::get_setting(&state.db, "blog_description")
30 +
        .ok()
31 +
        .flatten()
32 +
        .unwrap_or_default();
33 +
    let intro_content = db::get_setting(&state.db, "intro_content")
34 +
        .ok()
35 +
        .flatten()
36 +
        .unwrap_or_default();
37 +
    let nav_links = get_nav_links(&state.db);
38 +
39 +
    match db::get_published_posts(&state.db) {
40 +
        Ok(posts) => {
41 +
            let mut intro_html = render_markdown(&intro_content);
42 +
43 +
            if intro_content.contains("{{latest_posts}}") {
44 +
                let latest: Vec<&Post> = posts.iter().take(5).collect();
45 +
                let embed_html = render_latest_posts_embed(&latest);
46 +
                intro_html = intro_html.replace("<p>{{latest_posts}}</p>", &embed_html);
47 +
                intro_html = intro_html.replace("{{latest_posts}}", &embed_html);
48 +
            }
49 +
50 +
            let favicon_url = get_favicon_url(&state.db);
51 +
            let og_image_url = get_og_image_url(&state.db);
52 +
            let (header_html, footer_html) = get_header_footer_html(&state.db);
53 +
            WebTemplate(IndexTemplate {
54 +
                blog_title,
55 +
                blog_description,
56 +
                intro_html,
57 +
                posts,
58 +
                nav_links,
59 +
                favicon_url,
60 +
                og_image_url,
61 +
                site_url: state.site_url.clone(),
62 +
                header_html,
63 +
                footer_html,
64 +
            })
65 +
            .into_response()
66 +
        }
67 +
        Err(e) => {
68 +
            tracing::error!("Failed to list posts: {}", e);
69 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
70 +
        }
71 +
    }
72 +
}
73 +
74 +
pub async fn public_post(
75 +
    State(state): State<Arc<AppState>>,
76 +
    Path(slug): Path<String>,
77 +
) -> Response {
78 +
    match db::get_post_by_slug(&state.db, &slug) {
79 +
        Ok(Some(post)) if post.status == "published" => {
80 +
            let rendered_content = render_markdown(&post.content);
81 +
            let blog_title = get_blog_title(&state.db);
82 +
            let nav_links = get_nav_links(&state.db);
83 +
            let favicon_url = get_favicon_url(&state.db);
84 +
            let og_image_url = get_og_image_url(&state.db);
85 +
            let (header_html, footer_html) = get_header_footer_html(&state.db);
86 +
            WebTemplate(PostTemplate {
87 +
                blog_title,
88 +
                nav_links,
89 +
                post,
90 +
                rendered_content,
91 +
                favicon_url,
92 +
                og_image_url,
93 +
                site_url: state.site_url.clone(),
94 +
                header_html,
95 +
                footer_html,
96 +
            })
97 +
            .into_response()
98 +
        }
99 +
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
100 +
        Err(e) => {
101 +
            tracing::error!("Failed to get post: {}", e);
102 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
103 +
        }
104 +
    }
105 +
}
106 +
107 +
pub async fn public_page(
108 +
    State(state): State<Arc<AppState>>,
109 +
    Path(slug): Path<String>,
110 +
) -> Response {
111 +
    match db::get_page_by_slug(&state.db, &slug) {
112 +
        Ok(Some(page)) if page.is_published => {
113 +
            let rendered_content = render_markdown(&page.content);
114 +
            let blog_title = get_blog_title(&state.db);
115 +
            let nav_links = get_nav_links(&state.db);
116 +
            let favicon_url = get_favicon_url(&state.db);
117 +
            let og_image_url = get_og_image_url(&state.db);
118 +
            let (header_html, footer_html) = get_header_footer_html(&state.db);
119 +
            WebTemplate(PageTemplate {
120 +
                blog_title,
121 +
                nav_links,
122 +
                page,
123 +
                rendered_content,
124 +
                favicon_url,
125 +
                og_image_url,
126 +
                site_url: state.site_url.clone(),
127 +
                header_html,
128 +
                footer_html,
129 +
            })
130 +
            .into_response()
131 +
        }
132 +
        Ok(_) => (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response(),
133 +
        Err(e) => {
134 +
            tracing::error!("Failed to get page: {}", e);
135 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
136 +
        }
137 +
    }
138 +
}
139 +
140 +
pub async fn public_posts_list(State(state): State<Arc<AppState>>) -> Response {
141 +
    let blog_title = get_blog_title(&state.db);
142 +
    let nav_links = get_nav_links(&state.db);
143 +
    let favicon_url = get_favicon_url(&state.db);
144 +
    let og_image_url = get_og_image_url(&state.db);
145 +
146 +
    let (header_html, footer_html) = get_header_footer_html(&state.db);
147 +
148 +
    match db::get_published_posts(&state.db) {
149 +
        Ok(posts) => WebTemplate(PostsListTemplate {
150 +
            blog_title,
151 +
            nav_links,
152 +
            posts,
153 +
            favicon_url,
154 +
            og_image_url,
155 +
            site_url: state.site_url.clone(),
156 +
            header_html,
157 +
            footer_html,
158 +
        })
159 +
        .into_response(),
160 +
        Err(e) => {
161 +
            tracing::error!("Failed to list posts: {}", e);
162 +
            (StatusCode::INTERNAL_SERVER_ERROR, Html("Server error".to_string())).into_response()
163 +
        }
164 +
    }
165 +
}
166 +
167 +
pub async fn serve_custom_css(State(state): State<Arc<AppState>>) -> Response {
168 +
    let css = db::get_setting(&state.db, "custom_css")
169 +
        .ok()
170 +
        .flatten()
171 +
        .unwrap_or_default();
172 +
    (
173 +
        StatusCode::OK,
174 +
        [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
175 +
        css,
176 +
    )
177 +
        .into_response()
178 +
}
179 +
180 +
pub async fn fallback_handler(
181 +
    State(state): State<Arc<AppState>>,
182 +
    uri: Uri,
183 +
) -> Response {
184 +
    let path = uri.path().trim_start_matches('/');
185 +
    if let Ok(Some(redirect_to)) = db::find_alias_redirect(&state.db, path) {
186 +
        return Redirect::permanent(&redirect_to).into_response();
187 +
    }
188 +
    (StatusCode::NOT_FOUND, Html("Not found".to_string())).into_response()
189 +
}
190 +
191 +
pub async fn serve_uploaded_file(
192 +
    State(state): State<Arc<AppState>>,
193 +
    Path(filename): Path<String>,
194 +
) -> Response {
195 +
    if filename.contains("..") || filename.contains('/') || filename.contains('\\') {
196 +
        return StatusCode::NOT_FOUND.into_response();
197 +
    }
198 +
199 +
    let path = std::path::PathBuf::from(&state.uploads_dir).join(&filename);
200 +
    match tokio::fs::read(&path).await {
201 +
        Ok(bytes) => {
202 +
            let mime = mime_from_path(&filename);
203 +
            (
204 +
                StatusCode::OK,
205 +
                [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(mime))],
206 +
                bytes,
207 +
            )
208 +
                .into_response()
209 +
        }
210 +
        Err(_) => StatusCode::NOT_FOUND.into_response(),
211 +
    }
212 +
}
213 +
214 +
fn xml_escape(s: &str) -> String {
215 +
    s.replace('&', "&amp;")
216 +
        .replace('<', "&lt;")
217 +
        .replace('>', "&gt;")
218 +
        .replace('"', "&quot;")
219 +
        .replace('\'', "&apos;")
220 +
}
221 +
222 +
pub async fn rss_feed(State(state): State<Arc<AppState>>) -> Response {
223 +
    let blog_title = get_blog_title(&state.db);
224 +
    let blog_description = db::get_setting(&state.db, "blog_description")
225 +
        .ok()
226 +
        .flatten()
227 +
        .unwrap_or_default();
228 +
    let site_url = &state.site_url;
229 +
230 +
    let posts = match db::get_published_posts(&state.db) {
231 +
        Ok(posts) => posts,
232 +
        Err(e) => {
233 +
            tracing::error!("Failed to get posts for RSS: {}", e);
234 +
            return (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response();
235 +
        }
236 +
    };
237 +
238 +
    let mut items = String::new();
239 +
    for post in &posts {
240 +
        let link = format!("{}/posts/{}", site_url, xml_escape(&post.slug));
241 +
        let title = xml_escape(&post.title);
242 +
        let description = match &post.meta_description {
243 +
            Some(d) if !d.is_empty() => xml_escape(d),
244 +
            _ => {
245 +
                let plain: String = post.content.chars().take(200).collect();
246 +
                xml_escape(&plain)
247 +
            }
248 +
        };
249 +
        let pub_date = post.published_date.as_deref().unwrap_or(&post.created_at);
250 +
        let guid = format!("{}/posts/{}", site_url, xml_escape(&post.slug));
251 +
252 +
        items.push_str(&format!(
253 +
            "    <item>\n      <title>{title}</title>\n      <link>{link}</link>\n      <guid>{guid}</guid>\n      <description>{description}</description>\n      <pubDate>{pub_date}</pubDate>\n    </item>\n"
254 +
        ));
255 +
    }
256 +
257 +
    let last_build = posts
258 +
        .first()
259 +
        .and_then(|p| p.published_date.as_deref())
260 +
        .unwrap_or("");
261 +
262 +
    let xml = format!(
263 +
        r#"<?xml version="1.0" encoding="UTF-8"?>
264 +
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
265 +
  <channel>
266 +
    <title>{title}</title>
267 +
    <link>{site_url}</link>
268 +
    <description>{desc}</description>
269 +
    <lastBuildDate>{last_build}</lastBuildDate>
270 +
    <atom:link href="{site_url}/feed.xml" rel="self" type="application/rss+xml"/>
271 +
{items}  </channel>
272 +
</rss>"#,
273 +
        title = xml_escape(&blog_title),
274 +
        site_url = site_url,
275 +
        desc = xml_escape(&blog_description),
276 +
        last_build = last_build,
277 +
        items = items,
278 +
    );
279 +
280 +
    (
281 +
        StatusCode::OK,
282 +
        [(
283 +
            axum::http::header::CONTENT_TYPE,
284 +
            HeaderValue::from_static("application/rss+xml; charset=utf-8"),
285 +
        )],
286 +
        xml,
287 +
    )
288 +
        .into_response()
289 +
}
apps/posts/src/server/mod.rs (added) +513 −0
1 +
use askama::Template;
2 +
use axum::{
3 +
    extract::DefaultBodyLimit,
4 +
    routing::{get, post},
5 +
    Router,
6 +
};
7 +
use pulldown_cmark::{Options, Parser, html};
8 +
use rust_embed::Embed;
9 +
use std::sync::Arc;
10 +
11 +
use crate::db::{self, Db, Page, Post, UploadedFile};
12 +
13 +
mod handlers;
14 +
15 +
#[derive(Debug, Clone)]
16 +
pub struct NavLink {
17 +
    pub label: String,
18 +
    pub url: String,
19 +
}
20 +
21 +
#[derive(Clone)]
22 +
pub struct AppState {
23 +
    pub db: Db,
24 +
    pub app_password: String,
25 +
    pub cookie_secure: bool,
26 +
    pub uploads_dir: String,
27 +
    pub site_url: String,
28 +
}
29 +
30 +
#[derive(Embed)]
31 +
#[folder = "static/"]
32 +
struct Static;
33 +
34 +
// --- Templates ---
35 +
36 +
#[derive(Template)]
37 +
#[template(path = "base.html")]
38 +
struct BaseTemplate {
39 +
    blog_title: String,
40 +
    nav_links: Vec<NavLink>,
41 +
    favicon_url: String,
42 +
    og_image_url: String,
43 +
    header_html: String,
44 +
    footer_html: String,
45 +
}
46 +
47 +
#[derive(Template)]
48 +
#[template(path = "admin_base.html")]
49 +
struct AdminBaseTemplate;
50 +
51 +
#[derive(Template)]
52 +
#[template(path = "login.html")]
53 +
struct LoginTemplate {
54 +
    error: Option<String>,
55 +
}
56 +
57 +
#[derive(Template)]
58 +
#[template(path = "index.html")]
59 +
struct IndexTemplate {
60 +
    blog_title: String,
61 +
    blog_description: String,
62 +
    intro_html: String,
63 +
    posts: Vec<Post>,
64 +
    nav_links: Vec<NavLink>,
65 +
    favicon_url: String,
66 +
    og_image_url: String,
67 +
    site_url: String,
68 +
    header_html: String,
69 +
    footer_html: String,
70 +
}
71 +
72 +
#[derive(Template)]
73 +
#[template(path = "post.html")]
74 +
struct PostTemplate {
75 +
    blog_title: String,
76 +
    nav_links: Vec<NavLink>,
77 +
    post: Post,
78 +
    rendered_content: String,
79 +
    favicon_url: String,
80 +
    og_image_url: String,
81 +
    site_url: String,
82 +
    header_html: String,
83 +
    footer_html: String,
84 +
}
85 +
86 +
#[derive(Template)]
87 +
#[template(path = "page.html")]
88 +
struct PageTemplate {
89 +
    blog_title: String,
90 +
    nav_links: Vec<NavLink>,
91 +
    page: Page,
92 +
    rendered_content: String,
93 +
    favicon_url: String,
94 +
    og_image_url: String,
95 +
    site_url: String,
96 +
    header_html: String,
97 +
    footer_html: String,
98 +
}
99 +
100 +
#[derive(Template)]
101 +
#[template(path = "admin_index.html")]
102 +
struct AdminIndexTemplate {
103 +
    posts: Vec<Post>,
104 +
}
105 +
106 +
#[derive(Template)]
107 +
#[template(path = "admin_post_form.html")]
108 +
struct AdminPostFormTemplate {
109 +
    post: Option<Post>,
110 +
    error: Option<String>,
111 +
}
112 +
113 +
#[derive(Template)]
114 +
#[template(path = "admin_pages.html")]
115 +
struct AdminPagesTemplate {
116 +
    pages: Vec<Page>,
117 +
}
118 +
119 +
#[derive(Template)]
120 +
#[template(path = "admin_page_form.html")]
121 +
struct AdminPageFormTemplate {
122 +
    page: Option<Page>,
123 +
    error: Option<String>,
124 +
}
125 +
126 +
#[derive(Template)]
127 +
#[template(path = "admin_settings.html")]
128 +
struct AdminSettingsTemplate {
129 +
    blog_title: String,
130 +
    blog_description: String,
131 +
    intro_content: String,
132 +
    nav_links: String,
133 +
    custom_css: String,
134 +
    default_css: String,
135 +
    favicon_url: String,
136 +
    og_image_url: String,
137 +
    custom_header: String,
138 +
    custom_footer: String,
139 +
    success: bool,
140 +
}
141 +
142 +
#[derive(Template)]
143 +
#[template(path = "posts.html")]
144 +
struct PostsListTemplate {
145 +
    blog_title: String,
146 +
    nav_links: Vec<NavLink>,
147 +
    posts: Vec<Post>,
148 +
    favicon_url: String,
149 +
    og_image_url: String,
150 +
    site_url: String,
151 +
    header_html: String,
152 +
    footer_html: String,
153 +
}
154 +
155 +
#[derive(Template)]
156 +
#[template(path = "admin_files.html")]
157 +
struct AdminFilesTemplate {
158 +
    files: Vec<UploadedFile>,
159 +
    site_url: String,
160 +
    error: Option<String>,
161 +
    success: bool,
162 +
}
163 +
164 +
// --- Query/Form structs ---
165 +
166 +
#[derive(serde::Deserialize, Default)]
167 +
pub struct FlashQuery {
168 +
    pub error: Option<String>,
169 +
    #[serde(default)]
170 +
    pub success: bool,
171 +
}
172 +
173 +
#[derive(serde::Deserialize)]
174 +
struct LoginForm {
175 +
    password: String,
176 +
}
177 +
178 +
#[derive(serde::Deserialize)]
179 +
struct PostForm {
180 +
    attributes: String,
181 +
    content: String,
182 +
    #[serde(default)]
183 +
    action: String,
184 +
}
185 +
186 +
struct ParsedAttributes {
187 +
    title: String,
188 +
    slug: String,
189 +
    alias: String,
190 +
    published_date: String,
191 +
    meta_description: String,
192 +
    meta_image: String,
193 +
    lang: String,
194 +
    tags: String,
195 +
}
196 +
197 +
fn parse_attributes(text: &str) -> ParsedAttributes {
198 +
    let mut attrs = ParsedAttributes {
199 +
        title: String::new(),
200 +
        slug: String::new(),
201 +
        alias: String::new(),
202 +
        published_date: String::new(),
203 +
        meta_description: String::new(),
204 +
        meta_image: String::new(),
205 +
        lang: String::new(),
206 +
        tags: String::new(),
207 +
    };
208 +
    for line in text.lines() {
209 +
        if let Some((key, value)) = line.split_once(':') {
210 +
            let key = key.trim().to_lowercase();
211 +
            let value = value.trim().to_string();
212 +
            match key.as_str() {
213 +
                "title" => attrs.title = value,
214 +
                "slug" => attrs.slug = value,
215 +
                "alias" => attrs.alias = value,
216 +
                "published_date" => attrs.published_date = value,
217 +
                "description" | "meta_description" => attrs.meta_description = value,
218 +
                "meta_image" => attrs.meta_image = value,
219 +
                "lang" => attrs.lang = value,
220 +
                "tags" => attrs.tags = value,
221 +
                _ => {}
222 +
            }
223 +
        }
224 +
    }
225 +
    attrs
226 +
}
227 +
228 +
#[derive(serde::Deserialize)]
229 +
struct PageForm {
230 +
    attributes: String,
231 +
    content: String,
232 +
}
233 +
234 +
struct ParsedPageAttributes {
235 +
    title: String,
236 +
    slug: String,
237 +
    is_published: bool,
238 +
}
239 +
240 +
fn parse_page_attributes(text: &str) -> ParsedPageAttributes {
241 +
    let mut attrs = ParsedPageAttributes {
242 +
        title: String::new(),
243 +
        slug: String::new(),
244 +
        is_published: false,
245 +
    };
246 +
    for line in text.lines() {
247 +
        if let Some((key, value)) = line.split_once(':') {
248 +
            let key = key.trim().to_lowercase();
249 +
            let value = value.trim().to_string();
250 +
            match key.as_str() {
251 +
                "title" => attrs.title = value,
252 +
                "slug" => attrs.slug = value,
253 +
                "published" => attrs.is_published = value == "true",
254 +
                _ => {}
255 +
            }
256 +
        }
257 +
    }
258 +
    attrs
259 +
}
260 +
261 +
#[derive(serde::Deserialize)]
262 +
struct SettingsForm {
263 +
    blog_title: String,
264 +
    blog_description: String,
265 +
    intro_content: String,
266 +
    nav_links: String,
267 +
    custom_css: String,
268 +
    favicon_url: String,
269 +
    og_image_url: String,
270 +
    custom_header: String,
271 +
    custom_footer: String,
272 +
}
273 +
274 +
// --- Helpers ---
275 +
276 +
fn mime_from_path(path: &str) -> &'static str {
277 +
    match path.rsplit('.').next().unwrap_or("") {
278 +
        "css" => "text/css",
279 +
        "js" => "application/javascript",
280 +
        "html" => "text/html",
281 +
        "png" => "image/png",
282 +
        "jpg" | "jpeg" => "image/jpeg",
283 +
        "gif" => "image/gif",
284 +
        "webp" => "image/webp",
285 +
        "ico" => "image/x-icon",
286 +
        "svg" => "image/svg+xml",
287 +
        "woff" | "woff2" => "font/woff2",
288 +
        "ttf" => "font/ttf",
289 +
        "otf" => "font/otf",
290 +
        "json" | "webmanifest" => "application/json",
291 +
        "pdf" => "application/pdf",
292 +
        "mp4" => "video/mp4",
293 +
        "webm" => "video/webm",
294 +
        _ => "application/octet-stream",
295 +
    }
296 +
}
297 +
298 +
fn get_header_footer_html(db: &db::Db) -> (String, String) {
299 +
    let custom_header = db::get_setting(db, "custom_header")
300 +
        .ok()
301 +
        .flatten()
302 +
        .unwrap_or_default();
303 +
    let custom_footer = db::get_setting(db, "custom_footer")
304 +
        .ok()
305 +
        .flatten()
306 +
        .unwrap_or_default();
307 +
    let header_html = render_markdown(&custom_header);
308 +
    let footer_html = render_markdown(&custom_footer);
309 +
    (header_html, footer_html)
310 +
}
311 +
312 +
fn render_markdown(content: &str) -> String {
313 +
    let mut options = Options::empty();
314 +
    options.insert(Options::ENABLE_STRIKETHROUGH);
315 +
    options.insert(Options::ENABLE_TABLES);
316 +
    options.insert(Options::ENABLE_TASKLISTS);
317 +
    options.insert(Options::ENABLE_FOOTNOTES);
318 +
    let parser = Parser::new_ext(content, options);
319 +
    let mut html_output = String::new();
320 +
    html::push_html(&mut html_output, parser);
321 +
    html_output
322 +
}
323 +
324 +
fn now_datetime() -> String {
325 +
    andromeda_auth::datetime::now_datetime_string()
326 +
}
327 +
328 +
fn slugify(s: &str) -> String {
329 +
    s.to_lowercase()
330 +
        .chars()
331 +
        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
332 +
        .collect::<String>()
333 +
        .split('-')
334 +
        .filter(|s| !s.is_empty())
335 +
        .collect::<Vec<_>>()
336 +
        .join("-")
337 +
}
338 +
339 +
fn opt_str(s: &str) -> Option<&str> {
340 +
    let trimmed = s.trim();
341 +
    if trimmed.is_empty() { None } else { Some(trimmed) }
342 +
}
343 +
344 +
fn get_blog_title(db: &Db) -> String {
345 +
    db::get_setting(db, "blog_title")
346 +
        .ok()
347 +
        .flatten()
348 +
        .unwrap_or_else(|| "My Blog".to_string())
349 +
}
350 +
351 +
fn parse_nav_links(input: &str) -> Vec<NavLink> {
352 +
    let mut links = Vec::new();
353 +
    let mut chars = input.chars().peekable();
354 +
    while let Some(c) = chars.next() {
355 +
        if c == '[' {
356 +
            let label: String = chars.by_ref().take_while(|&ch| ch != ']').collect();
357 +
            if chars.peek() == Some(&'(') {
358 +
                chars.next();
359 +
                let url: String = chars.by_ref().take_while(|&ch| ch != ')').collect();
360 +
                if !label.is_empty() && !url.is_empty() {
361 +
                    links.push(NavLink { label, url });
362 +
                }
363 +
            }
364 +
        }
365 +
    }
366 +
    links
367 +
}
368 +
369 +
fn get_nav_links(db: &Db) -> Vec<NavLink> {
370 +
    let raw = db::get_setting(db, "nav_links")
371 +
        .ok()
372 +
        .flatten()
373 +
        .unwrap_or_default();
374 +
    parse_nav_links(&raw)
375 +
}
376 +
377 +
fn get_favicon_url(db: &Db) -> String {
378 +
    db::get_setting(db, "favicon_url")
379 +
        .ok()
380 +
        .flatten()
381 +
        .unwrap_or_default()
382 +
}
383 +
384 +
fn get_og_image_url(db: &Db) -> String {
385 +
    db::get_setting(db, "og_image_url")
386 +
        .ok()
387 +
        .flatten()
388 +
        .unwrap_or_default()
389 +
}
390 +
391 +
fn render_latest_posts_embed(posts: &[&Post]) -> String {
392 +
    let mut html = String::from("<div class=\"post-list\">");
393 +
    for post in posts {
394 +
        html.push_str(&format!(
395 +
            r#"<a href="/posts/{slug}" class="post-item"><div class="post-item-info"><span class="post-title">{title}</span>"#,
396 +
            slug = post.slug,
397 +
            title = post.title,
398 +
        ));
399 +
        if let Some(ref tags) = post.tags {
400 +
            if !tags.is_empty() {
401 +
                html.push_str(r#"<span class="post-tags">"#);
402 +
                for tag in tags.split(',') {
403 +
                    let tag = tag.trim();
404 +
                    if !tag.is_empty() {
405 +
                        html.push_str(&format!(r#"<span class="tag">{}</span>"#, tag));
406 +
                    }
407 +
                }
408 +
                html.push_str("</span>");
409 +
            }
410 +
        }
411 +
        html.push_str("</div>");
412 +
        if let Some(ref date) = post.published_date {
413 +
            html.push_str(&format!(r#"<time class="post-date">{}</time>"#, date));
414 +
        }
415 +
        html.push_str("</a>");
416 +
    }
417 +
    html.push_str("</div>");
418 +
    html
419 +
}
420 +
421 +
// --- Router ---
422 +
423 +
pub async fn run(host: String, port: u16) {
424 +
    use handlers::{admin, public};
425 +
426 +
    dotenvy::dotenv().ok();
427 +
428 +
    let db = db::init_db();
429 +
430 +
    if let Err(e) = db::prune_expired_sessions(&db) {
431 +
        tracing::warn!("Failed to prune sessions: {}", e);
432 +
    }
433 +
434 +
    let app_password = std::env::var("POSTS_PASSWORD").unwrap_or_else(|_| {
435 +
        tracing::warn!("POSTS_PASSWORD not set, using default 'changeme'");
436 +
        "changeme".to_string()
437 +
    });
438 +
439 +
    let cookie_secure = std::env::var("COOKIE_SECURE")
440 +
        .map(|v| v == "true")
441 +
        .unwrap_or(false);
442 +
443 +
    let uploads_dir = std::env::var("UPLOADS_DIR").unwrap_or_else(|_| "uploads".to_string());
444 +
    tokio::fs::create_dir_all(&uploads_dir)
445 +
        .await
446 +
        .expect("Failed to create uploads directory");
447 +
448 +
    let site_url = std::env::var("SITE_URL")
449 +
        .unwrap_or_else(|_| "http://localhost:3000".to_string())
450 +
        .trim_end_matches('/')
451 +
        .to_string();
452 +
453 +
    let state = Arc::new(AppState {
454 +
        db,
455 +
        app_password,
456 +
        cookie_secure,
457 +
        uploads_dir,
458 +
        site_url,
459 +
    });
460 +
461 +
    let app = Router::new()
462 +
        // Public routes
463 +
        .route("/", get(public::public_index))
464 +
        .route("/posts", get(public::public_posts_list))
465 +
        .route("/posts/{slug}", get(public::public_post))
466 +
        .route("/custom-styles.css", get(public::serve_custom_css))
467 +
        .route("/{slug}", get(public::public_page))
468 +
        .route("/feed.xml", get(public::rss_feed))
469 +
        // Admin auth
470 +
        .route("/admin/login", get(admin::get_login).post(admin::post_login))
471 +
        .route("/admin/logout", get(admin::get_logout))
472 +
        // Admin posts
473 +
        .route("/admin", get(admin::admin_index))
474 +
        .route("/admin/posts/new", get(admin::admin_new_post))
475 +
        .route("/admin/posts", post(admin::admin_create_post))
476 +
        .route("/admin/posts/{id}/edit", get(admin::admin_edit_post))
477 +
        .route("/admin/posts/{id}", post(admin::admin_update_post))
478 +
        .route("/admin/posts/{id}/delete", post(admin::admin_delete_post))
479 +
        .route("/admin/posts/{id}/publish", post(admin::admin_toggle_publish))
480 +
        // Admin pages
481 +
        .route("/admin/pages", get(admin::admin_pages))
482 +
        .route("/admin/pages/new", get(admin::admin_new_page))
483 +
        .route("/admin/pages/create", post(admin::admin_create_page))
484 +
        .route("/admin/pages/{id}/edit", get(admin::admin_edit_page))
485 +
        .route("/admin/pages/{id}", post(admin::admin_update_page))
486 +
        .route("/admin/pages/{id}/delete", post(admin::admin_delete_page))
487 +
        // Admin settings
488 +
        .route(
489 +
            "/admin/settings",
490 +
            get(admin::admin_get_settings).post(admin::admin_post_settings),
491 +
        )
492 +
        // Admin downloads
493 +
        .route("/admin/downloads/posts", get(admin::admin_download_posts))
494 +
        .route("/admin/downloads/uploads", get(admin::admin_download_uploads))
495 +
        // Admin files
496 +
        .route("/admin/files", get(admin::admin_files))
497 +
        .route("/admin/files/upload", post(admin::admin_upload_file))
498 +
        .route("/admin/files/{id}/delete", post(admin::admin_delete_file))
499 +
        // Public files
500 +
        .route("/files/{filename}", get(public::serve_uploaded_file))
501 +
        // Static assets
502 +
        .route("/static/{*path}", get(public::serve_static))
503 +
        // Fallback
504 +
        .fallback(get(public::fallback_handler))
505 +
        .with_state(state)
506 +
        .layer(DefaultBodyLimit::max(11 * 1024 * 1024));
507 +
508 +
    let addr = format!("{}:{}", host, port);
509 +
    tracing::info!("Listening on http://{}", addr);
510 +
511 +
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
512 +
    axum::serve(listener, app).await.unwrap();
513 +
}
crates/auth/src/datetime.rs (added) +119 −0
1 +
//! Zero-dep datetime helpers for formatting Unix timestamps as
2 +
//! `YYYY-MM-DD HH:MM:SS` strings suitable for SQLite TEXT columns
3 +
//! and string-sortable comparisons.
4 +
5 +
use std::time::{SystemTime, UNIX_EPOCH};
6 +
7 +
/// Current time as `YYYY-MM-DD HH:MM:SS`.
8 +
pub fn now_datetime_string() -> String {
9 +
    from_unix_secs(now_secs())
10 +
}
11 +
12 +
/// Time `secs_from_now` seconds in the future as `YYYY-MM-DD HH:MM:SS`.
13 +
pub fn expiry_datetime_string(secs_from_now: u64) -> String {
14 +
    from_unix_secs(now_secs() + secs_from_now)
15 +
}
16 +
17 +
/// Format an absolute Unix timestamp as `YYYY-MM-DD HH:MM:SS`.
18 +
pub fn from_unix_secs(secs: u64) -> String {
19 +
    let days = secs / 86400;
20 +
    let h = (secs / 3600) % 24;
21 +
    let m = (secs / 60) % 60;
22 +
    let s = secs % 60;
23 +
    format_unix_to_datetime(days, h, m, s)
24 +
}
25 +
26 +
/// Format an already-split unix time into `YYYY-MM-DD HH:MM:SS`.
27 +
pub fn format_unix_to_datetime(days: u64, h: u64, m: u64, s: u64) -> String {
28 +
    let (y, mo, d) = days_to_ymd(days as i64);
29 +
    format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, mo, d, h, m, s)
30 +
}
31 +
32 +
/// Convert days-since-Unix-epoch into `(year, month, day)`.
33 +
/// https://howardhinnant.github.io/date_algorithms.html
34 +
pub fn days_to_ymd(mut days: i64) -> (i64, i64, i64) {
35 +
    days += 719468;
36 +
    let era = if days >= 0 { days } else { days - 146096 } / 146097;
37 +
    let doe = (days - era * 146097) as u32;
38 +
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
39 +
    let y = yoe as i64 + era * 400;
40 +
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
41 +
    let mp = (5 * doy + 2) / 153;
42 +
    let d = doy - (153 * mp + 2) / 5 + 1;
43 +
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
44 +
    let y = if m <= 2 { y + 1 } else { y };
45 +
    (y, m as i64, d as i64)
46 +
}
47 +
48 +
fn now_secs() -> u64 {
49 +
    SystemTime::now()
50 +
        .duration_since(UNIX_EPOCH)
51 +
        .unwrap()
52 +
        .as_secs()
53 +
}
54 +
55 +
#[cfg(test)]
56 +
mod tests {
57 +
    use super::*;
58 +
59 +
    #[test]
60 +
    fn format_unix_epoch() {
61 +
        assert_eq!(format_unix_to_datetime(0, 0, 0, 0), "1970-01-01 00:00:00");
62 +
    }
63 +
64 +
    #[test]
65 +
    fn format_unix_known_date() {
66 +
        assert_eq!(
67 +
            format_unix_to_datetime(19737, 12, 30, 45),
68 +
            "2024-01-15 12:30:45"
69 +
        );
70 +
    }
71 +
72 +
    #[test]
73 +
    fn format_unix_y2k() {
74 +
        assert_eq!(
75 +
            format_unix_to_datetime(10957, 0, 0, 0),
76 +
            "2000-01-01 00:00:00"
77 +
        );
78 +
    }
79 +
80 +
    #[test]
81 +
    fn format_unix_leap_day() {
82 +
        assert_eq!(
83 +
            format_unix_to_datetime(19782, 23, 59, 59),
84 +
            "2024-02-29 23:59:59"
85 +
        );
86 +
    }
87 +
88 +
    #[test]
89 +
    fn format_unix_end_of_year() {
90 +
        assert_eq!(
91 +
            format_unix_to_datetime(19722, 23, 59, 59),
92 +
            "2023-12-31 23:59:59"
93 +
        );
94 +
    }
95 +
96 +
    #[test]
97 +
    fn now_string_valid_format() {
98 +
        let s = now_datetime_string();
99 +
        assert_eq!(s.len(), 19);
100 +
        assert_eq!(&s[4..5], "-");
101 +
        assert_eq!(&s[7..8], "-");
102 +
        assert_eq!(&s[10..11], " ");
103 +
        assert_eq!(&s[13..14], ":");
104 +
        assert_eq!(&s[16..17], ":");
105 +
    }
106 +
107 +
    #[test]
108 +
    fn expiry_string_in_future() {
109 +
        let now = now_datetime_string();
110 +
        let exp = expiry_datetime_string(7 * 24 * 3600);
111 +
        assert!(exp > now);
112 +
    }
113 +
114 +
    #[test]
115 +
    fn from_unix_secs_known() {
116 +
        // 2024-01-15 12:30:45 UTC = 1705321845
117 +
        assert_eq!(from_unix_secs(1705321845), "2024-01-15 12:30:45");
118 +
    }
119 +
}
crates/auth/src/lib.rs +2 −0
1 1
use rand::RngCore;
2 2
use subtle::ConstantTimeEq;
3 3
4 +
pub mod datetime;
5 +
4 6
/// Constant-time password comparison to prevent timing attacks.
5 7
/// Pads/truncates both sides to a fixed 256-byte buffer so length
6 8
/// differences don't leak via timing.