chore: small updates
229ec02e
7 file(s) · +14 −234
| 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= |
| 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 | - | } |
|
| 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 | - | } |
| 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)) |
|
| 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() { |
|
| 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> |
| 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 | ||