From 0851d5645916efbb6a8e580c06794b7f20e8ae8a Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 5 Sep 2026 04:36:08 +0000 Subject: [PATCH 1/3] Add [bookorbit] config, settings section and cached reader ids New BookorbitConfig section (enabled, URLs, OPDS user; password via DAILY_EPUB_BOOKORBIT__OPDS_PASS), its settings-dashboard group and secret masking, migration 0005 adding bookorbit_book_id/bookorbit_file_id to issues with Db::set_bookorbit_ids, and README rows for the new keys. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW --- README.md | 16 +++++++ config.example.toml | 7 +++ migrations/0005_bookorbit.sql | 2 + src/config.rs | 80 +++++++++++++++++++++++++++++++++++ src/db.rs | 68 ++++++++++++++++++++++++++++- src/web/dashboard/settings.rs | 23 +++++++++- 6 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 migrations/0005_bookorbit.sql diff --git a/README.md b/README.md index 2b7544d..97a1b31 100644 --- a/README.md +++ b/README.md @@ -233,6 +233,7 @@ these writes. |---|---|---| | `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. | | `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. | +| `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. | | `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. | | `GET/POST /login`, `POST /logout` | Public/session | Sign in and out; login attempts are throttled per client IP. | | `GET /account`, `POST /account/password`, `/account/logout-all` | User or admin | Change the current password or revoke sessions. | @@ -355,6 +356,11 @@ prints what resolved. | `editorial.summary_input_tokens` | `3000` | Article text offered to the summary prompt. | | `publish.epub_dir` | `/srv/bookorbit/libraries/daily-epub` | Both EPUB editions land here by atomic copy, and this is the directory the OPDS feed lists. The editions are distinguished by a `(X4)` tag in **both** the filename and `dc:title` — libraries and OPDS clients list books by title, so the filename alone would make them look identical. Point a BookOrbit watched folder at it if you want its UI too. **Renamed from `bookorbit_dir`**; the old key is a hard config error. | | `publish.xtc_dir` | `/var/lib/daily-epub/xtc` | XTC artifacts. **Not** listed in the OPDS feed — CrossPoint cannot acquire them — but downloadable at `/files/xtc/` for sideloading. | +| `bookorbit.enabled` | `false` | Enable the signed-in **Read in BookOrbit** integration when both OPDS credentials are set. | +| `bookorbit.public_url` | `https://bookorbit.hallada.net` | Browser-facing BookOrbit base URL. | +| `bookorbit.api_url` | `http://127.0.0.1:3498` | Server-facing BookOrbit base URL used for OPDS lookups. | +| `bookorbit.opds_user` | unset | Dedicated OPDS user created in BookOrbit's Settings → OPDS. | +| `bookorbit.opds_pass` | — | **`DAILY_EPUB_BOOKORBIT__OPDS_PASS`**, environment only. | | `xtc.enabled` | `true` | Set `false` to skip the converter entirely. | | `xtc.command` | `node` | Converter executable. | | `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends ` -o -f ` (plus `-c `). | @@ -699,6 +705,16 @@ This is deliberately independent of BookOrbit: it needs only the directory, so BookOrbit is optional, and it puts the day's issue one screen from the X4's home instead of several clicks down a library tree. +For desktop reading, the signed-in issue page can show a **Read in BookOrbit** +button that opens the Standard edition in BookOrbit's web reader. Create an OPDS +user in BookOrbit under Settings → OPDS, put its name in `config.toml`, put +`DAILY_EPUB_BOOKORBIT__OPDS_PASS` in `/etc/daily-epub/env`, set +`bookorbit.enabled = true`, and restart `daily-epub.service`; no systemd change +is needed because the unit already allows loopback HTTP. Book and file ids are +looked up lazily on the first click and cached on the `issues` row; if BookOrbit +re-indexes a book, `/issues//read?refresh=1` clears the cache and resolves +the ids again. + ### Why XTC is not in the feed XTC files are still generated and still land in `publish.xtc_dir` — they are just diff --git a/config.example.toml b/config.example.toml index e9c372f..8339ac3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -220,3 +220,10 @@ journal_lines = 300 # job-page journal tail; valid range 10..=5 # routes remain public. A signed-in web user can download without Basic auth. # basic_auth_user = "daily" # basic_auth_pass = "..." + +[bookorbit] +enabled = false +public_url = "https://bookorbit.hallada.net" # what the browser opens +api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host +opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS +# opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS) diff --git a/migrations/0005_bookorbit.sql b/migrations/0005_bookorbit.sql new file mode 100644 index 0000000..8985ae1 --- /dev/null +++ b/migrations/0005_bookorbit.sql @@ -0,0 +1,2 @@ +ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER; +ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER; diff --git a/src/config.rs b/src/config.rs index 63a1f54..2e27e19 100644 --- a/src/config.rs +++ b/src/config.rs @@ -82,6 +82,7 @@ pub struct Config { pub publish: PublishConfig, pub xtc: XtcConfig, pub server: ServerConfig, + pub bookorbit: BookorbitConfig, } impl Default for Config { @@ -106,6 +107,7 @@ impl Default for Config { publish: PublishConfig::default(), xtc: XtcConfig::default(), server: ServerConfig::default(), + bookorbit: BookorbitConfig::default(), } } } @@ -728,6 +730,60 @@ impl Default for ServerConfig { } } +/// `[bookorbit]` — optional web-reader integration via BookOrbit's OPDS API. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct BookorbitConfig { + /// Whether the signed-in BookOrbit reader integration is enabled. + pub enabled: bool, + /// Base URL opened in the reader's browser. + pub public_url: String, + /// Base URL used for server-side OPDS requests. + pub api_url: String, + /// Dedicated BookOrbit OPDS username. + pub opds_user: Option, + /// Dedicated BookOrbit OPDS password; supply via + /// `DAILY_EPUB_BOOKORBIT__OPDS_PASS`. + pub opds_pass: Option, +} + +impl Default for BookorbitConfig { + fn default() -> Self { + Self { + enabled: false, + public_url: "https://bookorbit.hallada.net".into(), + api_url: "http://127.0.0.1:3498".into(), + opds_user: None, + opds_pass: None, + } + } +} + +impl BookorbitConfig { + /// Whether the integration is enabled and has non-empty OPDS credentials. + pub fn is_active(&self) -> bool { + self.enabled + && self + .opds_user + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && self + .opds_pass + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + } + + /// Browser-facing base URL without trailing slashes. + pub fn public_url(&self) -> &str { + self.public_url.trim_end_matches('/') + } + + /// Server-facing API base URL without trailing slashes. + pub fn api_url(&self) -> &str { + self.api_url.trim_end_matches('/') + } +} + /// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are /// a stale-configuration error. const LLM_ROLE_KEYS: &[&str] = &[ @@ -1267,6 +1323,11 @@ mod tests { assert_eq!(c.curation.feedback.verdicts_in_prompt, 60); assert_eq!(c.xtc.format, XtcFormat::Xtch); assert_eq!(c.curation.sections.len(), 8); + assert!(!c.bookorbit.enabled); + assert_eq!(c.bookorbit.public_url, "https://bookorbit.hallada.net"); + assert_eq!(c.bookorbit.api_url, "http://127.0.0.1:3498"); + assert!(c.bookorbit.opds_user.is_none()); + assert!(c.bookorbit.opds_pass.is_none()); c.validate().unwrap(); } @@ -1296,6 +1357,7 @@ mod tests { jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token"); jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12"); jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2"); + jail.set_env("DAILY_EPUB_BOOKORBIT__OPDS_PASS", "orbit-secret"); jail.set_env("DAILY_EPUB_VOYAGE__API_KEY", "voyage-key"); jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false"); jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key"); @@ -1324,6 +1386,7 @@ mod tests { assert_eq!(c.miniflux.api_key.as_deref(), Some("secret-token")); assert_eq!(c.target_article_count, 12); assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2")); + assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret")); // untouched default assert_eq!(c.retention_days, 21); assert_eq!(c.timezone, "America/New_York"); @@ -1331,6 +1394,23 @@ mod tests { }); } + #[test] + fn bookorbit_activation_and_url_accessors() { + let mut bookorbit = BookorbitConfig { + enabled: true, + public_url: "https://books.example///".into(), + api_url: "http://127.0.0.1:3498/".into(), + opds_user: Some("reader".into()), + opds_pass: Some("secret".into()), + }; + assert!(bookorbit.is_active()); + assert_eq!(bookorbit.public_url(), "https://books.example"); + assert_eq!(bookorbit.api_url(), "http://127.0.0.1:3498"); + + bookorbit.opds_pass = Some(" ".into()); + assert!(!bookorbit.is_active()); + } + #[test] fn explicit_missing_path_is_an_error() { assert!(matches!( diff --git a/src/db.rs b/src/db.rs index 683f81f..0676264 100644 --- a/src/db.rs +++ b/src/db.rs @@ -71,6 +71,8 @@ pub struct IssueRow { pub front_page_html: Option, pub report_json: Option, pub issue_json: Option, + pub bookorbit_book_id: Option, + pub bookorbit_file_id: Option, } #[derive(Debug, Clone)] @@ -537,7 +539,8 @@ impl Db { pub async fn issue_by_date(&self, date: Date) -> Result> { let row = sqlx::query( "SELECT date, issue_number, generated_at, epub_path, x4_path, xtc_path, - front_page_html, report_json, issue_json + front_page_html, report_json, issue_json, + bookorbit_book_id, bookorbit_file_id FROM issues WHERE date = ?", ) .bind(date.to_string()) @@ -546,6 +549,25 @@ impl Db { row.as_ref().map(issue_from_row).transpose() } + /// Store or clear the BookOrbit reader ids cached for an issue date. + pub async fn set_bookorbit_ids(&self, date: Date, ids: Option<(i64, i64)>) -> Result<()> { + let (book_id, file_id) = match ids { + Some((book_id, file_id)) => (Some(book_id), Some(file_id)), + None => (None, None), + }; + sqlx::query( + "UPDATE issues + SET bookorbit_book_id = ?, bookorbit_file_id = ? + WHERE date = ?", + ) + .bind(book_id) + .bind(file_id) + .bind(date.to_string()) + .execute(&self.pool) + .await?; + Ok(()) + } + /// Issue archive rows, newest first. A non-positive limit means all rows. pub async fn issue_dates(&self, limit: Option) -> Result> { let rows = sqlx::query( @@ -969,6 +991,8 @@ fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result { front_page_html: row.get("front_page_html"), report_json: row.get("report_json"), issue_json: row.get("issue_json"), + bookorbit_book_id: row.get("bookorbit_book_id"), + bookorbit_file_id: row.get("bookorbit_file_id"), }) } @@ -1226,6 +1250,48 @@ mod tests { assert!(spend.values().all(|usd| *usd == 0.0)); } + #[tokio::test] + async fn bookorbit_ids_round_trip_and_clear() { + let (_dir, db) = temp_db().await; + let date: Date = "2026-08-15".parse().unwrap(); + db.upsert_issue( + date, + 1, + ts("2026-08-15T05:36:00Z"), + Some("The Daily EPUB - 2026-08-15.epub"), + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + + db.set_bookorbit_ids(date, Some((42, 84))).await.unwrap(); + db.upsert_issue( + date, + 1, + ts("2026-08-15T05:37:00Z"), + None, + None, + None, + None, + None, + None, + ) + .await + .unwrap(); + let issue = db.issue_by_date(date).await.unwrap().unwrap(); + assert_eq!(issue.bookorbit_book_id, Some(42)); + assert_eq!(issue.bookorbit_file_id, Some(84)); + + db.set_bookorbit_ids(date, None).await.unwrap(); + let issue = db.issue_by_date(date).await.unwrap().unwrap(); + assert_eq!(issue.bookorbit_book_id, None); + assert_eq!(issue.bookorbit_file_id, None); + } + async fn record_run(db: &Db, date: Date, started: &str, deepseek: f64, anthropic: f64) { use crate::report::{ProviderUsage, RunReport}; let started_at = ts(started); diff --git a/src/web/dashboard/settings.rs b/src/web/dashboard/settings.rs index 8a316f2..0ecf3f1 100644 --- a/src/web/dashboard/settings.rs +++ b/src/web/dashboard/settings.rs @@ -217,10 +217,12 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[ ("server.hmac_secret", FieldKind::Secret), ("server.basic_auth_user", FieldKind::Text), ("server.basic_auth_pass", FieldKind::Secret), + ("bookorbit.opds_user", FieldKind::Text), + ("bookorbit.opds_pass", FieldKind::Secret), ("xtc.settings", FieldKind::Path), ]; -const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass"]; +const SECRET_SUFFIXES: &[&str] = &["api_key", "hmac_secret", "basic_auth_pass", "opds_pass"]; const PATH_KEYS: &[&str] = &[ "database_path", @@ -256,6 +258,7 @@ const GROUP_ORDER: &[&str] = &[ "xtc", "server", "miniflux", + "bookorbit", ]; /// Help text per key, seeded from the README configuration table and the @@ -367,6 +370,11 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[ ("server.login_window_minutes", "Length of the login throttle window."), ("server.jobs_enabled", "Allow the dashboard to start the fixed systemd job catalogue."), ("server.journal_lines", "Journal lines shown on a dashboard job page (10-5000)."), + ("bookorbit.enabled", "Enable the signed-in Read in BookOrbit integration when OPDS credentials are also set."), + ("bookorbit.public_url", "Base URL opened in the browser for BookOrbit's web reader."), + ("bookorbit.api_url", "Base URL used by the server for BookOrbit OPDS requests; usually the loopback address."), + ("bookorbit.opds_user", "Dedicated OPDS user created in BookOrbit Settings → OPDS."), + ("bookorbit.opds_pass", "Password for bookorbit.opds_user. Environment only."), ]; /// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2). @@ -1654,6 +1662,8 @@ mod tests { "server.hmac_secret", "xtc.settings", "server.basic_auth_user", + "bookorbit.opds_user", + "bookorbit.opds_pass", ] { field(&groups, path); } @@ -1684,6 +1694,7 @@ mod tests { "xtc", "server", "miniflux", + "bookorbit", ] ); let anthropic = groups @@ -1799,6 +1810,7 @@ mod tests { config.voyage.api_key = Some("hunter2-voyage".into()); config.server.hmac_secret = Some("hunter2-hmac".into()); config.server.basic_auth_pass = Some("hunter2-basic".into()); + config.bookorbit.opds_pass = Some("hunter2-bookorbit".into()); if let Some(provider) = config.providers.get_mut("deepseek") { provider.api_key = Some("hunter2-deepseek".into()); } @@ -1808,6 +1820,7 @@ mod tests { "voyage.api_key", "server.hmac_secret", "server.basic_auth_pass", + "bookorbit.opds_pass", "providers.deepseek.api_key", "providers.anthropic.api_key", ] { @@ -2405,6 +2418,11 @@ mod tests { async fn settings_pages_render_save_and_show_hand_edits() { let dir = tempfile::tempdir().unwrap(); let path = copy_example(dir.path()); + let with_secret = std::fs::read_to_string(&path).unwrap().replace( + "# opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS)", + "opds_pass = \"hunter2-bookorbit\"", + ); + std::fs::write(&path, with_secret).unwrap(); let (_db_dir, state, _) = test_state(Some(&path)).await; let app = router(state.clone()); let cookie = login(&app).await; @@ -2421,12 +2439,15 @@ mod tests { "id=\"curation.ranking\"", "id=\"curation.ranking.weights.preliminary\"", "id=\"providers.gemini\"", + "id=\"bookorbit\"", ] { assert!(html.contains(anchor), "{anchor}"); } assert!(html.contains("name=\"curation.ranking.deep_keep\"")); assert!(html.contains("renormalized")); assert!(html.contains("not set")); + assert!(html.contains("in the config file — move it to the env file")); + assert!(!html.contains("hunter2-bookorbit")); assert!(html.contains("/dashboard/settings/history")); let saved = app From 0f45c34fdb4c65c12b3b2082251f904bc385514c Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 5 Sep 2026 04:33:05 +0000 Subject: [PATCH 2/3] Add the BookOrbit OPDS client module Searches BookOrbit's OPDS catalog for an issue's Standard edition and extracts the book/file ids the web reader route needs. Hand-rolled Atom parsing, no new dependencies; unit-tested against the real feed shape. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW --- src/bookorbit.rs | 508 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 2 files changed, 509 insertions(+) create mode 100644 src/bookorbit.rs diff --git a/src/bookorbit.rs b/src/bookorbit.rs new file mode 100644 index 0000000..9b7623a --- /dev/null +++ b/src/bookorbit.rs @@ -0,0 +1,508 @@ +//! BookOrbit OPDS catalog lookup support. +//! +//! BookOrbit is the companion library and browser-based EPUB reader used by +//! this service. Its OPDS catalog is preferable to its JSON API here because +//! OPDS uses static HTTP Basic credentials and therefore needs no JWT/refresh +//! token lifecycle. Searches use `/api/v1/opds/catalog?q=`, acquisition +//! links expose `/api/v1/opds//download?fileId=`, and browser +//! links use `/read//`. See +//! `docs/plans/2026-09-05-bookorbit-read-link.md` for the integration design. + +use jiff::civil::Date; +use reqwest::header::ACCEPT; + +const ACQUISITION_REL: &str = "http://opds-spec.org/acquisition"; +const DOWNLOAD_PREFIX: &str = "/api/v1/opds/"; + +/// The BookOrbit book and file identifiers required by its reader route. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BookorbitIds { + /// BookOrbit's identifier for the issue's book record. + pub book_id: i64, + /// BookOrbit's identifier for the EPUB file attached to the book. + pub file_id: i64, +} + +/// A failure while querying or parsing BookOrbit's OPDS catalog. +#[derive(Debug, thiserror::Error)] +pub enum BookorbitError { + /// The OPDS endpoint could not be reached. + #[error("BookOrbit unreachable: {0}")] + Unreachable(#[source] reqwest::Error), + /// BookOrbit rejected the configured OPDS Basic credentials. + #[error("BookOrbit rejected the OPDS credentials")] + Unauthorized, + /// BookOrbit returned an unexpected non-success status. + #[error("BookOrbit returned HTTP {0}")] + Status(reqwest::StatusCode), + /// BookOrbit returned a body that could not be read as the expected feed. + #[error("BookOrbit returned an unreadable OPDS feed: {0}")] + Malformed(String), +} + +/// Search BookOrbit's OPDS catalog for the Standard edition of the issue dated `date`. +/// +/// `api_url` must have no trailing slash. `Ok(None)` means BookOrbit has not +/// indexed the issue yet. +pub async fn find_issue( + client: &reqwest::Client, + api_url: &str, + opds_user: &str, + opds_pass: &str, + issue_title: &str, + date: Date, +) -> Result, BookorbitError> { + let response = client + .get(format!("{api_url}/api/v1/opds/catalog")) + .query(&[("q", date.to_string())]) + .basic_auth(opds_user, Some(opds_pass)) + .header(ACCEPT, "application/atom+xml") + .send() + .await + .map_err(BookorbitError::Unreachable)?; + + let status = response.status(); + if matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { + return Err(BookorbitError::Unauthorized); + } + if !status.is_success() { + return Err(BookorbitError::Status(status)); + } + + let feed_xml = response.text().await.map_err(|error| { + if error.is_timeout() || error.is_connect() { + BookorbitError::Unreachable(error) + } else { + BookorbitError::Malformed(error.to_string()) + } + })?; + let ids = select_issue_entry(&feed_xml, issue_title, date)?; + if let Some(ids) = ids { + tracing::info!( + book_id = ids.book_id, + file_id = ids.file_id, + "resolved BookOrbit issue" + ); + } + Ok(ids) +} + +/// Select the Standard issue from an Atom feed without performing network I/O. +pub fn select_issue_entry( + feed_xml: &str, + issue_title: &str, + date: Date, +) -> Result, BookorbitError> { + if find_open_tag(feed_xml, "feed", 0).is_none() { + return Err(BookorbitError::Malformed( + "response does not contain an Atom element".to_string(), + )); + } + + let fallback_title = format!("The Daily EPUB - {date}"); + let mut fallback_entry = None; + + for entry in entry_bodies(feed_xml) { + let Some(title) = element_text(entry, "title") else { + continue; + }; + let title = xml_unescape(title); + let title = title.trim(); + if title.ends_with("(X4)") { + continue; + } + if title == issue_title { + return acquisition_ids(entry); + } + if title == fallback_title && fallback_entry.is_none() { + fallback_entry = Some(entry); + } + } + + fallback_entry.map_or(Ok(None), acquisition_ids) +} + +/// Build the public BookOrbit reader URL for `ids`. +/// +/// `public_url` must have no trailing slash. +pub fn reader_url(public_url: &str, ids: BookorbitIds) -> String { + format!("{public_url}/read/{}/{}", ids.book_id, ids.file_id) +} + +fn entry_bodies(feed_xml: &str) -> Vec<&str> { + let mut entries = Vec::new(); + let mut cursor = 0; + + while let Some(start) = find_open_tag(feed_xml, "entry", cursor) { + let Some(open_end_offset) = feed_xml[start..].find('>') else { + break; + }; + let body_start = start + open_end_offset + 1; + let Some(close_offset) = feed_xml[body_start..].find("") else { + break; + }; + let body_end = body_start + close_offset; + entries.push(&feed_xml[body_start..body_end]); + cursor = body_end + "".len(); + } + + entries +} + +fn element_text<'a>(xml: &'a str, name: &str) -> Option<&'a str> { + let start = find_open_tag(xml, name, 0)?; + let open_end = start + xml[start..].find('>')?; + let text_start = open_end + 1; + let close = format!(""); + let text_end = text_start + xml[text_start..].find(&close)?; + Some(&xml[text_start..text_end]) +} + +fn find_open_tag(xml: &str, name: &str, mut cursor: usize) -> Option { + let needle = format!("<{name}"); + while let Some(offset) = xml[cursor..].find(&needle) { + let start = cursor + offset; + let after_name = start + needle.len(); + if xml + .as_bytes() + .get(after_name) + .is_some_and(|byte| byte.is_ascii_whitespace() || matches!(byte, b'>' | b'/')) + { + return Some(start); + } + cursor = after_name; + } + None +} + +fn acquisition_ids(entry: &str) -> Result, BookorbitError> { + let mut cursor = 0; + let mut saw_acquisition = false; + + while let Some(start) = find_open_tag(entry, "link", cursor) { + let Some(end_offset) = entry[start..].find('>') else { + break; + }; + let end = start + end_offset; + let attributes = &entry[start + "(attributes: &'a str, wanted: &str) -> Option<&'a str> { + let bytes = attributes.as_bytes(); + let mut cursor = 0; + + while cursor < bytes.len() { + while cursor < bytes.len() && (bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b'/') + { + cursor += 1; + } + let name_start = cursor; + while cursor < bytes.len() + && !bytes[cursor].is_ascii_whitespace() + && !matches!(bytes[cursor], b'=' | b'/' | b'>') + { + cursor += 1; + } + if name_start == cursor { + cursor += 1; + continue; + } + let name = &attributes[name_start..cursor]; + + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + if bytes.get(cursor) != Some(&b'=') { + continue; + } + cursor += 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() { + cursor += 1; + } + + let quote = *bytes.get(cursor)?; + if !matches!(quote, b'\'' | b'"') { + return None; + } + cursor += 1; + let value_start = cursor; + while cursor < bytes.len() && bytes[cursor] != quote { + cursor += 1; + } + if cursor == bytes.len() { + return None; + } + let value = &attributes[value_start..cursor]; + cursor += 1; + if name == wanted { + return Some(value); + } + } + + None +} + +fn parse_download_href(href: &str) -> Option { + let path = href.strip_prefix(DOWNLOAD_PREFIX)?; + let (book_id, query) = path.split_once("/download?fileId=")?; + if book_id.is_empty() || !book_id.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + let file_id = match query.split_once('&') { + Some((file_id, extra_params)) + if !extra_params.is_empty() && !extra_params.split('&').any(str::is_empty) => + { + file_id + } + Some(_) => return None, + None => query, + }; + if file_id.is_empty() || !file_id.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + + Some(BookorbitIds { + book_id: book_id.parse().ok()?, + file_id: file_id.parse().ok()?, + }) +} + +fn xml_unescape(text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut cursor = 0; + + while let Some(offset) = text[cursor..].find('&') { + let ampersand = cursor + offset; + output.push_str(&text[cursor..ampersand]); + let entity_start = ampersand + 1; + let Some(end_offset) = text[entity_start..].find(';') else { + output.push_str(&text[ampersand..]); + return output; + }; + let entity_end = entity_start + end_offset; + let entity = &text[entity_start..entity_end]; + if let Some(character) = decode_entity(entity) { + output.push(character); + } else { + output.push_str(&text[ampersand..=entity_end]); + } + cursor = entity_end + 1; + } + + output.push_str(&text[cursor..]); + output +} + +fn decode_entity(entity: &str) -> Option { + match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + _ => entity + .strip_prefix("#x") + .or_else(|| entity.strip_prefix("#X")) + .and_then(|digits| u32::from_str_radix(digits, 16).ok()) + .or_else(|| { + entity + .strip_prefix('#') + .and_then(|digits| digits.parse().ok()) + }) + .and_then(char::from_u32), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const REAL_SHAPE_FEED: &str = r#" + + urn:bookorbit:catalog + BookOrbit Catalog + + The Daily EPUB — 2026-09-05 (X4) + urn:bookorbit:book:410 + + + + The Daily EPUB — 2026-09-05 + urn:bookorbit:book:411 + + +"#; + + fn date() -> Date { + "2026-09-05".parse().expect("date") + } + + fn feed(entries: &str) -> String { + format!(r#"{entries}"#) + } + + #[test] + fn exact_title_match_returns_the_right_ids() { + let xml = feed( + r#"Another book + The Daily EPUB — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 31, + file_id: 47 + }) + ); + } + + #[test] + fn x4_entry_listed_first_is_skipped_for_standard() { + assert_eq!( + select_issue_entry(REAL_SHAPE_FEED, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 411, + file_id: 902 + }) + ); + } + + #[test] + fn hyphen_form_fallback_works_without_em_dash_title() { + let xml = feed( + r#"The Daily EPUB - 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 52, + file_id: 81 + }) + ); + } + + #[test] + fn exact_title_outranks_an_earlier_fallback() { + let xml = feed( + r#"The Daily EPUB - 2026-09-05 + The Daily EPUB — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 3, + file_id: 4 + }) + ); + } + + #[test] + fn no_matching_entry_returns_none() { + let xml = feed( + r#"Unrelated"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + None + ); + assert_eq!( + select_issue_entry(&feed(""), "The Daily EPUB — 2026-09-05", date()).unwrap(), + None + ); + } + + #[test] + fn malformed_acquisition_href_is_an_error_for_a_match() { + let xml = feed( + r#"The Daily EPUB — 2026-09-05"#, + ); + + assert!(matches!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()), + Err(BookorbitError::Malformed(_)) + )); + } + + #[test] + fn title_entities_are_unescaped_before_comparison() { + let xml = feed( + r#"Books & News — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "Books & News — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 12, + file_id: 13 + }) + ); + } + + #[test] + fn attributed_entry_and_reordered_link_attributes_parse() { + let xml = feed( + r#"The Daily EPUB — 2026-09-05"#, + ); + + assert_eq!( + select_issue_entry(&xml, "The Daily EPUB — 2026-09-05", date()).unwrap(), + Some(BookorbitIds { + book_id: 63, + file_id: 64 + }) + ); + } + + #[test] + fn reader_url_formats_the_reader_route() { + assert_eq!( + reader_url( + "https://bookorbit.example", + BookorbitIds { + book_id: 14, + file_id: 29 + } + ), + "https://bookorbit.example/read/14/29" + ); + } + + #[test] + fn body_without_feed_is_malformed() { + assert!(matches!( + select_issue_entry( + "not an Atom feed", + "The Daily EPUB — 2026-09-05", + date() + ), + Err(BookorbitError::Malformed(_)) + )); + } +} diff --git a/src/lib.rs b/src/lib.rs index 915b00a..e193a30 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ //! ``` pub mod auth; +pub mod bookorbit; pub mod comments; pub mod config; pub mod curate; From 9aff78ebf1ce04219d61abd62004b2e703f73e52 Mon Sep 17 00:00:00 2001 From: Tyler Hallada Date: Sat, 5 Sep 2026 04:54:07 +0000 Subject: [PATCH 3/3] Add the Read in BookOrbit button and /issues/{date}/read redirect The signed-in issue page shows a Read in BookOrbit button (only when the integration is active and the Standard EPUB exists) that hits /issues/{date}/read. The route redirects to the cached BookOrbit reader URL, or resolves the ids through OPDS on first click and caches them; ?refresh=1 re-resolves. Not indexed yet is a 503, upstream failures 502. Includes the implementation plan. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW --- docs/plans/2026-09-05-bookorbit-read-link.md | 106 ++++++ src/types.rs | 9 +- src/web/issue.rs | 338 ++++++++++++++++++- src/web/mod.rs | 15 + src/web/templates/issue_full.html | 2 +- 5 files changed, 453 insertions(+), 17 deletions(-) create mode 100644 docs/plans/2026-09-05-bookorbit-read-link.md diff --git a/docs/plans/2026-09-05-bookorbit-read-link.md b/docs/plans/2026-09-05-bookorbit-read-link.md new file mode 100644 index 0000000..fc2991e --- /dev/null +++ b/docs/plans/2026-09-05-bookorbit-read-link.md @@ -0,0 +1,106 @@ +# "Read in BookOrbit" link on the issue page + +**Date:** 2026-09-05 +**Repository:** `thallada/the-daily-epub` +**Status:** small implementation plan, ready to execute +**Builds on:** `docs/plans/2026-09-03-web-dashboard.md` (the signed-in issue page this adds a button to) + +Written for a fresh implementation agent. Every fact about BookOrbit below was verified on 2026-09-05 against its source (`github.com/bookorbit/bookorbit`, `HEAD`) and against the live instance on this host. Do not re-derive them; do re-check anything marked *assumption*. + +--- + +## 1. Goal + +On the signed-in issue page (`src/web/templates/issue_full.html`), next to the existing **Download EPUB / X4 EPUB / XTC** buttons, add a **Read in BookOrbit** button that opens the *normal* (Standard) edition of that issue in BookOrbit's in-browser EPUB reader at `https://bookorbit.hallada.net`. The operator reads on desktop through this link instead of downloading. + +Out of scope: the X4 edition, the public (anonymous) issue page, the OPDS feed, the EPUB itself. + +## 2. Verified facts + +### About this repo + +- The pipeline does **not** upload to BookOrbit. `publish::publish_issue` atomically copies both EPUB editions into `publish.epub_dir` (`/home/thallada/bookorbit/books/daily-epub` on the host); BookOrbit's *watched folder* scanner picks them up later. Nothing in the run ever learns a BookOrbit id. +- BookOrbit runs on the same host as Docker (`~/bookorbit/docker-compose.yml`): app container `bookorbit-app` published on `127.0.0.1:3498`, Postgres in `bookorbit-db` with **no published port**, and the `daily-epub` service user has no Docker socket access. Reading BookOrbit's database directly is therefore not an option. +- Issue titles: `Issue::title()` is `The Daily EPUB — ` (em dash), and `title_for(Edition::Standard)` is that exact string while `title_for(Edition::X4)` appends ` (X4)` (`src/types.rs` ~531–544). Filenames use a hyphen instead: `publish::issue_filename(date, edition, "epub")` → `The Daily EPUB - .epub` / `... (X4).epub`. +- The signed-in issue view is built in `src/web/issue.rs`: `IssueView` (line ~33) carries `downloads: Vec`, assembled around line 258 from the `issues` row (`row.epub_path`, `row.x4_path`, `row.xtc_path`). The template renders them at `src/web/templates/issue_full.html:6` as `Download …`. +- Signed-in issue routes live in the `full_issues` router in `src/web/mod.rs` (~line 659: `/issues/{date}/articles/{article_id}`, `/issues/{date}/world`, `/issues/{date}/behind`, behind `login_required!` and `map_forbidden`); public routes (`/issues`, `/issues/{date}`) are on the root router ~line 682. Put the new route in **`full_issues`**. +- `issues` table (`migrations/0001_init.sql:70`): `date TEXT PK, issue_number, generated_at, epub_path, x4_path, xtc_path, front_page_html, report_json` (+ `issue_json` added later). Migrations run via `sqlx::migrate!("./migrations")` (`src/db.rs:24`); latest file is `0004_web.sql`, so the new one is `0005_bookorbit.sql`. `Db::issue_by_date(date) -> Option` is at `src/db.rs:537`; `upsert_issue` at `:496` (do not touch it, ids are resolved lazily, see §3). +- Config: sections are `#[serde(deny_unknown_fields, default)]` structs hung off `Config` (`src/config.rs:82–85`, e.g. `pub server: ServerConfig`), with `Default` impls. Env overrides are `DAILY_EPUB_
__`. `config.example.toml` documents every key; the README has a configuration table (~line 350). +- The settings dashboard (`src/web/dashboard/settings.rs`) has a **hardcoded section registry** (`const` list around line 248–259 ending `"publish", "xtc", "server", "miniflux"`), a secret-field list (`("server.basic_auth_pass", FieldKind::Secret)` ~line 219, plus `SECRET_SUFFIXES` at ~223), per-key help text (~line 360), and a test asserting the section list (~line 1683). A new section must be added to all of these or the settings page/test breaks. +- Shared HTTP client: `crate::http::build_client(timeout)` (`src/http.rs:14`), rustls + gzip, no cookies. reqwest 0.13 with the `query` and `json` features is already a dependency; **no XML parser is in the tree** — the OPDS response is tiny and regular, so either add `quick-xml` or match with a small hand parser (see §4). + +### About BookOrbit + +- Web reader route (client router, `client/src/router/index.ts`): **`/read/:bookId/:fileId`**. Both ids are required numeric integers. Book detail page is `/book/:bookId`. +- An unauthenticated visit to `/read/...` serves the SPA shell (200) and the client auth guard redirects to `/login?redirect=`, returning to the reader after sign-in. So the link can point straight at the reader. +- OPDS (server `server/src/modules/opds/opds.service.ts`, `BASE = '/api/v1/opds'`): + - Auth is **HTTP Basic** against a dedicated *OPDS user* created in BookOrbit's settings (separate static credentials, not the web login). Verified live: `GET http://127.0.0.1:3498/api/v1/opds/catalog?q=Daily` → `401`, `www-authenticate: Basic realm="bookorbit OPDS"`. + - Search: `GET /api/v1/opds/catalog?q=` (Atom acquisition feed; server-side `ILIKE` on title, accent-insensitive). Paged with `?page=N`. + - Each `` contains ``, `urn:bookorbit:book:`, and one acquisition link per file: + ``. + **That href carries both numbers the reader URL needs.** Each daily issue edition is its own book with a single EPUB file. +- The JSON API (`/api/v1/books/search?q=`, `/api/v1/books/:id` with `files[].id`) exists too but needs a JWT from `/auth/login` (5/min throttle, refresh cookie). Not worth it; OPDS is enough. +- Book titles come from the EPUB `dc:title`, so the Standard edition is titled `The Daily EPUB — 2026-09-05` and the X4 one `The Daily EPUB — 2026-09-05 (X4)`. *Assumption:* BookOrbit's metadata fetcher has not renamed them; the matcher in §4 tolerates a fallback to the filename form. + +## 3. Design decisions (settled) + +| Topic | Decision | Why | +|---|---|---| +| Where ids are resolved | **Lazily, on click**, by a new redirect route `GET /issues/{date}/read` in the signed-in router. | BookOrbit indexes the folder some time after the run ends, so the id does not exist at publish time. Rendering the issue page must stay free of cross-service calls (see perf work in `main` 17a8804 and earlier). | +| Caching | Two nullable columns on `issues`: `bookorbit_book_id INTEGER`, `bookorbit_file_id INTEGER`. Filled on first successful lookup; later clicks redirect from the cache with no network call. | One OPDS call per issue, ever. | +| Stale cache | If BookOrbit answers the *reader page*, we can't tell from a redirect. Instead: `GET /issues/{date}/read?refresh=1` clears the cached ids and re-resolves. Expose it as a tiny "wrong book?" link only if trivial; otherwise document the query param in the README. | Books can be deleted/re-added in BookOrbit, changing ids. | +| Not indexed yet | Route returns **503** with the existing error template (`src/web/templates/error.html`), message "BookOrbit has not indexed this issue yet. Try again in a minute." Nothing cached. | Honest, cheap, retry-friendly. | +| Which edition | Standard only. The entry whose `` equals `issue.title_for(Edition::Standard)` **exactly**; entries ending in ` (X4)` are never chosen. | The whole point is desktop reading. | +| Auth to BookOrbit | HTTP Basic with an OPDS user's credentials from config. | Static creds, no token lifecycle; same mechanism KOReader already uses. | +| Feature flag | Whole feature off unless `[bookorbit] enabled = true` **and** the three credentials/URL keys are set. Button and route are absent when off. | Repo stays usable without BookOrbit, as today. | +| Link target | `<public_url>/read/<bookId>/<fileId>`, `public_url` from config (`https://bookorbit.hallada.net`), opened with `target="_blank" rel="noopener"`. | BookOrbit's own login redirect handles the signed-out case. | + +## 4. Implementation steps + +1. **Config** (`src/config.rs`, `config.example.toml`, README table): + ```toml + [bookorbit] + enabled = false + public_url = "https://bookorbit.hallada.net" # what the browser opens + api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host + opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS + # opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS) + ``` + `BookorbitConfig { enabled: bool, public_url: String, api_url: String, opds_user: Option<String>, opds_pass: Option<String> }` with `deny_unknown_fields, default`. Add `pub bookorbit: BookorbitConfig` to `Config` and its `Default`. Add a `fn is_active(&self) -> bool` (enabled + user + pass non-empty). Validate URLs have no trailing slash (or trim). + +2. **Settings dashboard** (`src/web/dashboard/settings.rs`): add `"bookorbit"` to the section list, `("bookorbit.opds_pass", FieldKind::Secret)` (and `"opds_pass"` to `SECRET_SUFFIXES`), help text for the five keys, update the section-list test. Copy the pattern used for `server.basic_auth_pass`. + +3. **Migration** `migrations/0005_bookorbit.sql`: + ```sql + ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER; + ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER; + ``` + Extend `IssueRow` and `issue_by_date`'s `SELECT` with the two columns; add `Db::set_bookorbit_ids(date, Option<(i64, i64)>)` (`None` clears). + +4. **OPDS client** — new module `src/bookorbit.rs` (~100 lines): + - `pub async fn find_issue(client: &reqwest::Client, cfg: &BookorbitConfig, issue_title: &str, date: Date) -> Result<Option<(i64, i64)>>`. + - `GET {api_url}/api/v1/opds/catalog?q={date}` with `.basic_auth(user, Some(pass))` and `Accept: application/atom+xml`. Searching by the ISO date string avoids em-dash/hyphen and ILIKE-escaping questions, and returns at most the two editions. + - Parse entries: split on `<entry>`…`</entry>`, take `<title>` (XML-unescape `&` etc.), and the first acquisition `href` matching `^/api/v1/opds/(\d+)/download\?fileId=(\d+)$`. Choose the entry whose title equals `issue_title` exactly; if none, accept the title equal to the filename stem `The Daily EPUB - <date>` (hyphen form); never accept a title ending in `(X4)`. Prefer `quick-xml` if you'd rather not hand-roll; either is fine, keep it small and unit-tested against a fixture built from the real XML shape in §2. + - Map `401/403` to a clear error ("BookOrbit rejected the OPDS credentials"), connection errors to "BookOrbit unreachable". Timeout 5 s via `http::build_client`. + - Log at `info` on first resolution with both ids. + +5. **Route** `GET /issues/{date}/read` (`src/web/issue.rs`, registered in `src/web/mod.rs` next to `/issues/{date}/behind`, same session guard as the other signed-in issue routes): + - 404 if the feature is inactive or the issue row is missing. + - If `refresh=1`, clear cached ids first. + - Cached ids → `303 See Other` to `{public_url}/read/{book}/{file}`. + - Else call `bookorbit::find_issue`; on `Some`, store and redirect; on `None`, 503 with the "not indexed yet" message; on `Err`, 502 with the error text (operator-only page, so the text can be specific). + - Hold a `reqwest::Client` in `WebState` (`src/web/mod.rs:144`) or build one per call; per-call is acceptable at this frequency. + +6. **Template**: add `read_href: Option<String>` to `IssueView` (set to `/issues/{date}/read` when the feature is active and `row.epub_path` is present, i.e. only alongside a real Standard EPUB). In `issue_full.html:6`, render `<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>` **before** the download buttons, inside the same flex row, and keep the row visible when it is the only button. + +7. **Tests**: unit tests for the XML matcher (exact title, X4 skipped, hyphen fallback, no match, malformed href); a `src/web` test that the route is 404 when inactive and 303 to the right URL when ids are cached (no network). Follow `downloads_are_listed_only_while_the_files_exist` (`src/web/issue.rs:~2059`) for the fixture style. + +8. **Docs/ops**: README configuration table rows for `[bookorbit]`; a note under Delivery: create an OPDS user in BookOrbit (Settings → OPDS), put its name in `config.toml` and the password in `/etc/daily-epub/env` as `DAILY_EPUB_BOOKORBIT__OPDS_PASS`, restart `daily-epub.service`. No systemd change is needed: `systemd/daily-epub.service` sets only `RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX` and no `IPAddressDeny`, so loopback HTTP to port 3498 is allowed. + +## 5. Acceptance + +- With the section absent or `enabled = false`: no button, `/issues/<date>/read` → 404, all existing tests pass. +- With valid OPDS credentials: first click on today's issue → 303 to `https://bookorbit.hallada.net/read/<n>/<m>` and the `issues` row now has both ids; second click makes no request to BookOrbit (check the journal). +- Clicking before BookOrbit has scanned the new file → 503 "not indexed yet", nothing cached, later click succeeds. +- The X4 edition is never the target even though it matches the same search. +- `cargo fmt`, `cargo clippy`, `cargo test` clean. diff --git a/src/types.rs b/src/types.rs index e2ca0b6..5d0df8d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -526,10 +526,17 @@ pub struct IssueMeta { pub reading_minutes: i64, } +/// The issue name for a date, without an edition tag: +/// "The Daily EPUB — 2026-08-15". Shared by [`IssueMeta::title`] and callers +/// that only have a date (the BookOrbit lookup matches OPDS titles against it). +pub fn issue_title(date: Date) -> String { + format!("The Daily EPUB — {date}") +} + impl IssueMeta { /// The issue's name, without an edition tag: "The Daily EPUB — 2026-08-15". pub fn title(&self) -> String { - format!("The Daily EPUB — {}", self.date) + issue_title(self.date) } /// `dc:title` for one edition: [`title`](Self::title) plus the edition tag diff --git a/src/web/issue.rs b/src/web/issue.rs index b33683d..de91834 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -1,13 +1,15 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::Read; use std::path::{Path as FsPath, PathBuf}; +use std::time::Duration; use anyhow::Context; use askama::Template; -use axum::extract::{Extension, Path, State}; -use axum::response::{IntoResponse, Response}; +use axum::extract::{Extension, Path, Query, State}; +use axum::response::{IntoResponse, Redirect, Response}; use axum_login::tower_sessions::Session; use jiff::civil::Date; +use serde::Deserialize; use sqlx::Row; use crate::db::Db; @@ -32,6 +34,8 @@ pub struct Download { #[derive(Debug, Clone)] pub struct IssueView { pub issue: Issue, + /// Signed-in BookOrbit redirect route when the Standard EPUB can be downloaded. + pub read_href: Option<String>, pub downloads: Vec<Download>, pub from_json: bool, pub world_html: Option<String>, @@ -255,18 +259,21 @@ pub async fn load( } else { recover_world_html(&row, config) }; - let downloads = [ - ( - "EPUB", - row.epub_path.as_deref(), - Some(config.publish.epub_dir.join(crate::publish::issue_filename( - date, - Edition::Standard, - "epub", - ))), + let standard_download = download( + "EPUB", + row.epub_path.as_deref(), + Some(config.publish.epub_dir.join(crate::publish::issue_filename( + date, + Edition::Standard, "epub", - ), - ( + ))), + "epub", + ); + let read_href = (config.bookorbit.is_active() && standard_download.is_some()) + .then(|| format!("/issues/{date}/read")); + let downloads = [ + standard_download, + download( "X4 EPUB", row.x4_path.as_deref(), Some(config.publish.epub_dir.join(crate::publish::issue_filename( @@ -276,13 +283,14 @@ pub async fn load( ))), "epub", ), - ("XTC", row.xtc_path.as_deref(), None, "xtc"), + download("XTC", row.xtc_path.as_deref(), None, "xtc"), ] .into_iter() - .filter_map(|(label, raw, fallback, kind)| download(label, raw, fallback, kind)) + .flatten() .collect(); Ok(Some(IssueView { issue, + read_href, downloads, from_json, world_html, @@ -881,6 +889,7 @@ struct IssueFullTemplate { issue_number: i64, stats_line: String, front_page_html: String, + read_href: Option<String>, downloads: Vec<Download>, sections: Vec<FullSection>, has_world: bool, @@ -1003,6 +1012,7 @@ pub async fn render_full( issue_number: view.issue.meta.issue_number, stats_line: view.issue.meta.stats_line(), front_page_html: view.issue.editorial.front_page_html.clone(), + read_href: view.read_href, downloads: view.downloads, sections, has_world: view.issue.world_briefing.is_some() || view.world_html.is_some(), @@ -1186,6 +1196,96 @@ pub async fn behind( .into_response()) } +/// Query parameters accepted by the BookOrbit reader redirect. +#[derive(Debug, Default, Deserialize)] +pub struct ReadQuery { + #[serde(default)] + refresh: Option<String>, +} + +/// Open the Standard edition of an issue in BookOrbit's browser reader. +pub async fn read( + State(state): State<AppState>, + auth: AuthSession, + Path(date): Path<Date>, + Query(query): Query<ReadQuery>, +) -> Result<Response, WebError> { + let _viewer = auth + .user() + .await + .map(Viewer::from) + .ok_or_else(|| WebError::Unauthenticated { + next: format!("/issues/{date}/read"), + })?; + let config = state.config(); + if !config.bookorbit.is_active() { + return Err(WebError::NotFound); + } + let Some(row) = state.db.issue_by_date(date).await? else { + return Err(WebError::NotFound); + }; + let refresh = query.refresh.as_deref() == Some("1"); + if refresh { + state.db.set_bookorbit_ids(date, None).await?; + } else if let (Some(book_id), Some(file_id)) = (row.bookorbit_book_id, row.bookorbit_file_id) { + let ids = crate::bookorbit::BookorbitIds { book_id, file_id }; + return Ok(Redirect::to(&crate::bookorbit::reader_url( + config.bookorbit.public_url(), + ids, + )) + .into_response()); + } + + let client = crate::http::build_client(Duration::from_secs(5)) + .map_err(|error| WebError::BadGateway(format!("BookOrbit error: {error}")))?; + let opds_user = config + .bookorbit + .opds_user + .as_deref() + .ok_or(WebError::NotFound)?; + let opds_pass = config + .bookorbit + .opds_pass + .as_deref() + .ok_or(WebError::NotFound)?; + let issue_title = crate::types::issue_title(date); + match crate::bookorbit::find_issue( + &client, + config.bookorbit.api_url(), + opds_user, + opds_pass, + &issue_title, + date, + ) + .await + { + Ok(Some(ids)) => { + state + .db + .set_bookorbit_ids(date, Some((ids.book_id, ids.file_id))) + .await?; + tracing::info!( + %date, + book_id = ids.book_id, + file_id = ids.file_id, + "cached BookOrbit issue ids" + ); + Ok(Redirect::to(&crate::bookorbit::reader_url( + config.bookorbit.public_url(), + ids, + )) + .into_response()) + } + Ok(None) => Err(WebError::ServiceUnavailable( + "BookOrbit has not indexed this issue yet. Try again in a minute.".to_string(), + )), + Err(error) => { + tracing::warn!(%date, %error, "BookOrbit issue lookup failed"); + Err(WebError::BadGateway(error.to_string())) + } + } +} + fn article_href(date: Date, article_id: ArticleId) -> String { format!("/issues/{date}/articles/{article_id}") } @@ -2091,6 +2191,214 @@ mod tests { assert!(!issue.contains("Download XTC")); } + #[tokio::test] + async fn bookorbit_read_is_hidden_and_not_found_when_inactive() { + let (_dir, db, source) = seeded_issue(false).await; + crate::web::users::add(&db, "reader", "correct horse battery", false) + .await + .unwrap(); + let app = crate::server::router(crate::server::AppState::new( + db, + crate::config::Config::default(), + None, + )); + let cookie = login_cookie(&app, "reader", "correct horse battery").await; + + let issue = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/issues/{}", source.meta.date)) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let issue = response_text(issue).await; + assert!(issue.contains("Download EPUB")); + assert!(!issue.contains("Read in BookOrbit")); + + let read = app + .oneshot( + Request::builder() + .uri(format!("/issues/{}/read", source.meta.date)) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn bookorbit_read_button_and_cached_redirect_use_the_public_url() { + let (dir, db, source) = seeded_issue(true).await; + crate::web::users::add(&db, "reader", "correct horse battery", false) + .await + .unwrap(); + let epub_dir = dir.path().join("epubs"); + std::fs::create_dir(&epub_dir).unwrap(); + std::fs::write( + epub_dir.join(crate::publish::issue_filename( + source.meta.date, + Edition::Standard, + "epub", + )), + b"standard epub", + ) + .unwrap(); + db.set_bookorbit_ids(source.meta.date, Some((12, 34))) + .await + .unwrap(); + + let mut config = crate::config::Config::default(); + config.publish.epub_dir = epub_dir.clone(); + config.bookorbit.enabled = true; + config.bookorbit.opds_user = Some("reader".into()); + config.bookorbit.opds_pass = Some("secret".into()); + config.bookorbit.api_url = "http://127.0.0.1:9".into(); + let app = crate::server::router(crate::server::AppState::new( + db.clone(), + config.clone(), + None, + )); + let cookie = login_cookie(&app, "reader", "correct horse battery").await; + + let issue = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/issues/{}", source.meta.date)) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let issue = response_text(issue).await; + assert!(issue.contains("Read in BookOrbit")); + assert!(issue.contains(&format!("href=\"/issues/{}/read\"", source.meta.date))); + + let read = app + .oneshot( + Request::builder() + .uri(format!("/issues/{}/read", source.meta.date)) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::SEE_OTHER); + assert_eq!( + read.headers().get(header::LOCATION).unwrap(), + "https://bookorbit.hallada.net/read/12/34" + ); + + config.bookorbit.public_url = "https://example.test/".into(); + let app = crate::server::router(crate::server::AppState::new(db, config, None)); + let cookie = login_cookie(&app, "reader", "correct horse battery").await; + let read = app + .oneshot( + Request::builder() + .uri(format!("/issues/{}/read", source.meta.date)) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::SEE_OTHER); + assert_eq!( + read.headers().get(header::LOCATION).unwrap(), + "https://example.test/read/12/34" + ); + } + + #[tokio::test] + async fn bookorbit_read_button_is_hidden_without_the_standard_epub() { + let (dir, db, source) = seeded_issue(true).await; + crate::web::users::add(&db, "reader", "correct horse battery", false) + .await + .unwrap(); + let mut config = crate::config::Config::default(); + config.publish.epub_dir = dir.path().join("missing-epubs"); + config.bookorbit.enabled = true; + config.bookorbit.opds_user = Some("reader".into()); + config.bookorbit.opds_pass = Some("secret".into()); + let app = crate::server::router(crate::server::AppState::new(db, config, None)); + let cookie = login_cookie(&app, "reader", "correct horse battery").await; + + let issue = app + .oneshot( + Request::builder() + .uri(format!("/issues/{}", source.meta.date)) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let issue = response_text(issue).await; + assert!(!issue.contains("Read in BookOrbit")); + assert!(!issue.contains(&format!("href=\"/issues/{}/read\"", source.meta.date))); + } + + #[tokio::test] + async fn bookorbit_read_reports_upstream_connection_errors() { + let (_dir, db, source) = seeded_issue(true).await; + crate::web::users::add(&db, "reader", "correct horse battery", false) + .await + .unwrap(); + let mut config = crate::config::Config::default(); + config.bookorbit.enabled = true; + config.bookorbit.opds_user = Some("reader".into()); + config.bookorbit.opds_pass = Some("secret".into()); + config.bookorbit.api_url = "http://127.0.0.1:9".into(); + let app = crate::server::router(crate::server::AppState::new(db, config, None)); + let cookie = login_cookie(&app, "reader", "correct horse battery").await; + + let read = app + .oneshot( + Request::builder() + .uri(format!("/issues/{}/read", source.meta.date)) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::BAD_GATEWAY); + assert!(response_text(read).await.contains("BookOrbit")); + } + + #[tokio::test] + async fn anonymous_bookorbit_read_redirects_to_login() { + let (_dir, db, source) = seeded_issue(true).await; + let mut config = crate::config::Config::default(); + config.bookorbit.enabled = true; + config.bookorbit.opds_user = Some("reader".into()); + config.bookorbit.opds_pass = Some("secret".into()); + let app = crate::server::router(crate::server::AppState::new(db, config, None)); + + let read = app + .oneshot( + Request::builder() + .uri(format!("/issues/{}/read", source.meta.date)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(read.status(), StatusCode::FOUND); + assert_eq!( + read.headers().get(header::LOCATION).unwrap(), + format!("/login?next=%2Fissues%2F{}%2Fread", source.meta.date).as_str() + ); + } + #[test] fn file_sizes_are_human_readable() { assert_eq!(format_file_size(42), "42 B"); diff --git a/src/web/mod.rs b/src/web/mod.rs index 9daf497..add7d8b 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -456,6 +456,12 @@ pub enum WebError { Unauthenticated { next: String }, #[error("bad request: {0}")] BadRequest(String), + /// BookOrbit has not indexed the requested issue yet. + #[error("service unavailable: {0}")] + ServiceUnavailable(String), + /// A BookOrbit OPDS request failed. + #[error("bad gateway: {0}")] + BadGateway(String), #[error("request origin did not match this site")] Csrf, #[error(transparent)] @@ -511,6 +517,14 @@ impl IntoResponse for WebError { Self::BadRequest(ref message) => { (StatusCode::BAD_REQUEST, "Bad request", message.as_str()) } + Self::ServiceUnavailable(ref message) => ( + StatusCode::SERVICE_UNAVAILABLE, + "Not indexed yet", + message.as_str(), + ), + Self::BadGateway(ref message) => { + (StatusCode::BAD_GATEWAY, "BookOrbit error", message.as_str()) + } Self::Db(ref error) => { tracing::error!(%error, "web database request failed"); ( @@ -660,6 +674,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App .route("/issues/{date}/articles/{article_id}", get(issue::article)) .route("/issues/{date}/world", get(issue::world)) .route("/issues/{date}/behind", get(issue::behind)) + .route("/issues/{date}/read", get(issue::read)) .route_layer(login_required!( session::Backend, login_url = "/login", diff --git a/src/web/templates/issue_full.html b/src/web/templates/issue_full.html index 33d2d12..0f72e9f 100644 --- a/src/web/templates/issue_full.html +++ b/src/web/templates/issue_full.html @@ -3,7 +3,7 @@ <article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0"> <header class="mb-8 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ display_date }} · No. {{ issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ stats_line }}</p></header> <section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section> - {% if !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %} + {% if read_href.is_some() || !downloads.is_empty() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% for download in downloads %}<a class="btn" href="{{ download.href }}">Download {{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %} <section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2> {% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %} </section>