8 |
9 |
10 | Kindly RSS Reader is a self-hosted feed aggregator (supporting both RSS and Atom feeds) designed for e-ink devices such as Kindle and optimized for low-end computers like the Raspberry Pi.
11 |
12 | Feel free to test it, report issues, or contribute by submitting pull requests with new features.
13 |
14 | > **Note:** This project is in its early development stages.
15 |
16 | ## Features
17 | - Fetch and aggregate RSS and Atom feeds.
18 | - Optimized for e-ink display readability.
19 | - Self-hostable on low-end hardware.
20 |
21 | ## Running the application
22 |
23 | ### Configuration: Environment variables
24 |
25 | The following environment variables are used to configure some aspects of the app:
26 |
27 | - `MAX_ARTICLES_QTY_TO_DOWNLOAD`: When adding a new feed, downloads the specified number of articles from newest to oldest. Additional articles will be fetched on demand. If not specified, all articles will be downloaded.
28 |
29 | *Default value: `0`*
30 |
31 | - `DATA_PATH`: Path for storing the app data, such as fetched articles, config and the database file.
32 |
33 | *Default value: `.`*
34 |
35 | **Note**: Do not modify this when running it in docker.
36 |
37 | - `STATIC_DATA_PATH`: Path where static folders are located (`migrations`, `static` and `templates`).
38 |
39 | *Default value: `.`*
40 |
41 | **Note**: Do not modify this when running it in docker.
42 |
43 | - `RUST_LOG`: Configure log level:
44 | - `TRACE`
45 | - `DEBUG`
46 | - `INFO` *default*
47 | - `WARN`
48 | - `ERROR`
49 |
50 |
51 | ### Docker
52 |
53 | At the moment only a docker image is supported. To run the project:
54 |
55 | ```bash
56 | docker run \
57 | -d \
58 | -p 3000:3000 \
59 | --restart unless-stopped \
60 | -v "$(pwd)/kindly-rss-data/data:/home/data" \
61 | --name kindly-rss \
62 | nicoan/kindly-rss-reader
63 | ```
64 |
65 | The argument `--restart unless-stopped` will strart the container automatically when the docker daemon stats, unless it is stopped.
66 |
67 | **Note**: If you wish to modify some enviroment variable value, add `-e VAR_NAME=` to the `docker run ...` command.
68 |
69 | Then open your browser and navigate to the app with the address `http:://:3000`. I *highly* recommend to add feeds from a computer.
70 |
71 | ## Running the Project for development
72 |
73 | ### Using Cargo
74 |
75 | You can run the project with:
76 |
77 | ```bash
78 | cargo run
79 | ```
80 |
81 |
82 | ### Using Docker
83 |
84 | 1. Build the Docker image:
85 |
86 | ```bash
87 | docker build --tag kindly-rss .
88 | ```
89 |
90 | 2. Run the container:
91 |
92 | ```bash
93 | docker run --rm -p 3000:3000 kindly-rss
94 | ```
95 |
96 | ## Showroom
97 |
98 | Here are some screenshots of the Kindly RSS Reader in action:
99 |
100 | ### Light theme
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 | ### Dark theme
110 |
111 |
112 |
113 |
114 |
115 |
116 |
--------------------------------------------------------------------------------
/assets/logo.xcf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/assets/logo.xcf
--------------------------------------------------------------------------------
/config.json:
--------------------------------------------------------------------------------
1 | {"dark_theme":false,"zoom":1.0}
--------------------------------------------------------------------------------
/config/config.json:
--------------------------------------------------------------------------------
1 | {"dark_theme":false,"zoom":1.0}
--------------------------------------------------------------------------------
/images/1_dark.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/1_dark.jpeg
--------------------------------------------------------------------------------
/images/1_light.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/1_light.jpeg
--------------------------------------------------------------------------------
/images/2_dark.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/2_dark.jpeg
--------------------------------------------------------------------------------
/images/2_light.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/2_light.jpeg
--------------------------------------------------------------------------------
/images/3_dark.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/3_dark.jpeg
--------------------------------------------------------------------------------
/images/3_light.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/3_light.jpeg
--------------------------------------------------------------------------------
/images/logo_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/logo_dark.png
--------------------------------------------------------------------------------
/images/logo_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nicoan/kindly-rss-reader/228e551147841620556e8f7aa27e7e7d5b4da61d/images/logo_light.png
--------------------------------------------------------------------------------
/migrations/20241230_article.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS article (
2 | id VARCHAR(36) PRIMARY KEY,
3 |
4 | title TEXT NOT NULL,
5 |
6 | author TEXT,
7 |
8 | guid TEXT,
9 | -- The complete link to the article
10 | link TEXT,
11 |
12 | -- Content of the article. Depending on the storage engine used can be a fs path or the content itself
13 | -- For this implementation (SQLite) it is a path to the fs
14 | content TEXT,
15 |
16 | -- If the article was read
17 | read SMALLINT,
18 |
19 | -- If the article was exracted from an HTML instead of the content field in RSS
20 | html_parsed SMALLINT,
21 |
22 |
23 | -- This is the pub date. If for some reason the field is not available we put the date we parsed the article
24 | last_updated DATE NOT NULL,
25 |
26 | feed_id VARCHAR(16),
27 |
28 | FOREIGN KEY (feed_id) REFERENCES feed(id) ON DELETE CASCADE ON UPDATE CASCADE,
29 | UNIQUE(guid)
30 | );
31 |
--------------------------------------------------------------------------------
/migrations/20241230_feed.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS feed (
2 | id VARCHAR(36) PRIMARY KEY,
3 | title TEXT NOT NULL,
4 | url TEXT NOT NULL,
5 | link TEXT NOT NULL,
6 | favicon_path TEXT,
7 | last_updated DATE NOT NULL,
8 |
9 | UNIQUE(url)
10 | );
11 |
--------------------------------------------------------------------------------
/migrations/migrations.sql:
--------------------------------------------------------------------------------
1 | -- This table is to keep track of the applied migrations
2 | CREATE TABLE IF NOT EXISTS migrations (
3 | -- name of the migrated file
4 | name TEXT PRIMARY KEY
5 | );
6 |
--------------------------------------------------------------------------------
/src/config.rs:
--------------------------------------------------------------------------------
1 | use std::{net::IpAddr, str::FromStr};
2 |
3 | use serde::Deserialize;
4 |
5 | #[derive(Deserialize)]
6 | pub struct Config {
7 | /// Ip to serve the app
8 | #[serde(default = "Config::default_ip")]
9 | pub ip: IpAddr,
10 |
11 | /// Port to serve the app
12 | #[serde(default = "Config::default_port")]
13 | pub port: u16,
14 |
15 | /// Path where we save the data that changes (articles, images, database)
16 | #[serde(default = "Config::default_data_path")]
17 | pub data_path: String,
18 |
19 | /// Path where we save static apps data (migrations, templates, etc)
20 | #[serde(default = "Config::default_static_data_path")]
21 | pub static_data_path: String,
22 |
23 | /// Maximum HTML articles quantity to pre-download when adding a new feed
24 | /// If it is None, we download all, otherwise we download the specified quantity
25 | #[serde(default = "Config::max_articles_qty_to_download")]
26 | pub max_articles_qty_to_download: Option,
27 |
28 | /// How many minutes we should wait before checking the feed for new articles
29 | #[serde(default = "Config::minutes_to_check_for_updates")]
30 | pub minutes_to_check_for_updates: u16,
31 | }
32 |
33 | impl Config {
34 | pub fn load() -> Self {
35 | match envy::from_env::() {
36 | Ok(config) => config,
37 | Err(error) => panic!("{:#?}", error),
38 | }
39 | }
40 |
41 | pub fn print_information(&self) {
42 | tracing::info!("Running on: {}:{}", self.ip, self.port);
43 | tracing::info!("Data path: {}", self.data_path);
44 | tracing::info!("Static data path: {}", self.static_data_path);
45 | }
46 |
47 | fn default_data_path() -> String {
48 | ".".to_owned()
49 | }
50 |
51 | fn default_static_data_path() -> String {
52 | ".".to_owned()
53 | }
54 |
55 | fn default_port() -> u16 {
56 | 3000
57 | }
58 |
59 | fn default_ip() -> IpAddr {
60 | IpAddr::from_str("0.0.0.0").expect("there was an error creating the default ip")
61 | }
62 |
63 | fn max_articles_qty_to_download() -> Option {
64 | None
65 | }
66 |
67 | fn minutes_to_check_for_updates() -> u16 {
68 | 120
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/controllers/config/mod.rs:
--------------------------------------------------------------------------------
1 | mod set_dark_theme;
2 | mod set_zoom;
3 |
4 | pub use set_dark_theme::set_dark_theme;
5 | pub use set_zoom::set_zoom;
6 |
--------------------------------------------------------------------------------
/src/controllers/config/set_dark_theme.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::ApiError;
2 | use crate::services::persisted_config::PersistedConfigService;
3 | use crate::state::AppState;
4 | use axum::extract::State;
5 | use axum::Form;
6 | use serde::Deserialize;
7 |
8 | #[derive(Deserialize, Debug)]
9 | pub struct DarkThemeData {
10 | pub dark_theme: bool,
11 | }
12 |
13 | pub async fn set_dark_theme(
14 | State(state): State,
15 | Form(dark_theme_data): Form,
16 | ) -> Result<(), ApiError>
17 | where
18 | S: AppState,
19 | {
20 | state
21 | .persisted_config_service()
22 | .set_dark_theme(dark_theme_data.dark_theme)
23 | .await?;
24 |
25 | Ok(())
26 | }
27 |
--------------------------------------------------------------------------------
/src/controllers/config/set_zoom.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::ApiError;
2 | use crate::services::persisted_config::PersistedConfigService;
3 | use crate::state::AppState;
4 | use axum::extract::State;
5 | use axum::Form;
6 | use serde::Deserialize;
7 |
8 | #[derive(Deserialize, Debug)]
9 | pub struct ZoomData {
10 | pub zoom: f64,
11 | }
12 |
13 | pub async fn set_zoom(
14 | State(state): State,
15 | Form(zoom_data): Form,
16 | ) -> Result<(), ApiError>
17 | where
18 | S: AppState,
19 | {
20 | state
21 | .persisted_config_service()
22 | .set_zoom(zoom_data.zoom)
23 | .await?;
24 |
25 | Ok(())
26 | }
27 |
--------------------------------------------------------------------------------
/src/controllers/feed/add_new_feed.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::ApiError;
2 | use crate::services::feed::FeedService;
3 | use crate::state::AppState;
4 | use axum::extract::State;
5 | use axum::response::Redirect;
6 | use axum::Form;
7 | use reqwest::Url;
8 | use serde::Deserialize;
9 |
10 | #[derive(Deserialize, Debug)]
11 | pub struct FeedAddForm {
12 | pub url: String,
13 | }
14 |
15 | pub async fn add_new_feed(
16 | State(state): State,
17 | Form(rss_url): Form,
18 | ) -> Result
19 | where
20 | S: AppState,
21 | {
22 | let url = Url::try_from(rss_url.url.as_str()).map_err(|e| anyhow::anyhow!("{e}"))?;
23 | state.feed_service().add_feed(url).await?;
24 | Ok(Redirect::to("/"))
25 | }
26 |
--------------------------------------------------------------------------------
/src/controllers/feed/add_new_feed_form.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::{ApiError, HtmlResponse};
2 | use crate::services::templates::{TemplateService, TEMPLATE_NAME_FEED_ADD};
3 | use crate::state::AppState;
4 | use axum::extract::State;
5 | use minijinja::context;
6 |
7 | pub async fn add_new_feed_form(State(state): State) -> Result
8 | where
9 | S: AppState,
10 | {
11 | Ok(HtmlResponse::new(
12 | state
13 | .template_service()
14 | .render_template(TEMPLATE_NAME_FEED_ADD, context! {})
15 | .await?,
16 | ))
17 | }
18 |
--------------------------------------------------------------------------------
/src/controllers/feed/delete_feed.rs:
--------------------------------------------------------------------------------
1 | use crate::state::AppState;
2 | use crate::{controllers::ApiError, services::feed::FeedService};
3 | use axum::{
4 | extract::{Path, State},
5 | response::Redirect,
6 | };
7 | use uuid::Uuid;
8 |
9 | pub async fn delete_feed(
10 | State(state): State,
11 | Path(feed_id): Path,
12 | ) -> Result {
13 | state.feed_service().delete_feed(feed_id).await?;
14 |
15 | Ok(Redirect::to("/"))
16 | }
17 |
--------------------------------------------------------------------------------
/src/controllers/feed/get_article.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::ApiError;
2 | use crate::controllers::HtmlResponse;
3 | use crate::services::feed::FeedService;
4 | use crate::services::templates::TemplateService;
5 | use crate::services::templates::TEMPLATE_NAME_ARTICLE;
6 | use crate::state::AppState;
7 | use axum::extract::Path;
8 | use axum::extract::State;
9 | use minijinja::context;
10 | use uuid::Uuid;
11 |
12 | pub async fn get_article(
13 | State(state): State,
14 | Path((feed_id, article_id)): Path<(Uuid, Uuid)>,
15 | ) -> Result
16 | where
17 | S: AppState,
18 | {
19 | let feed = state.feed_service().get_feed(feed_id).await?;
20 |
21 | let (article_data, content) = state
22 | .feed_service()
23 | .get_item_content(feed_id, article_id)
24 | .await?;
25 |
26 | let rendered_article = state
27 | .template_service()
28 | .render_template(
29 | TEMPLATE_NAME_ARTICLE,
30 | context! { feed => feed, article => content, article_data => article_data },
31 | )
32 | .await?;
33 |
34 | if !article_data.read {
35 | state
36 | .feed_service()
37 | .mark_article_as_read(feed_id, article_id)
38 | .await?;
39 | }
40 |
41 | Ok(HtmlResponse::new(rendered_article))
42 | }
43 |
--------------------------------------------------------------------------------
/src/controllers/feed/get_article_list.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::{ApiError, HtmlResponse};
2 | use crate::services::feed::FeedService;
3 | use crate::services::templates::{TemplateService, TEMPLATE_NAME_ARTICLE_LIST};
4 | use crate::state::AppState;
5 | use crate::view_models::article_list_item::ArticleListItem;
6 | use axum::extract::Path;
7 | use axum::extract::State;
8 | use minijinja::context;
9 | use uuid::Uuid;
10 |
11 | pub async fn get_article_list(
12 | State(state): State,
13 | Path(feed_id): Path,
14 | ) -> Result
15 | where
16 | S: AppState,
17 | {
18 | let (feed, articles) = state.feed_service().get_channel(feed_id).await?;
19 |
20 | let articles: Vec = articles.into_iter().map(ArticleListItem::from).collect();
21 |
22 | let rendered_html = state
23 | .template_service()
24 | .render_template(
25 | TEMPLATE_NAME_ARTICLE_LIST,
26 | context! { feed => feed, articles => articles },
27 | )
28 | .await?;
29 |
30 | Ok(HtmlResponse::new(rendered_html))
31 | }
32 |
--------------------------------------------------------------------------------
/src/controllers/feed/get_feed_list.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::{ApiError, HtmlResponse};
2 | use crate::services::templates::TEMPLATE_NAME_FEED_LIST;
3 | use crate::services::{feed::FeedService, templates::TemplateService};
4 | use crate::state::AppState;
5 | use axum::extract::State;
6 | use minijinja::context;
7 |
8 | pub async fn get_feed_list(State(state): State) -> Result
9 | where
10 | S: AppState,
11 | {
12 | let feeds = state.feed_service().get_feed_list().await?;
13 |
14 | let rendered_html = state
15 | .template_service()
16 | .render_template(TEMPLATE_NAME_FEED_LIST, context! { feeds => feeds })
17 | .await?;
18 |
19 | Ok(HtmlResponse::new(rendered_html))
20 | }
21 |
--------------------------------------------------------------------------------
/src/controllers/feed/mod.rs:
--------------------------------------------------------------------------------
1 | mod add_new_feed;
2 | mod add_new_feed_form;
3 | mod delete_feed;
4 | mod get_article;
5 | mod get_article_list;
6 | mod get_feed_list;
7 |
8 | pub use add_new_feed::add_new_feed;
9 | pub use add_new_feed_form::add_new_feed_form;
10 | pub use delete_feed::delete_feed;
11 | pub use get_article::get_article;
12 | pub use get_article_list::get_article_list;
13 | pub use get_feed_list::get_feed_list;
14 |
--------------------------------------------------------------------------------
/src/controllers/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod config;
2 | pub mod feed;
3 | pub mod not_found;
4 |
5 | use axum::response::{Html, IntoResponse};
6 | use reqwest::{header, StatusCode};
7 |
8 | pub(crate) struct ApiError {
9 | pub original_error: Box,
10 | pub status_code: StatusCode,
11 | }
12 |
13 | impl IntoResponse for ApiError {
14 | fn into_response(self) -> axum::response::Response {
15 | tracing::error!("{}", &self.original_error);
16 | (
17 | self.status_code,
18 | [
19 | (header::CONTENT_TYPE, "text/html; charset=utf-8"),
20 | (
21 | header::CACHE_CONTROL,
22 | "no-store, no-cache, must-revalidate, max-age=0",
23 | ),
24 | (header::PRAGMA, "no-cache"),
25 | (header::EXPIRES, "0"),
26 | ],
27 | )
28 | .into_response()
29 | }
30 | }
31 |
32 | impl From for ApiError {
33 | fn from(error: anyhow::Error) -> Self {
34 | Self {
35 | original_error: error.into(),
36 | status_code: StatusCode::INTERNAL_SERVER_ERROR,
37 | }
38 | }
39 | }
40 |
41 | /// This response is used to render a template. It includes the needed headers as well (for example
42 | /// the ones to not save cache)
43 | pub(crate) struct HtmlResponse {
44 | body: String,
45 | status_code: StatusCode,
46 | }
47 |
48 | impl HtmlResponse {
49 | pub fn new(rendered_html: String) -> Self {
50 | Self {
51 | body: rendered_html,
52 | status_code: StatusCode::OK,
53 | }
54 | }
55 |
56 | pub fn with_status_code(mut self, status_code: StatusCode) -> Self {
57 | self.status_code = status_code;
58 | self
59 | }
60 | }
61 |
62 | impl IntoResponse for HtmlResponse {
63 | fn into_response(self) -> axum::response::Response {
64 | (
65 | self.status_code,
66 | [
67 | (header::CONTENT_TYPE, "text/html; charset=utf-8"),
68 | (
69 | header::CACHE_CONTROL,
70 | "no-store, no-cache, must-revalidate, max-age=0",
71 | ),
72 | (header::PRAGMA, "no-cache"),
73 | (header::EXPIRES, "0"),
74 | ],
75 | Html(self.body),
76 | )
77 | .into_response()
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/controllers/not_found.rs:
--------------------------------------------------------------------------------
1 | use crate::controllers::ApiError;
2 | use crate::services::templates::{TemplateService, TEMPLATE_NAME_ERROR};
3 | use crate::state::AppState;
4 | use crate::view_models::error::Error;
5 | use axum::extract::State;
6 | use minijinja::context;
7 | use reqwest::StatusCode;
8 |
9 | use super::HtmlResponse;
10 |
11 | pub async fn not_found(State(state): State) -> Result
12 | where
13 | S: AppState,
14 | {
15 | let error = Error::not_found();
16 | let rendered_html = state
17 | .template_service()
18 | .render_template(TEMPLATE_NAME_ERROR, context! { error => error})
19 | .await?;
20 |
21 | Ok(HtmlResponse::new(rendered_html).with_status_code(StatusCode::NOT_FOUND))
22 | }
23 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | mod config;
2 | mod controllers;
3 | mod middlewares;
4 | mod models;
5 | pub mod providers;
6 | mod repositories;
7 | mod router;
8 | pub mod services;
9 | mod state;
10 | mod tracing;
11 | mod view_models;
12 |
13 | use crate::repositories::init_database;
14 | use crate::tracing::init_tracing;
15 | use config::Config;
16 | use state::State;
17 | use std::sync::Arc;
18 |
19 | #[tokio::main]
20 | async fn main() {
21 | // Init tracing
22 | init_tracing();
23 |
24 | // Configuration
25 | let config = Arc::new(Config::load());
26 |
27 | // Init database
28 | let connection = init_database(&config);
29 |
30 | // Create state
31 | let state = State::new(connection, config.clone()).await;
32 |
33 | // Initialize App
34 | let app = router::build(state, &config);
35 | let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.ip, config.port))
36 | .await
37 | .expect("unable to bind tcp listener");
38 |
39 | config.print_information();
40 | axum::serve(listener, app).await.unwrap();
41 | }
42 |
--------------------------------------------------------------------------------
/src/middlewares/error_handling_middleware.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | convert::Infallible,
3 | future::Future,
4 | pin::Pin,
5 | task::{Context, Poll},
6 | };
7 |
8 | use axum::{
9 | body::Body,
10 | http::{Request, Response},
11 | response::IntoResponse,
12 | };
13 | use minijinja::context;
14 | use reqwest::StatusCode;
15 | use tower::{Layer, Service};
16 |
17 | use crate::{
18 | controllers::HtmlResponse,
19 | services::templates::{TemplateService, TEMPLATE_NAME_ERROR},
20 | state::AppState,
21 | view_models::error::Error,
22 | };
23 |
24 | // Middleware Layer
25 | #[derive(Clone)]
26 | pub struct ErrorHandlingLayer {
27 | state: AS,
28 | }
29 |
30 | impl ErrorHandlingLayer {
31 | pub fn new(state: AS) -> Self {
32 | Self { state }
33 | }
34 | }
35 |
36 | impl Layer for ErrorHandlingLayer {
37 | type Service = ErrorHandlingMiddleware;
38 |
39 | fn layer(&self, inner: S) -> Self::Service {
40 | ErrorHandlingMiddleware {
41 | inner,
42 | state: self.state.clone(),
43 | }
44 | }
45 | }
46 |
47 | #[derive(Clone)]
48 | pub struct ErrorHandlingMiddleware {
49 | inner: S,
50 | state: AS,
51 | }
52 |
53 | async fn get_error(error: Error, state: impl AppState) -> HtmlResponse {
54 | let rendered_html = state
55 | .template_service()
56 | .render_template(TEMPLATE_NAME_ERROR, context! { error => error})
57 | .await;
58 |
59 | match rendered_html {
60 | Ok(rendered_html) => {
61 | HtmlResponse::new(rendered_html).with_status_code(StatusCode::INTERNAL_SERVER_ERROR)
62 | }
63 | Err(e) => {
64 | tracing::error!("an unexpected error ocurred rendering an error: {e:?}");
65 | HtmlResponse::new("Something went horribly wrong. There was an error trying to render the error page. Please check the logs".to_owned()).with_status_code(StatusCode::INTERNAL_SERVER_ERROR)
66 | }
67 | }
68 | }
69 |
70 | impl Service> for ErrorHandlingMiddleware
71 | where
72 | S: Service, Response = Response, Error = Infallible>
73 | + Clone
74 | + Send
75 | + 'static,
76 | S::Future: Send + 'static,
77 | ReqBody: std::marker::Send + 'static,
78 | {
79 | type Response = Response;
80 | type Error = S::Error;
81 | type Future = Pin> + Send>>;
82 |
83 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> {
84 | self.inner.poll_ready(cx)
85 | }
86 |
87 | fn call(&mut self, req: Request) -> Self::Future {
88 | let mut inner = self.inner.clone();
89 | let state = self.state.clone();
90 |
91 | Box::pin(async move {
92 | match inner.call(req).await {
93 | Ok(response) => match response.status().as_u16() {
94 | s if (500..=599).contains(&s) => {
95 | let error = Error::internal_error();
96 | Ok(get_error(error, state).await.into_response())
97 | }
98 | 404 => {
99 | let error = Error::not_found();
100 | Ok(get_error(error, state).await.into_response())
101 | }
102 | 400 => {
103 | let error = Error::bad_request();
104 | Ok(get_error(error, state).await.into_response())
105 | }
106 | _ => Ok(response),
107 | },
108 | Err(err) => {
109 | tracing::error!("an unexpected error ocurred: {err:?}");
110 | let error = Error::internal_error();
111 | Ok(get_error(error, state).await.into_response())
112 | }
113 | }
114 | })
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/middlewares/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod error_handling_middleware;
2 |
--------------------------------------------------------------------------------
/src/models/article.rs:
--------------------------------------------------------------------------------
1 | use std::str::FromStr;
2 |
3 | use chrono::{DateTime, Utc};
4 | use serde::Serialize;
5 | use sqlite::Row;
6 | use uuid::Uuid;
7 |
8 | use crate::repositories::RepositoryError;
9 |
10 | #[derive(Serialize, Debug)]
11 | pub struct Article {
12 | pub id: Uuid,
13 | pub feed_id: Uuid,
14 | pub title: String,
15 | pub author: Option,
16 | pub guid: String,
17 | pub link: String,
18 | pub content: Option,
19 | pub read: bool,
20 | pub html_parsed: bool,
21 | pub last_updated: DateTime,
22 | }
23 |
24 | impl TryFrom for Article {
25 | type Error = RepositoryError;
26 |
27 | fn try_from(row: Row) -> Result {
28 | let id = Uuid::from_str(row.read::<&str, _>("id"))
29 | .map_err(|e| RepositoryError::Deserialization(e.into()))?;
30 |
31 | let feed_id = Uuid::from_str(row.read::<&str, _>("feed_id"))
32 | .map_err(|e| RepositoryError::Deserialization(e.into()))?;
33 |
34 | Ok(Article {
35 | id,
36 | feed_id,
37 | title: row.read::<&str, _>("title").into(),
38 | author: row.read::