From 841e8419b06eeb273ec4ccdd6afcddc9baba323f Mon Sep 17 00:00:00 2001 From: mace Date: Sun, 7 Jun 2026 16:26:42 +0200 Subject: [PATCH] updated rust version, minor fixes --- Cargo.toml | 2 +- README.md | 41 +++++++++++++ src/main.rs | 5 +- src/reader/sync.rs | 58 +++++++++++++++---- vue/src/assets/modal.css | 5 +- vue/src/components/RssFeeds.vue | 11 +++- vue/src/components/__tests__/RssFeeds.spec.js | 39 +++++++++++++ 7 files changed, 143 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e1f5c71..781214e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rss-reader" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/README.md b/README.md index 2ec8d5b..761d69a 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,47 @@ docker compose down -v # stop and wipe all data — careful! docker compose up --build -d # rebuild after pulling code changes ``` +### Optional: Apache reverse proxy (TLS termination) + +If you want to expose the app under a domain with HTTPS, put Apache in front of the `frontend` container (which keeps listening on `localhost:8080`) and let Apache handle TLS. Enable the required modules first: + +```sh +sudo a2enmod proxy proxy_http proxy_wstunnel ssl headers +``` + +Then a vhost like this proxies everything — including the WebSocket-capable Vite/axios traffic and the `/api/` calls the frontend's nginx already forwards to the backend — to the container: + +```apache + + ServerName rss.example.com + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/rss.example.com/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/rss.example.com/privkey.pem + + ProxyPreserveHost On + ProxyPass / http://127.0.0.1:8080/ + ProxyPassReverse / http://127.0.0.1:8080/ + + RequestHeader set X-Forwarded-Proto "https" + + ErrorLog ${APACHE_LOG_DIR}/rss-error.log + CustomLog ${APACHE_LOG_DIR}/rss-access.log combined + + +# Redirect plain HTTP to HTTPS + + ServerName rss.example.com + Redirect permanent / https://rss.example.com/ + +``` + +Notes for this setup: + +- Set `FRONTEND_ORIGIN=https://rss.example.com` in your root `.env` so the backend's CORS check allows the proxied origin, then `docker compose up --build -d backend`. +- You no longer need to publish port `8080` to the LAN — change the `frontend` service's port mapping in `docker-compose.yml` to `"127.0.0.1:8080:80"` so only Apache (on the same host) can reach it. +- Obtain the certificate with `certbot --apache -d rss.example.com` (via the [Certbot](https://certbot.eff.org/) Apache plugin), which can also write the vhost and set up auto-renewal for you. + ### Notes - Migrations run automatically at backend startup — no manual `diesel` step needed in production. diff --git a/src/main.rs b/src/main.rs index 3a8f465..5318d2e 100755 --- a/src/main.rs +++ b/src/main.rs @@ -34,7 +34,7 @@ async fn main() -> std::io::Result<()> { .allow_any_header() .supports_credentials(); - let app = App::new() + App::new() .wrap_fn(|req, srv| { let mut passed: bool; let request_url: String = String::from(req.uri().path()); @@ -73,8 +73,7 @@ async fn main() -> std::io::Result<()> { } }) .wrap(cors) - .configure(views::views_factory); - app + .configure(views::views_factory) }) .bind("0.0.0.0:8001")? .run() diff --git a/src/reader/sync.rs b/src/reader/sync.rs index 412a5b2..8b860b5 100644 --- a/src/reader/sync.rs +++ b/src/reader/sync.rs @@ -27,6 +27,17 @@ fn get_date(date_str: &str) -> Result { DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local()) } +// Some feeds (e.g. Deutsche Welle) embed responsive-image templates such as +// `src="https://example.com/img_${formatId}.jpg"` that their own frontend +// JavaScript fills in before loading — verbatim, they 404. Skip those and +// pick the first with a real, directly loadable URL instead. +fn image_src_is_resolvable(element: &scraper::ElementRef) -> bool { + match element.value().attr("src") { + Some(src) => !src.contains('{') && !src.to_lowercase().contains("%7b"), + None => false, + } +} + fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { let item_title = item.title.clone().unwrap(); log::info!("Create feed item: {}", item_title); @@ -35,20 +46,18 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { let frag = Html::parse_fragment(base_content); let mut content = "".to_string(); - let frag_clone = frag.clone(); - frag.tree.into_iter().for_each(|node| { - let selector_img = Selector::parse("img").unwrap(); - for element in frag_clone.select(&selector_img) { - if !content.starts_with("") - } - } - if let scraper::node::Node::Text(text) = node { + let selector_img = Selector::parse("img").unwrap(); + if let Some(image) = frag.select(&selector_img).find(image_src_is_resolvable) { + content.push_str(&image.html()); + content.push_str("
"); + } + + for node in frag.tree.nodes() { + if let scraper::node::Node::Text(text) = node.value() { content.push_str(&text.text); } - }); + } let existing_item: Vec = feed_item::table .filter(feed_id.eq(feed.id)) @@ -140,6 +149,33 @@ mod tests { assert!(get_date("not-a-date").is_err()); } + #[test] + fn create_feed_item_skips_template_placeholder_images() { + let html = Html::parse_fragment( + r#"

placeholder

+

real image

"#, + ); + let selector = Selector::parse("img").unwrap(); + + let chosen = html + .select(&selector) + .find(image_src_is_resolvable) + .expect("should find a resolvable image"); + + assert_eq!( + Some("https://example.test/real.jpg"), + chosen.value().attr("src") + ); + } + + #[test] + fn create_feed_item_finds_no_image_when_all_are_templated() { + let html = Html::parse_fragment(r#""#); + let selector = Selector::parse("img").unwrap(); + + assert!(html.select(&selector).find(image_src_is_resolvable).is_none()); + } + #[actix_web::test] async fn create_feed_item_does_not_duplicate_existing_items() { let mut connection = establish_connection(); diff --git a/vue/src/assets/modal.css b/vue/src/assets/modal.css index 643de0e..7a359fd 100644 --- a/vue/src/assets/modal.css +++ b/vue/src/assets/modal.css @@ -26,7 +26,8 @@ input { overflow-y: auto; margin: auto; padding: 20px 30px; - background-color: #fff; + background-color: var(--color-background-soft); + color: var(--color-text); border-radius: 2px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33); transition: all 0.3s ease; @@ -34,7 +35,7 @@ input { .modal-header h3 { margin-top: 0; - color: #42b983; + color: var(--color-heading); } .modal-body { diff --git a/vue/src/components/RssFeeds.vue b/vue/src/components/RssFeeds.vue index f2a711e..10391d1 100644 --- a/vue/src/components/RssFeeds.vue +++ b/vue/src/components/RssFeeds.vue @@ -73,9 +73,14 @@ const fetchData = async () => { 'user-token': localStorage.getItem("user-token") } }); + const items = []; response.data.feeds.forEach(feed => { - feed.items.forEach(item => feeds.value.push({ ...item, feedTitle: feed.title })); + feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title })); }); + // timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain + // lexicographic comparison sorts them chronologically. + items.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + feeds.value = items; await nextTick(); setupIntersectionObserver(); } catch (error) { @@ -109,6 +114,10 @@ async function sync() { let observer; // Declare observer outside the setup function function setupIntersectionObserver() { + if (observer) { + observer.disconnect(); + } + observer = new IntersectionObserver(handleIntersection, { root: null, // Use the viewport as the root rootMargin: '0px', diff --git a/vue/src/components/__tests__/RssFeeds.spec.js b/vue/src/components/__tests__/RssFeeds.spec.js index f8a5a88..a2fc369 100644 --- a/vue/src/components/__tests__/RssFeeds.spec.js +++ b/vue/src/components/__tests__/RssFeeds.spec.js @@ -59,6 +59,45 @@ describe('RssFeeds', () => { expect(wrapper.text()).not.toContain('No unread articles.') }) + it('sorts articles by date across feeds, newest first', async () => { + axios.get.mockResolvedValueOnce({ + data: { + feeds: [ + { + title: 'Old Feed', + items: [ + { + id: 1, + title: 'Older article', + content: '

old

', + url: 'https://example.test/1', + timestamp: '2026-01-01 10:00:00', + }, + ], + }, + { + title: 'New Feed', + items: [ + { + id: 2, + title: 'Newer article', + content: '

new

', + url: 'https://example.test/2', + timestamp: '2026-02-01 10:00:00', + }, + ], + }, + ], + }, + }) + + const wrapper = mount(RssFeeds) + await flushPromises() + + const titles = wrapper.findAll('.feed-title').map(el => el.text()) + expect(titles).toEqual(['Newer article', 'Older article']) + }) + it('syncs feeds for the current user', async () => { axios.get.mockResolvedValue({ data: { feeds: [] } }) axios.post.mockResolvedValueOnce({ status: 200 })