diff --git a/README.md b/README.md index 999e7a7..1ed4f14 100644 --- a/README.md +++ b/README.md @@ -394,7 +394,7 @@ 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.enabled` | `false` | Enable the admin-only **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. | @@ -753,7 +753,7 @@ 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** +For desktop reading, the issue page can show admins 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 diff --git a/src/publish.rs b/src/publish.rs index 014e101..9ea9f68 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -460,7 +460,7 @@ fn xml_escape(s: &str) -> String { } /// Percent-encode one URL path segment (filenames contain spaces and parens). -fn percent_encode(s: &str) -> String { +pub fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for byte in s.as_bytes() { match byte { diff --git a/src/web/issue.rs b/src/web/issue.rs index be11e6b..0cb803d 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -643,7 +643,8 @@ fn download( let name = path.file_name()?.to_str()?; Some(Download { label: label.to_string(), - href: format!("/files/{kind}/{}", crate::web::encode_component(name)), + // A path segment, not a query string: spaces must be %20, never `+`. + href: format!("/files/{kind}/{}", crate::publish::percent_encode(name)), size_bytes: metadata.len(), size: format_file_size(metadata.len()), }) @@ -1224,13 +1225,16 @@ pub async fn read( Path(date): Path, Query(query): Query, ) -> Result { - let _viewer = auth + let viewer = auth .user() .await .map(Viewer::from) .ok_or_else(|| WebError::Unauthenticated { next: format!("/issues/{date}/read"), })?; + if viewer.role != crate::web::users::Role::Admin { + return Err(WebError::Forbidden); + } let config = state.config(); if !config.bookorbit.is_active() { return Err(WebError::NotFound); @@ -2253,13 +2257,21 @@ mod tests { ["X4 EPUB", "XTC"] ); + let primary_href = downloads.primary.href.clone(); + assert!( + primary_href.starts_with("/files/epub/The%20Daily%20EPUB%20-%20"), + "{primary_href}" + ); + assert!(!primary_href.contains('+'), "{primary_href}"); + 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 + .clone() .oneshot( Request::builder() .uri(format!("/issues/{}", source.meta.date)) - .header(header::COOKIE, cookie) + .header(header::COOKIE, &cookie) .body(Body::empty()) .unwrap(), ) @@ -2268,6 +2280,18 @@ mod tests { let issue = response_text(issue).await; assert!(issue.contains(">Download EPUB")); assert!(issue.contains("Choose download format")); + // The link the page renders must resolve to the file it names. + let file = app + .oneshot( + Request::builder() + .uri(primary_href) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(file.status(), StatusCode::OK); assert!(issue.contains("Standard EPUB")); assert!(issue.contains("X4 EPUB")); assert!(issue.contains("XTC")); @@ -2279,7 +2303,7 @@ mod tests { #[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) + crate::web::users::add(&db, "admin", "correct horse battery", true) .await .unwrap(); let app = crate::server::router(crate::server::AppState::new( @@ -2287,7 +2311,7 @@ mod tests { crate::config::Config::default(), None, )); - let cookie = login_cookie(&app, "reader", "correct horse battery").await; + let cookie = login_cookie(&app, "admin", "correct horse battery").await; let issue = app .clone() @@ -2320,7 +2344,7 @@ mod tests { #[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) + crate::web::users::add(&db, "admin", "correct horse battery", true) .await .unwrap(); let epub_dir = dir.path().join("epubs"); @@ -2349,7 +2373,7 @@ mod tests { config.clone(), None, )); - let cookie = login_cookie(&app, "reader", "correct horse battery").await; + let cookie = login_cookie(&app, "admin", "correct horse battery").await; let issue = app .clone() @@ -2384,7 +2408,7 @@ mod tests { 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 cookie = login_cookie(&app, "admin", "correct horse battery").await; let read = app .oneshot( Request::builder() @@ -2402,6 +2426,62 @@ mod tests { ); } + #[tokio::test] + async fn bookorbit_read_is_admin_only() { + 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; + 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 + .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(">Download EPUB")); + + 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::FORBIDDEN); + } + #[tokio::test] async fn bookorbit_read_button_is_hidden_without_the_standard_epub() { let (dir, db, source) = seeded_issue(true).await; @@ -2434,7 +2514,7 @@ mod tests { #[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) + crate::web::users::add(&db, "admin", "correct horse battery", true) .await .unwrap(); let mut config = crate::config::Config::default(); @@ -2443,7 +2523,7 @@ mod tests { 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 cookie = login_cookie(&app, "admin", "correct horse battery").await; let read = app .oneshot( diff --git a/src/web/templates/issue_full.html b/src/web/templates/issue_full.html index 5f9d331..a9b5239 100644 --- a/src/web/templates/issue_full.html +++ b/src/web/templates/issue_full.html @@ -3,7 +3,7 @@

{{ display_date }} · No. {{ issue_number }}

{{ stats_line }}

The Brief

{{ front_page_html|safe }}
- {% if read_href.is_some() || downloads.is_some() %}
{% match read_href %}{% when Some with (href) %}Read in BookOrbit{% when None %}{% endmatch %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}Download {{ downloads.primary.label }}{% else %}{% endif %}{% when None %}{% endmatch %}
{% endif %} + {% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}
{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}Read in BookOrbit{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}Download {{ downloads.primary.label }}{% else %}{% endif %}{% when None %}{% endmatch %}
{% endif %}

In This Issue

{% for section in sections %}

{{ section.name }}

    {% for entry in section.entries %}
  • {{ entry.title }}

    {{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · dashboard{% endif %}

    {% if !entry.summary.is_empty() %}

    {{ entry.summary }}

    {% endif %}{% match entry.why %}{% when Some with (why) %}

    Why it's here: {{ why }}

    {% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}
  • {% endfor %}
{% endfor %}