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 })