chore: small updates 229ec02e
Steve · 2026-04-25 11:56 7 file(s) · +14 −234
apps/library/.env.example +0 −1
4 4
HOST=127.0.0.1
5 5
PORT=3000
6 6
LIBRARY_DB_PATH=/data/library.sqlite
7 -
API_KEY=
8 7
GOOGLE_BOOKS_API_KEY=
apps/library/src/auth.rs +2 −39
1 1
use axum::{
2 2
    extract::{FromRef, FromRequestParts},
3 -
    http::{request::Parts, StatusCode},
3 +
    http::request::Parts,
4 4
    response::{IntoResponse, Redirect, Response},
5 5
};
6 6
use chrono::{Duration, Utc};
11 11
12 12
pub use andromeda_auth::{
13 13
    build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token,
14 -
    verify_api_key, verify_password,
14 +
    verify_password,
15 15
};
16 16
17 17
const SESSION_DAYS: i64 = 7;
58 58
    }
59 59
}
60 60
61 -
pub struct ApiAuth;
62 -
63 -
impl<S> FromRequestParts<S> for ApiAuth
64 -
where
65 -
    S: Send + Sync,
66 -
    Arc<AppState>: FromRef<S>,
67 -
{
68 -
    type Rejection = Response;
69 -
70 -
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
71 -
        let state = Arc::<AppState>::from_ref(state);
72 -
73 -
        if let Some(expected_key) = state.api_key.as_deref() {
74 -
            if let Some(header) = parts.headers.get(axum::http::header::AUTHORIZATION) {
75 -
                if let Ok(s) = header.to_str() {
76 -
                    if let Some(token) = s.strip_prefix("Bearer ").or_else(|| s.strip_prefix("bearer ")) {
77 -
                        if verify_api_key(token.trim(), expected_key) {
78 -
                            return Ok(ApiAuth);
79 -
                        }
80 -
                    }
81 -
                }
82 -
            }
83 -
        }
84 -
85 -
        if let Some(token) = extract_session_cookie(&parts.headers) {
86 -
            if is_valid_session(&state.db, &token) {
87 -
                return Ok(ApiAuth);
88 -
            }
89 -
        }
90 -
91 -
        Err((
92 -
            StatusCode::UNAUTHORIZED,
93 -
            axum::Json(serde_json::json!({ "error": "unauthorized" })),
94 -
        )
95 -
            .into_response())
96 -
    }
97 -
}
apps/library/src/db.rs +0 −27
165 165
    Ok(n > 0)
166 166
}
167 167
168 -
#[derive(Debug, Default, Clone, Copy, Serialize)]
169 -
pub struct StatusCounts {
170 -
    pub read: i64,
171 -
    pub reading: i64,
172 -
    pub want: i64,
173 -
}
174 -
175 -
pub fn count_by_status(db: &Db) -> Result<StatusCounts, DbError> {
176 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
177 -
    let mut stmt = conn.prepare("SELECT status, COUNT(*) FROM books GROUP BY status")?;
178 -
    let mut counts = StatusCounts::default();
179 -
    let rows = stmt.query_map([], |row| {
180 -
        let s: String = row.get(0)?;
181 -
        let n: i64 = row.get(1)?;
182 -
        Ok((s, n))
183 -
    })?;
184 -
    for r in rows {
185 -
        let (s, n) = r?;
186 -
        match s.as_str() {
187 -
            "read" => counts.read = n,
188 -
            "reading" => counts.reading = n,
189 -
            "want" => counts.want = n,
190 -
            _ => {}
191 -
        }
192 -
    }
193 -
    Ok(counts)
194 -
}
apps/library/src/main.rs +3 −128
20 20
use rust_embed::Embed;
21 21
use serde::Deserialize;
22 22
23 -
use crate::db::{Book, BookStatus, NewBook, StatusCounts};
23 +
use crate::db::{Book, BookStatus, NewBook};
24 24
25 25
#[derive(Embed)]
26 26
#[folder = "static/"]
29 29
pub struct AppState {
30 30
    pub db: Db,
31 31
    pub admin_password: Option<String>,
32 -
    pub api_key: Option<String>,
33 32
    pub google_books_api_key: Option<String>,
34 33
    pub cookie_secure: bool,
35 34
    pub base_url: String,
51 50
    tab: &'static str,
52 51
    tab_label: &'static str,
53 52
    books: Vec<BookView>,
54 -
    counts: StatusCounts,
55 53
}
56 54
57 55
#[derive(Template)]
76 74
    success: Option<String>,
77 75
    error: Option<String>,
78 76
    books: Vec<AdminBookRow>,
79 -
    api_key_configured: bool,
80 -
    google_key_configured: bool,
81 77
}
82 78
83 79
fn render_index(
96 92
            notes: b.notes,
97 93
        })
98 94
        .collect();
99 -
    let counts = db::count_by_status(&state.db).unwrap_or_default();
100 95
    Html(
101 96
        IndexTemplate {
102 97
            base_url: state.base_url.clone(),
103 98
            tab,
104 99
            tab_label: label,
105 100
            books,
106 -
            counts,
107 101
        }
108 102
        .render()
109 103
        .unwrap(),
216 210
            success: q.success,
217 211
            error: q.error,
218 212
            books,
219 -
            api_key_configured: state.api_key.is_some(),
220 -
            google_key_configured: state.google_books_api_key.is_some(),
221 213
        }
222 214
        .render()
223 215
        .unwrap(),
373 365
    }
374 366
}
375 367
376 -
#[derive(Deserialize)]
377 -
struct CreateBookBody {
378 -
    google_id: Option<String>,
379 -
    title: String,
380 -
    authors: String,
381 -
    isbn: Option<String>,
382 -
    cover_url: Option<String>,
383 -
    notes: Option<String>,
384 -
    status: String,
385 -
}
386 -
387 -
async fn api_create_book(
388 -
    _auth: auth::ApiAuth,
389 -
    State(state): State<Arc<AppState>>,
390 -
    Json(body): Json<CreateBookBody>,
391 -
) -> Response {
392 -
    let Some(status) = BookStatus::parse(&body.status) else {
393 -
        return (
394 -
            StatusCode::BAD_REQUEST,
395 -
            Json(serde_json::json!({ "error": "invalid status" })),
396 -
        )
397 -
            .into_response();
398 -
    };
399 -
    let new_book = NewBook {
400 -
        google_id: body.google_id,
401 -
        title: body.title,
402 -
        authors: body.authors,
403 -
        isbn: body.isbn,
404 -
        cover_url: body.cover_url,
405 -
        notes: body.notes,
406 -
        status,
407 -
    };
408 -
    match db::insert_book(&state.db, &new_book) {
409 -
        Ok(id) => match db::get_book(&state.db, id) {
410 -
            Ok(Some(book)) => (StatusCode::CREATED, Json(book)).into_response(),
411 -
            _ => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
412 -
        },
413 -
        Err(e) => {
414 -
            tracing::error!("create book: {e}");
415 -
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
416 -
        }
417 -
    }
418 -
}
419 -
420 -
#[derive(Deserialize)]
421 -
struct PatchBookBody {
422 -
    status: Option<String>,
423 -
    notes: Option<String>,
424 -
}
425 -
426 -
async fn api_patch_book(
427 -
    _auth: auth::ApiAuth,
428 -
    State(state): State<Arc<AppState>>,
429 -
    Path(id): Path<i64>,
430 -
    Json(body): Json<PatchBookBody>,
431 -
) -> Response {
432 -
    if let Some(s) = body.status.as_deref() {
433 -
        let Some(status) = BookStatus::parse(s) else {
434 -
            return (
435 -
                StatusCode::BAD_REQUEST,
436 -
                Json(serde_json::json!({ "error": "invalid status" })),
437 -
            )
438 -
                .into_response();
439 -
        };
440 -
        if let Err(e) = db::update_book_status(&state.db, id, status) {
441 -
            tracing::error!("update status: {e}");
442 -
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
443 -
        }
444 -
    }
445 -
    if let Some(notes) = body.notes.as_deref() {
446 -
        let trimmed = notes.trim();
447 -
        let n = if trimmed.is_empty() { None } else { Some(trimmed) };
448 -
        if let Err(e) = db::update_book_notes(&state.db, id, n) {
449 -
            tracing::error!("update notes: {e}");
450 -
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
451 -
        }
452 -
    }
453 -
    match db::get_book(&state.db, id) {
454 -
        Ok(Some(book)) => Json(book).into_response(),
455 -
        Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" })))
456 -
            .into_response(),
457 -
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
458 -
    }
459 -
}
460 -
461 -
async fn api_delete_book(
462 -
    _auth: auth::ApiAuth,
463 -
    State(state): State<Arc<AppState>>,
464 -
    Path(id): Path<i64>,
465 -
) -> Response {
466 -
    match db::delete_book(&state.db, id) {
467 -
        Ok(true) => StatusCode::NO_CONTENT.into_response(),
468 -
        Ok(false) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "not found" })))
469 -
            .into_response(),
470 -
        Err(e) => {
471 -
            tracing::error!("delete book: {e}");
472 -
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
473 -
        }
474 -
    }
475 -
}
476 -
477 -
async fn api_counts(State(state): State<Arc<AppState>>) -> Response {
478 -
    match db::count_by_status(&state.db) {
479 -
        Ok(counts) => Json(counts).into_response(),
480 -
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
481 -
    }
482 -
}
483 -
484 368
// ── main ─────────────────────────────────────────────────────────────────
485 369
486 370
#[tokio::main]
506 390
    let base_url =
507 391
        std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
508 392
509 -
    let api_key = std::env::var("API_KEY").ok().filter(|s| !s.is_empty());
510 -
    if api_key.is_none() {
511 -
        tracing::warn!("API_KEY not set; write API accessible via session cookie only");
512 -
    }
513 393
    let google_books_api_key = std::env::var("GOOGLE_BOOKS_API_KEY")
514 394
        .ok()
515 395
        .filter(|s| !s.is_empty());
517 397
    let state = Arc::new(AppState {
518 398
        db,
519 399
        admin_password: std::env::var("ADMIN_PASSWORD").ok(),
520 -
        api_key,
521 400
        google_books_api_key,
522 401
        cookie_secure,
523 402
        base_url,
537 416
        .route("/admin/books/{id}/delete", post(admin_delete_book));
538 417
539 418
    let api_router = Router::new()
540 -
        .route("/api/books", get(api_list_books).post(api_create_book))
541 -
        .route(
542 -
            "/api/books/{id}",
543 -
            get(api_get_book).patch(api_patch_book).delete(api_delete_book),
544 -
        )
545 -
        .route("/api/counts", get(api_counts));
419 +
        .route("/api/books", get(api_list_books))
420 +
        .route("/api/books/{id}", get(api_get_book));
546 421
547 422
    let app = Router::new()
548 423
        .route("/", get(root_redirect))
apps/library/src/templates/admin.html +1 −4
35 35
      </div>
36 36
      <div id="search-status" class="search-status" style="display:none;"></div>
37 37
      <div id="search-results" class="search-results"></div>
38 -
      <p class="hint">Google Books API key: {% if google_key_configured %}configured{% else %}not set (default rate limits apply){% endif %}</p>
39 38
    </section>
40 39
41 40
    <section class="admin-subs">
66 65
              <noscript><button type="submit">Save</button></noscript>
67 66
            </form>
68 67
            <form method="POST" action="/admin/books/{{ b.id }}/notes" class="inline notes-form">
69 -
              <textarea name="notes" rows="2" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea>
68 +
              <textarea name="notes" rows="5" placeholder="notes">{% if let Some(n) = b.notes %}{{ n }}{% endif %}</textarea>
70 69
              <button type="submit">Save notes</button>
71 70
            </form>
72 71
            <form method="POST" action="/admin/books/{{ b.id }}/delete" class="inline">
78 77
      </div>
79 78
      {% endif %}
80 79
    </section>
81 -
82 -
    <p class="hint">API key: {% if api_key_configured %}configured{% else %}not set (write API requires session cookie){% endif %}</p>
83 80
84 81
    <script>
85 82
      async function searchBooks() {
apps/library/src/templates/index.html +3 −6
29 29
    <div class="header">
30 30
      <a href="/" class="logo"><h1>LIBRARY</h1></a>
31 31
      <nav class="links">
32 +
        <a href="/read"{% if tab == "read" %} class="active"{% endif %}>read</a>
33 +
        <a href="/reading"{% if tab == "reading" %} class="active"{% endif %}>reading</a>
34 +
        <a href="/want"{% if tab == "want" %} class="active"{% endif %}>want to read</a>
32 35
        <a href="/admin">add</a>
33 36
      </nav>
34 37
    </div>
35 -
36 -
    <nav class="tabs">
37 -
      <a href="/read" class="tab{% if tab == "read" %} active{% endif %}">Read <span class="count">{{ counts.read }}</span></a>
38 -
      <a href="/reading" class="tab{% if tab == "reading" %} active{% endif %}">Reading <span class="count">{{ counts.reading }}</span></a>
39 -
      <a href="/want" class="tab{% if tab == "want" %} active{% endif %}">Want to Read <span class="count">{{ counts.want }}</span></a>
40 -
    </nav>
41 38
42 39
    {% if books.is_empty() %}
43 40
    <p class="no-books">No books in {{ tab_label }}.</p>
apps/library/static/styles.css +5 −29
8 8
  text-transform: uppercase;
9 9
}
10 10
11 -
/* Tabs */
12 -
13 -
.tabs {
14 -
  display: flex;
15 -
  gap: 1.5rem;
16 -
  padding: 0.75rem 0;
17 -
  border-bottom: 1px solid #333;
18 -
  margin-bottom: 1.5rem;
19 -
}
20 -
21 -
.tab {
22 -
  font-size: 14px;
23 -
  text-decoration: none;
24 -
  opacity: 0.5;
25 -
  display: inline-flex;
26 -
  align-items: center;
27 -
  gap: 0.4rem;
28 -
}
11 +
/* Active nav link (current tab) */
29 12
30 -
.tab:hover {
31 -
  opacity: 0.7;
32 -
}
33 -
34 -
.tab.active {
13 +
.links a.active {
35 14
  opacity: 1;
36 -
}
37 -
38 -
.tab .count {
39 -
  font-size: 12px;
40 -
  opacity: 0.5;
41 15
}
42 16
43 17
/* Books list */
173 147
174 148
.book-card.admin textarea {
175 149
  width: 100%;
150 +
  min-height: 1.6rem;
176 151
  font-family: inherit;
177 152
  font-size: 13px;
153 +
  line-height: 1.4;
178 154
  background: #121113;
179 155
  color: #ffffff;
180 156
  border: 1px solid #333;
181 -
  padding: 0.4rem;
157 +
  padding: 0.3rem 0.4rem;
182 158
  resize: vertical;
183 159
}
184 160