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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
This commit is contained in:
2026-09-05 04:36:08 +00:00
co-authored by Claude Fable 5.1
parent 6c2d74c734
commit 0851d56459
6 changed files with 194 additions and 2 deletions
+16
View File
@@ -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`, `/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}/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 /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/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. | | `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. | | `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.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/<name>` for sideloading. | | `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/<name>` 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.enabled` | `true` | Set `false` to skip the converter entirely. |
| `xtc.command` | `node` | Converter executable. | | `xtc.command` | `node` | Converter executable. |
| `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`). | | `xtc.args` | `["/opt/epub-to-xtc-converter/cli/index.js", "convert"]` | Prefix; the code appends `<input.epub> -o <output> -f <format>` (plus `-c <settings>`). |
@@ -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 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. 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/<date>/read?refresh=1` clears the cache and resolves
the ids again.
### Why XTC is not in the feed ### Why XTC is not in the feed
XTC files are still generated and still land in `publish.xtc_dir` — they are just XTC files are still generated and still land in `publish.xtc_dir` — they are just
+7
View File
@@ -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. # routes remain public. A signed-in web user can download without Basic auth.
# basic_auth_user = "daily" # basic_auth_user = "daily"
# basic_auth_pass = "..." # 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)
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER;
ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER;
+80
View File
@@ -82,6 +82,7 @@ pub struct Config {
pub publish: PublishConfig, pub publish: PublishConfig,
pub xtc: XtcConfig, pub xtc: XtcConfig,
pub server: ServerConfig, pub server: ServerConfig,
pub bookorbit: BookorbitConfig,
} }
impl Default for Config { impl Default for Config {
@@ -106,6 +107,7 @@ impl Default for Config {
publish: PublishConfig::default(), publish: PublishConfig::default(),
xtc: XtcConfig::default(), xtc: XtcConfig::default(),
server: ServerConfig::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<String>,
/// Dedicated BookOrbit OPDS password; supply via
/// `DAILY_EPUB_BOOKORBIT__OPDS_PASS`.
pub opds_pass: Option<String>,
}
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 /// Config keys that moved from `[deepseek]` to `[llm]`; anywhere else they are
/// a stale-configuration error. /// a stale-configuration error.
const LLM_ROLE_KEYS: &[&str] = &[ const LLM_ROLE_KEYS: &[&str] = &[
@@ -1267,6 +1323,11 @@ mod tests {
assert_eq!(c.curation.feedback.verdicts_in_prompt, 60); assert_eq!(c.curation.feedback.verdicts_in_prompt, 60);
assert_eq!(c.xtc.format, XtcFormat::Xtch); assert_eq!(c.xtc.format, XtcFormat::Xtch);
assert_eq!(c.curation.sections.len(), 8); 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(); c.validate().unwrap();
} }
@@ -1296,6 +1357,7 @@ mod tests {
jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token"); jail.set_env("DAILY_EPUB_MINIFLUX__API_KEY", "secret-token");
jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12"); jail.set_env("DAILY_EPUB_TARGET_ARTICLE_COUNT", "12");
jail.set_env("DAILY_EPUB_SERVER__HMAC_SECRET", "hunter2"); 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__API_KEY", "voyage-key");
jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false"); jail.set_env("DAILY_EPUB_VOYAGE__ENABLED", "false");
jail.set_env("DAILY_EPUB_PROVIDERS__GEMINI__API_KEY", "gemini-key"); 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.miniflux.api_key.as_deref(), Some("secret-token"));
assert_eq!(c.target_article_count, 12); assert_eq!(c.target_article_count, 12);
assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2")); assert_eq!(c.server.hmac_secret.as_deref(), Some("hunter2"));
assert_eq!(c.bookorbit.opds_pass.as_deref(), Some("orbit-secret"));
// untouched default // untouched default
assert_eq!(c.retention_days, 21); assert_eq!(c.retention_days, 21);
assert_eq!(c.timezone, "America/New_York"); 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] #[test]
fn explicit_missing_path_is_an_error() { fn explicit_missing_path_is_an_error() {
assert!(matches!( assert!(matches!(
+67 -1
View File
@@ -71,6 +71,8 @@ pub struct IssueRow {
pub front_page_html: Option<String>, pub front_page_html: Option<String>,
pub report_json: Option<String>, pub report_json: Option<String>,
pub issue_json: Option<String>, pub issue_json: Option<String>,
pub bookorbit_book_id: Option<i64>,
pub bookorbit_file_id: Option<i64>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -537,7 +539,8 @@ impl Db {
pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> { pub async fn issue_by_date(&self, date: Date) -> Result<Option<IssueRow>> {
let row = sqlx::query( let row = sqlx::query(
"SELECT date, issue_number, generated_at, epub_path, x4_path, xtc_path, "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 = ?", FROM issues WHERE date = ?",
) )
.bind(date.to_string()) .bind(date.to_string())
@@ -546,6 +549,25 @@ impl Db {
row.as_ref().map(issue_from_row).transpose() 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. /// Issue archive rows, newest first. A non-positive limit means all rows.
pub async fn issue_dates(&self, limit: Option<i64>) -> Result<Vec<IssueListRow>> { pub async fn issue_dates(&self, limit: Option<i64>) -> Result<Vec<IssueListRow>> {
let rows = sqlx::query( let rows = sqlx::query(
@@ -969,6 +991,8 @@ fn issue_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<IssueRow> {
front_page_html: row.get("front_page_html"), front_page_html: row.get("front_page_html"),
report_json: row.get("report_json"), report_json: row.get("report_json"),
issue_json: row.get("issue_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)); 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) { async fn record_run(db: &Db, date: Date, started: &str, deepseek: f64, anthropic: f64) {
use crate::report::{ProviderUsage, RunReport}; use crate::report::{ProviderUsage, RunReport};
let started_at = ts(started); let started_at = ts(started);
+22 -1
View File
@@ -217,10 +217,12 @@ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
("server.hmac_secret", FieldKind::Secret), ("server.hmac_secret", FieldKind::Secret),
("server.basic_auth_user", FieldKind::Text), ("server.basic_auth_user", FieldKind::Text),
("server.basic_auth_pass", FieldKind::Secret), ("server.basic_auth_pass", FieldKind::Secret),
("bookorbit.opds_user", FieldKind::Text),
("bookorbit.opds_pass", FieldKind::Secret),
("xtc.settings", FieldKind::Path), ("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] = &[ const PATH_KEYS: &[&str] = &[
"database_path", "database_path",
@@ -256,6 +258,7 @@ const GROUP_ORDER: &[&str] = &[
"xtc", "xtc",
"server", "server",
"miniflux", "miniflux",
"bookorbit",
]; ];
/// Help text per key, seeded from the README configuration table and the /// 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.login_window_minutes", "Length of the login throttle window."),
("server.jobs_enabled", "Allow the dashboard to start the fixed systemd job catalogue."), ("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)."), ("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). /// `DAILY_EPUB_` + the path upper-cased with `.` → `__` (§13.1 item 2).
@@ -1654,6 +1662,8 @@ mod tests {
"server.hmac_secret", "server.hmac_secret",
"xtc.settings", "xtc.settings",
"server.basic_auth_user", "server.basic_auth_user",
"bookorbit.opds_user",
"bookorbit.opds_pass",
] { ] {
field(&groups, path); field(&groups, path);
} }
@@ -1684,6 +1694,7 @@ mod tests {
"xtc", "xtc",
"server", "server",
"miniflux", "miniflux",
"bookorbit",
] ]
); );
let anthropic = groups let anthropic = groups
@@ -1799,6 +1810,7 @@ mod tests {
config.voyage.api_key = Some("hunter2-voyage".into()); config.voyage.api_key = Some("hunter2-voyage".into());
config.server.hmac_secret = Some("hunter2-hmac".into()); config.server.hmac_secret = Some("hunter2-hmac".into());
config.server.basic_auth_pass = Some("hunter2-basic".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") { if let Some(provider) = config.providers.get_mut("deepseek") {
provider.api_key = Some("hunter2-deepseek".into()); provider.api_key = Some("hunter2-deepseek".into());
} }
@@ -1808,6 +1820,7 @@ mod tests {
"voyage.api_key", "voyage.api_key",
"server.hmac_secret", "server.hmac_secret",
"server.basic_auth_pass", "server.basic_auth_pass",
"bookorbit.opds_pass",
"providers.deepseek.api_key", "providers.deepseek.api_key",
"providers.anthropic.api_key", "providers.anthropic.api_key",
] { ] {
@@ -2405,6 +2418,11 @@ mod tests {
async fn settings_pages_render_save_and_show_hand_edits() { async fn settings_pages_render_save_and_show_hand_edits() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = copy_example(dir.path()); 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 (_db_dir, state, _) = test_state(Some(&path)).await;
let app = router(state.clone()); let app = router(state.clone());
let cookie = login(&app).await; let cookie = login(&app).await;
@@ -2421,12 +2439,15 @@ mod tests {
"id=\"curation.ranking\"", "id=\"curation.ranking\"",
"id=\"curation.ranking.weights.preliminary\"", "id=\"curation.ranking.weights.preliminary\"",
"id=\"providers.gemini\"", "id=\"providers.gemini\"",
"id=\"bookorbit\"",
] { ] {
assert!(html.contains(anchor), "{anchor}"); assert!(html.contains(anchor), "{anchor}");
} }
assert!(html.contains("name=\"curation.ranking.deep_keep\"")); assert!(html.contains("name=\"curation.ranking.deep_keep\""));
assert!(html.contains("renormalized")); assert!(html.contains("renormalized"));
assert!(html.contains("not set")); 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")); assert!(html.contains("/dashboard/settings/history"));
let saved = app let saved = app