updated rust version, minor fixes
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,47 @@ docker compose down -v # stop and wipe all data — careful!
|
|||||||
docker compose up --build -d # rebuild after pulling code changes
|
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
|
||||||
|
<VirtualHost *:443>
|
||||||
|
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
|
||||||
|
</VirtualHost>
|
||||||
|
|
||||||
|
# Redirect plain HTTP to HTTPS
|
||||||
|
<VirtualHost *:80>
|
||||||
|
ServerName rss.example.com
|
||||||
|
Redirect permanent / https://rss.example.com/
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
### Notes
|
||||||
|
|
||||||
- Migrations run automatically at backend startup — no manual `diesel` step needed in production.
|
- Migrations run automatically at backend startup — no manual `diesel` step needed in production.
|
||||||
|
|||||||
+2
-3
@@ -34,7 +34,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.allow_any_header()
|
.allow_any_header()
|
||||||
.supports_credentials();
|
.supports_credentials();
|
||||||
|
|
||||||
let app = App::new()
|
App::new()
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
let mut passed: bool;
|
let mut passed: bool;
|
||||||
let request_url: String = String::from(req.uri().path());
|
let request_url: String = String::from(req.uri().path());
|
||||||
@@ -73,8 +73,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.configure(views::views_factory);
|
.configure(views::views_factory)
|
||||||
app
|
|
||||||
})
|
})
|
||||||
.bind("0.0.0.0:8001")?
|
.bind("0.0.0.0:8001")?
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
+46
-10
@@ -27,6 +27,17 @@ fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
|||||||
DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local())
|
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 <img> 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) {
|
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
||||||
let item_title = item.title.clone().unwrap();
|
let item_title = item.title.clone().unwrap();
|
||||||
log::info!("Create feed item: {}", item_title);
|
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 frag = Html::parse_fragment(base_content);
|
||||||
let mut content = "".to_string();
|
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) {
|
let selector_img = Selector::parse("img").unwrap();
|
||||||
if !content.starts_with("<img") {
|
if let Some(image) = frag.select(&selector_img).find(image_src_is_resolvable) {
|
||||||
content.push_str(&element.html());
|
content.push_str(&image.html());
|
||||||
content.push_str("<br>")
|
content.push_str("<br>");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let scraper::node::Node::Text(text) = node {
|
for node in frag.tree.nodes() {
|
||||||
|
if let scraper::node::Node::Text(text) = node.value() {
|
||||||
content.push_str(&text.text);
|
content.push_str(&text.text);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
let existing_item: Vec<FeedItem> = feed_item::table
|
let existing_item: Vec<FeedItem> = feed_item::table
|
||||||
.filter(feed_id.eq(feed.id))
|
.filter(feed_id.eq(feed.id))
|
||||||
@@ -140,6 +149,33 @@ mod tests {
|
|||||||
assert!(get_date("not-a-date").is_err());
|
assert!(get_date("not-a-date").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_feed_item_skips_template_placeholder_images() {
|
||||||
|
let html = Html::parse_fragment(
|
||||||
|
r#"<img src="https://example.test/img_${formatId}.jpg"><p>placeholder</p>
|
||||||
|
<img src="https://example.test/real.jpg"><p>real image</p>"#,
|
||||||
|
);
|
||||||
|
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#"<img src="https://example.test/img_${formatId}.jpg">"#);
|
||||||
|
let selector = Selector::parse("img").unwrap();
|
||||||
|
|
||||||
|
assert!(html.select(&selector).find(image_src_is_resolvable).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn create_feed_item_does_not_duplicate_existing_items() {
|
async fn create_feed_item_does_not_duplicate_existing_items() {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ input {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 20px 30px;
|
padding: 20px 30px;
|
||||||
background-color: #fff;
|
background-color: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
@@ -34,7 +35,7 @@ input {
|
|||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #42b983;
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
|
|||||||
@@ -73,9 +73,14 @@ const fetchData = async () => {
|
|||||||
'user-token': localStorage.getItem("user-token")
|
'user-token': localStorage.getItem("user-token")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const items = [];
|
||||||
response.data.feeds.forEach(feed => {
|
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();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,6 +114,10 @@ async function sync() {
|
|||||||
let observer; // Declare observer outside the setup function
|
let observer; // Declare observer outside the setup function
|
||||||
|
|
||||||
function setupIntersectionObserver() {
|
function setupIntersectionObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
observer = new IntersectionObserver(handleIntersection, {
|
observer = new IntersectionObserver(handleIntersection, {
|
||||||
root: null, // Use the viewport as the root
|
root: null, // Use the viewport as the root
|
||||||
rootMargin: '0px',
|
rootMargin: '0px',
|
||||||
|
|||||||
@@ -59,6 +59,45 @@ describe('RssFeeds', () => {
|
|||||||
expect(wrapper.text()).not.toContain('No unread articles.')
|
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: '<p>old</p>',
|
||||||
|
url: 'https://example.test/1',
|
||||||
|
timestamp: '2026-01-01 10:00:00',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'New Feed',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Newer article',
|
||||||
|
content: '<p>new</p>',
|
||||||
|
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 () => {
|
it('syncs feeds for the current user', async () => {
|
||||||
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
||||||
axios.post.mockResolvedValueOnce({ status: 200 })
|
axios.post.mockResolvedValueOnce({ status: 200 })
|
||||||
|
|||||||
Reference in New Issue
Block a user