Merge pull request #27 from stevedylandev/chore/refactor-posts aeae7c73
chore/refactor posts
Steve Simkins · 2026-04-16 22:27 4 file(s) · +162 −218
apps/posts/src/db.rs +0 −42
360 360
    Ok(pages)
361 361
}
362 362
363 -
pub fn get_published_pages(db: &Db) -> Result<Vec<Page>, DbError> {
364 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
365 -
    let mut stmt = conn.prepare(
366 -
        &format!("SELECT {} FROM pages WHERE is_published = 1 ORDER BY nav_order ASC, id ASC", PAGE_COLS),
367 -
    )?;
368 -
    let pages = stmt
369 -
        .query_map([], from_row)?
370 -
        .collect::<Result<Vec<_>, _>>()?;
371 -
    Ok(pages)
372 -
}
373 -
374 363
pub fn update_page(
375 364
    db: &Db,
376 365
    short_id: &str,
425 414
        params![key, value],
426 415
    )?;
427 416
    Ok(())
428 -
}
429 -
430 -
pub fn get_all_settings(db: &Db) -> Result<Vec<(String, String)>, DbError> {
431 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
432 -
    let mut stmt = conn.prepare("SELECT key, value FROM settings ORDER BY key")?;
433 -
    let settings = stmt
434 -
        .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
435 -
        .collect::<Result<Vec<_>, _>>()?;
436 -
    Ok(settings)
437 417
}
438 418
439 419
// --- File CRUD ---
653 633
    }
654 634
655 635
    #[test]
656 -
    fn get_published_pages_filters() {
657 -
        let db = test_db();
658 -
        create_page(&db, "Pub", "pub", "x", true, 1).unwrap();
659 -
        create_page(&db, "Draft", "draft", "x", false, 2).unwrap();
660 -
661 -
        let published = get_published_pages(&db).unwrap();
662 -
        assert_eq!(published.len(), 1);
663 -
        assert_eq!(published[0].title, "Pub");
664 -
    }
665 -
666 -
    #[test]
667 636
    fn update_page_works() {
668 637
        let db = test_db();
669 638
        let page = create_page(&db, "Old", "old", "old content", false, 0).unwrap();
705 674
    fn settings_missing_key() {
706 675
        let db = test_db();
707 676
        assert!(get_setting(&db, "nonexistent").unwrap().is_none());
708 -
    }
709 -
710 -
    #[test]
711 -
    fn get_all_settings_works() {
712 -
        let db = test_db();
713 -
        set_setting(&db, "a", "1").unwrap();
714 -
        set_setting(&db, "b", "2").unwrap();
715 -
716 -
        let all = get_all_settings(&db).unwrap();
717 -
        assert_eq!(all.len(), 2);
718 -
        assert_eq!(all[0], ("a".to_string(), "1".to_string()));
719 677
    }
720 678
721 679
    // ── File CRUD ──────────────────────────────────────────────────────
apps/posts/src/server/handlers/admin.rs +25 −90
370 370
    State(state): State<Arc<AppState>>,
371 371
    Query(q): Query<FlashQuery>,
372 372
) -> Response {
373 -
    let blog_title = db::get_setting(&state.db, "blog_title").ok().flatten().unwrap_or_default();
374 -
    let blog_description = db::get_setting(&state.db, "blog_description").ok().flatten().unwrap_or_default();
375 -
    let intro_content = db::get_setting(&state.db, "intro_content").ok().flatten().unwrap_or_default();
376 -
    let nav_links = db::get_setting(&state.db, "nav_links").ok().flatten().unwrap_or_default();
377 -
    let custom_css = db::get_setting(&state.db, "custom_css").ok().flatten().unwrap_or_default();
378 -
    let favicon_url = db::get_setting(&state.db, "favicon_url").ok().flatten().unwrap_or_default();
379 -
    let og_image_url = db::get_setting(&state.db, "og_image_url").ok().flatten().unwrap_or_default();
380 -
    let custom_header = db::get_setting(&state.db, "custom_header").ok().flatten().unwrap_or_default();
381 -
    let custom_footer = db::get_setting(&state.db, "custom_footer").ok().flatten().unwrap_or_default();
373 +
    let blog_title = get_setting_or_default(&state.db, "blog_title");
374 +
    let blog_description = get_setting_or_default(&state.db, "blog_description");
375 +
    let intro_content = get_setting_or_default(&state.db, "intro_content");
376 +
    let nav_links = get_setting_or_default(&state.db, "nav_links");
377 +
    let custom_css = get_setting_or_default(&state.db, "custom_css");
378 +
    let favicon_url = get_setting_or_default(&state.db, "favicon_url");
379 +
    let og_image_url = get_setting_or_default(&state.db, "og_image_url");
380 +
    let custom_header = get_setting_or_default(&state.db, "custom_header");
381 +
    let custom_footer = get_setting_or_default(&state.db, "custom_footer");
382 382
    let default_css = Static::get("styles.css")
383 383
        .map(|f| String::from_utf8_lossy(&f.data).into_owned())
384 384
        .unwrap_or_default();
541 541
    };
542 542
543 543
    let result = tokio::task::spawn_blocking(move || {
544 -
        let mut buf = std::io::Cursor::new(Vec::new());
545 -
        {
546 -
            let mut zip = zip::ZipWriter::new(&mut buf);
547 -
            let options = zip::write::SimpleFileOptions::default()
548 -
                .compression_method(zip::CompressionMethod::Deflated);
549 -
            for post in &posts {
550 -
                let filename = format!("{}.md", post.slug);
551 -
                let mut frontmatter = format!(
552 -
                    "---\ntitle: {}\nslug: {}\nstatus: {}",
553 -
                    post.title, post.slug, post.status
554 -
                );
555 -
                if let Some(ref pd) = post.published_date {
556 -
                    frontmatter.push_str(&format!("\npublished_date: {}", pd));
557 -
                }
558 -
                if let Some(ref tags) = post.tags {
559 -
                    frontmatter.push_str(&format!("\ntags: {}", tags));
560 -
                }
561 -
                frontmatter.push_str(&format!("\nlang: {}", post.lang));
562 -
                if let Some(ref alias) = post.alias {
563 -
                    frontmatter.push_str(&format!("\nalias: {}", alias));
564 -
                }
565 -
                if let Some(ref meta_image) = post.meta_image {
566 -
                    frontmatter.push_str(&format!("\nmeta_image: {}", meta_image));
567 -
                }
568 -
                if let Some(ref meta_desc) = post.meta_description {
569 -
                    frontmatter.push_str(&format!("\ndescription: {}", meta_desc));
570 -
                }
571 -
                frontmatter.push_str("\n---\n\n");
572 -
                let content = format!("{}{}", frontmatter, post.content);
573 -
                if let Err(e) = zip.start_file(&filename, options) {
574 -
                    tracing::warn!("Failed to add {} to zip: {}", filename, e);
575 -
                    continue;
576 -
                }
577 -
                if let Err(e) = std::io::Write::write_all(&mut zip, content.as_bytes()) {
578 -
                    tracing::warn!("Failed to write {} to zip: {}", filename, e);
579 -
                }
580 -
            }
581 -
            let _ = zip.finish();
582 -
        }
583 -
        buf.into_inner()
544 +
        let markdown_files: Vec<_> = posts
545 +
            .iter()
546 +
            .map(|p| (format!("{}.md", p.slug), post_to_markdown(p)))
547 +
            .collect();
548 +
        let entries: Vec<_> = markdown_files
549 +
            .iter()
550 +
            .map(|(name, content)| (name.clone(), content.as_bytes()))
551 +
            .collect();
552 +
        build_zip(&entries, zip::CompressionMethod::Deflated)
584 553
    })
585 554
    .await;
586 555
587 556
    match result {
588 -
        Ok(bytes) => (
589 -
            StatusCode::OK,
590 -
            [
591 -
                (axum::http::header::CONTENT_TYPE, "application/zip"),
592 -
                (
593 -
                    axum::http::header::CONTENT_DISPOSITION,
594 -
                    "attachment; filename=\"posts.zip\"",
595 -
                ),
596 -
            ],
597 -
            bytes,
598 -
        )
599 -
            .into_response(),
557 +
        Ok(bytes) => zip_response(bytes, "posts.zip"),
600 558
        Err(e) => {
601 559
            tracing::error!("Failed to create posts zip: {}", e);
602 560
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
638 596
    }
639 597
640 598
    let result = tokio::task::spawn_blocking(move || {
641 -
        let mut buf = std::io::Cursor::new(Vec::new());
642 -
        {
643 -
            let mut zip = zip::ZipWriter::new(&mut buf);
644 -
            let options = zip::write::SimpleFileOptions::default()
645 -
                .compression_method(zip::CompressionMethod::Stored);
646 -
            for (name, bytes) in &file_data {
647 -
                if let Err(e) = zip.start_file(name, options) {
648 -
                    tracing::warn!("Failed to add {} to zip: {}", name, e);
649 -
                    continue;
650 -
                }
651 -
                if let Err(e) = std::io::Write::write_all(&mut zip, bytes) {
652 -
                    tracing::warn!("Failed to write {} to zip: {}", name, e);
653 -
                }
654 -
            }
655 -
            let _ = zip.finish();
656 -
        }
657 -
        buf.into_inner()
599 +
        let entries: Vec<_> = file_data
600 +
            .iter()
601 +
            .map(|(name, bytes)| (name.clone(), bytes.as_slice()))
602 +
            .collect();
603 +
        build_zip(&entries, zip::CompressionMethod::Stored)
658 604
    })
659 605
    .await;
660 606
661 607
    match result {
662 -
        Ok(bytes) => (
663 -
            StatusCode::OK,
664 -
            [
665 -
                (axum::http::header::CONTENT_TYPE, "application/zip"),
666 -
                (
667 -
                    axum::http::header::CONTENT_DISPOSITION,
668 -
                    "attachment; filename=\"uploads.zip\"",
669 -
                ),
670 -
            ],
671 -
            bytes,
672 -
        )
673 -
            .into_response(),
608 +
        Ok(bytes) => zip_response(bytes, "uploads.zip"),
674 609
        Err(e) => {
675 610
            tracing::error!("Failed to create uploads zip: {}", e);
676 611
            (StatusCode::INTERNAL_SERVER_ERROR, "Export failed").into_response()
apps/posts/src/server/handlers/public.rs +36 −65
25 25
}
26 26
27 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);
28 +
    let ctx = SiteContext::from_state(&state);
29 +
    let blog_description = get_setting_or_default(&state.db, "blog_description");
30 +
    let intro_content = get_setting_or_default(&state.db, "intro_content");
38 31
39 32
    match db::get_published_posts(&state.db) {
40 33
        Ok(posts) => {
47 40
                intro_html = intro_html.replace("{{latest_posts}}", &embed_html);
48 41
            }
49 42
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 43
            WebTemplate(IndexTemplate {
54 -
                blog_title,
44 +
                blog_title: ctx.blog_title,
55 45
                blog_description,
56 46
                intro_html,
57 47
                posts,
58 -
                nav_links,
59 -
                favicon_url,
60 -
                og_image_url,
61 -
                site_url: state.site_url.clone(),
62 -
                header_html,
63 -
                footer_html,
48 +
                nav_links: ctx.nav_links,
49 +
                favicon_url: ctx.favicon_url,
50 +
                og_image_url: ctx.og_image_url,
51 +
                site_url: ctx.site_url,
52 +
                header_html: ctx.header_html,
53 +
                footer_html: ctx.footer_html,
64 54
            })
65 55
            .into_response()
66 56
        }
77 67
) -> Response {
78 68
    match db::get_post_by_slug(&state.db, &slug) {
79 69
        Ok(Some(post)) if post.status == "published" => {
70 +
            let ctx = SiteContext::from_state(&state);
80 71
            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 72
            WebTemplate(PostTemplate {
87 -
                blog_title,
88 -
                nav_links,
73 +
                blog_title: ctx.blog_title,
74 +
                nav_links: ctx.nav_links,
89 75
                post,
90 76
                rendered_content,
91 -
                favicon_url,
92 -
                og_image_url,
93 -
                site_url: state.site_url.clone(),
94 -
                header_html,
95 -
                footer_html,
77 +
                favicon_url: ctx.favicon_url,
78 +
                og_image_url: ctx.og_image_url,
79 +
                site_url: ctx.site_url,
80 +
                header_html: ctx.header_html,
81 +
                footer_html: ctx.footer_html,
96 82
            })
97 83
            .into_response()
98 84
        }
110 96
) -> Response {
111 97
    match db::get_page_by_slug(&state.db, &slug) {
112 98
        Ok(Some(page)) if page.is_published => {
99 +
            let ctx = SiteContext::from_state(&state);
113 100
            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 101
            WebTemplate(PageTemplate {
120 -
                blog_title,
121 -
                nav_links,
102 +
                blog_title: ctx.blog_title,
103 +
                nav_links: ctx.nav_links,
122 104
                page,
123 105
                rendered_content,
124 -
                favicon_url,
125 -
                og_image_url,
126 -
                site_url: state.site_url.clone(),
127 -
                header_html,
128 -
                footer_html,
106 +
                favicon_url: ctx.favicon_url,
107 +
                og_image_url: ctx.og_image_url,
108 +
                site_url: ctx.site_url,
109 +
                header_html: ctx.header_html,
110 +
                footer_html: ctx.footer_html,
129 111
            })
130 112
            .into_response()
131 113
        }
138 120
}
139 121
140 122
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);
123 +
    let ctx = SiteContext::from_state(&state);
147 124
148 125
    match db::get_published_posts(&state.db) {
149 126
        Ok(posts) => WebTemplate(PostsListTemplate {
150 -
            blog_title,
151 -
            nav_links,
127 +
            blog_title: ctx.blog_title,
128 +
            nav_links: ctx.nav_links,
152 129
            posts,
153 -
            favicon_url,
154 -
            og_image_url,
155 -
            site_url: state.site_url.clone(),
156 -
            header_html,
157 -
            footer_html,
130 +
            favicon_url: ctx.favicon_url,
131 +
            og_image_url: ctx.og_image_url,
132 +
            site_url: ctx.site_url,
133 +
            header_html: ctx.header_html,
134 +
            footer_html: ctx.footer_html,
158 135
        })
159 136
        .into_response(),
160 137
        Err(e) => {
165 142
}
166 143
167 144
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();
145 +
    let css = get_setting_or_default(&state.db, "custom_css");
172 146
    (
173 147
        StatusCode::OK,
174 148
        [(axum::http::header::CONTENT_TYPE, HeaderValue::from_static("text/css"))],
221 195
222 196
pub async fn rss_feed(State(state): State<Arc<AppState>>) -> Response {
223 197
    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();
198 +
    let blog_description = get_setting_or_default(&state.db, "blog_description");
228 199
    let site_url = &state.site_url;
229 200
230 201
    let posts = match db::get_published_posts(&state.db) {
apps/posts/src/server/mod.rs +101 −21
39 39
    blog_title: String,
40 40
    nav_links: Vec<NavLink>,
41 41
    favicon_url: String,
42 -
    og_image_url: String,
43 42
    header_html: String,
44 43
    footer_html: String,
45 44
}
296 295
}
297 296
298 297
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();
298 +
    let custom_header = get_setting_or_default(db, "custom_header");
299 +
    let custom_footer = get_setting_or_default(db, "custom_footer");
307 300
    let header_html = render_markdown(&custom_header);
308 301
    let footer_html = render_markdown(&custom_footer);
309 302
    (header_html, footer_html)
341 334
    if trimmed.is_empty() { None } else { Some(trimmed) }
342 335
}
343 336
337 +
fn get_setting_or_default(db: &Db, key: &str) -> String {
338 +
    db::get_setting(db, key).ok().flatten().unwrap_or_default()
339 +
}
340 +
344 341
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())
342 +
    let title = get_setting_or_default(db, "blog_title");
343 +
    if title.is_empty() { "My Blog".to_string() } else { title }
349 344
}
350 345
351 346
fn parse_nav_links(input: &str) -> Vec<NavLink> {
375 370
}
376 371
377 372
fn get_favicon_url(db: &Db) -> String {
378 -
    db::get_setting(db, "favicon_url")
379 -
        .ok()
380 -
        .flatten()
381 -
        .unwrap_or_default()
373 +
    get_setting_or_default(db, "favicon_url")
382 374
}
383 375
384 376
fn get_og_image_url(db: &Db) -> String {
385 -
    db::get_setting(db, "og_image_url")
386 -
        .ok()
387 -
        .flatten()
388 -
        .unwrap_or_default()
377 +
    get_setting_or_default(db, "og_image_url")
378 +
}
379 +
380 +
struct SiteContext {
381 +
    blog_title: String,
382 +
    nav_links: Vec<NavLink>,
383 +
    favicon_url: String,
384 +
    og_image_url: String,
385 +
    site_url: String,
386 +
    header_html: String,
387 +
    footer_html: String,
388 +
}
389 +
390 +
impl SiteContext {
391 +
    fn from_state(state: &AppState) -> Self {
392 +
        let (header_html, footer_html) = get_header_footer_html(&state.db);
393 +
        Self {
394 +
            blog_title: get_blog_title(&state.db),
395 +
            nav_links: get_nav_links(&state.db),
396 +
            favicon_url: get_favicon_url(&state.db),
397 +
            og_image_url: get_og_image_url(&state.db),
398 +
            site_url: state.site_url.clone(),
399 +
            header_html,
400 +
            footer_html,
401 +
        }
402 +
    }
389 403
}
390 404
391 405
fn render_latest_posts_embed(posts: &[&Post]) -> String {
416 430
    }
417 431
    html.push_str("</div>");
418 432
    html
433 +
}
434 +
435 +
fn post_to_markdown(post: &Post) -> String {
436 +
    use std::fmt::Write;
437 +
    let mut out = format!("---\ntitle: {}\nslug: {}\nstatus: {}", post.title, post.slug, post.status);
438 +
    let optional_fields: &[(&str, &Option<String>)] = &[
439 +
        ("published_date", &post.published_date),
440 +
        ("tags", &post.tags),
441 +
    ];
442 +
    for (key, value) in optional_fields {
443 +
        if let Some(v) = value {
444 +
            let _ = write!(out, "\n{}: {}", key, v);
445 +
        }
446 +
    }
447 +
    let _ = write!(out, "\nlang: {}", post.lang);
448 +
    let optional_tail: &[(&str, &Option<String>)] = &[
449 +
        ("alias", &post.alias),
450 +
        ("meta_image", &post.meta_image),
451 +
        ("description", &post.meta_description),
452 +
    ];
453 +
    for (key, value) in optional_tail {
454 +
        if let Some(v) = value {
455 +
            let _ = write!(out, "\n{}: {}", key, v);
456 +
        }
457 +
    }
458 +
    out.push_str("\n---\n\n");
459 +
    out.push_str(&post.content);
460 +
    out
461 +
}
462 +
463 +
fn build_zip(
464 +
    entries: &[(String, &[u8])],
465 +
    compression: zip::CompressionMethod,
466 +
) -> Vec<u8> {
467 +
    let mut buf = std::io::Cursor::new(Vec::new());
468 +
    {
469 +
        let mut zip = zip::ZipWriter::new(&mut buf);
470 +
        let options = zip::write::SimpleFileOptions::default().compression_method(compression);
471 +
        for (name, data) in entries {
472 +
            if let Err(e) = zip.start_file(name, options) {
473 +
                tracing::warn!("Failed to add {} to zip: {}", name, e);
474 +
                continue;
475 +
            }
476 +
            if let Err(e) = std::io::Write::write_all(&mut zip, data) {
477 +
                tracing::warn!("Failed to write {} to zip: {}", name, e);
478 +
            }
479 +
        }
480 +
        let _ = zip.finish();
481 +
    }
482 +
    buf.into_inner()
483 +
}
484 +
485 +
fn zip_response(bytes: Vec<u8>, filename: &str) -> axum::response::Response {
486 +
    use axum::response::IntoResponse;
487 +
    (
488 +
        axum::http::StatusCode::OK,
489 +
        [
490 +
            (axum::http::header::CONTENT_TYPE, "application/zip"),
491 +
            (
492 +
                axum::http::header::CONTENT_DISPOSITION,
493 +
                &format!("attachment; filename=\"{}\"", filename),
494 +
            ),
495 +
        ],
496 +
        bytes,
497 +
    )
498 +
        .into_response()
419 499
}
420 500
421 501
// --- Router ---