├── .dockerignore ├── .env ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── Dockerfiles ├── Dockerfile.armv6 ├── Dockerfile.armv7 ├── Dockerfile.armv8 ├── Dockerfile.build └── Dockerfile.x86_64 ├── LICENSE ├── Makefile ├── README.md ├── assets └── logo.xcf ├── config.json ├── config └── config.json ├── images ├── 1_dark.jpeg ├── 1_light.jpeg ├── 2_dark.jpeg ├── 2_light.jpeg ├── 3_dark.jpeg ├── 3_light.jpeg ├── logo_dark.png └── logo_light.png ├── migrations ├── 20241230_article.sql ├── 20241230_feed.sql └── migrations.sql ├── src ├── config.rs ├── controllers │ ├── config │ │ ├── mod.rs │ │ ├── set_dark_theme.rs │ │ └── set_zoom.rs │ ├── feed │ │ ├── add_new_feed.rs │ │ ├── add_new_feed_form.rs │ │ ├── delete_feed.rs │ │ ├── get_article.rs │ │ ├── get_article_list.rs │ │ ├── get_feed_list.rs │ │ └── mod.rs │ ├── mod.rs │ └── not_found.rs ├── main.rs ├── middlewares │ ├── error_handling_middleware.rs │ └── mod.rs ├── models │ ├── article.rs │ ├── feed.rs │ ├── mod.rs │ ├── parsed_feed.rs │ └── persisted_config.rs ├── providers │ ├── feed_parser │ │ ├── atom_parser_impl.rs │ │ ├── error.rs │ │ ├── mod.rs │ │ └── rss_parser_impl.rs │ ├── html_processor │ │ ├── error.rs │ │ ├── html_processor_impl.rs │ │ └── mod.rs │ ├── image_processor │ │ ├── error.rs │ │ ├── image_processor_fs_impl.rs │ │ └── mod.rs │ ├── mod.rs │ └── persisted_config │ │ ├── error.rs │ │ ├── mod.rs │ │ └── persisted_config_fs_impl.rs ├── repositories │ ├── error.rs │ ├── feed │ │ ├── feed_repository_impl.rs │ │ └── mod.rs │ ├── feed_content │ │ ├── feed_content_fs_impl.rs │ │ └── mod.rs │ ├── init.rs │ ├── mod.rs │ └── persisted_config │ │ ├── mod.rs │ │ └── persisted_config_repository_impl.rs ├── router.rs ├── services │ ├── feed │ │ ├── error.rs │ │ ├── feed_service_impl.rs │ │ └── mod.rs │ ├── mod.rs │ ├── persisted_config │ │ ├── error.rs │ │ ├── mod.rs │ │ └── persisted_config_service_impl.rs │ └── templates │ │ ├── error.rs │ │ ├── mod.rs │ │ └── template_service_impl.rs ├── state.rs ├── tracing.rs └── view_models │ ├── article_list_item.rs │ ├── error.rs │ └── mod.rs ├── static ├── Shuttle.toml ├── css │ ├── dark_theme.css │ ├── font-awesome.min.css │ ├── general.css │ └── light_theme.css ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.svg │ ├── fontawesome-webfont.ttf │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── images │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── bad_request.webp │ ├── bad_request_dark.webp │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── favicon │ │ ├── android-chrome-192x192.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ └── favicon.ico │ ├── internal_error.webp │ ├── internal_error_dark.webp │ ├── logo.webp │ ├── logo_dark.webp │ ├── not_found.webp │ ├── not_found_dark.webp │ └── site.webmanifest └── not_found.html └── templates ├── article.html ├── article_list.html ├── common_head.html ├── dialog.html ├── error.html ├── feed_add.html ├── feed_list.html └── toolbar.html /.dockerignore: -------------------------------------------------------------------------------- 1 | /target 2 | database.db 3 | /articles 4 | **/.DS_store/** 5 | .env 6 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=sqlite:database.db 2 | IP=0.0.0.0 3 | PORT=3000 4 | DATA_PATH=. 5 | STATIC_DATA_PATH=. 6 | MAX_ARTICLES_QTY_TO_DOWNLOAD=5 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | database.db 3 | /articles 4 | .DS_store 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kindle-rss-reader" 3 | version = "0.4.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | anyhow = "1.0.95" 8 | atom_syndication = "0.12.2" 9 | axum = { version = "0.7.5", features = ["macros"] } 10 | axum_static = "1.7.1" 11 | chrono = { version = "0.4.39", features = ["serde"] } 12 | envy = "0.4.2" 13 | minijinja = { version = "2.0.2", features = ["loader"] } 14 | regex = "1.11.1" 15 | reqwest = { version = "0.12.5", default-features = false, features=["rustls-tls"] } 16 | rss = "2.0.8" 17 | serde = { version = "1.0.203", features = ["derive"] } 18 | serde_json = "1.0.138" 19 | sqlite = "0.36.1" 20 | thiserror = "2.0.11" 21 | tokio = { version = "1.38.2", features = ["fs", "io-util", "rt-multi-thread"] } 22 | tower = "0.4.13" 23 | tower-http = { version = "0.5.2", features = ["fs"] } 24 | tracing = "0.1.40" 25 | tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } 26 | uuid = { version = "1.11.0", features = ["serde", "v4"] } 27 | -------------------------------------------------------------------------------- /Dockerfiles/Dockerfile.armv6: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM nicoan/kindly-rss-builder AS builder 2 | 3 | WORKDIR /home 4 | 5 | ENV PATH=$PATH:/build/cross-armv6/bin 6 | ENV CARGO_TARGET_ARM_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-musleabihf-gcc 7 | 8 | RUN rustup target add arm-unknown-linux-musleabihf 9 | COPY . ./ 10 | 11 | RUN cargo build --target arm-unknown-linux-musleabihf --release 12 | 13 | FROM alpine:3 AS run 14 | 15 | RUN mkdir -p /home/kindlyrss/static_data \ 16 | && mkdir -p /home/kindlyrss/data 17 | 18 | EXPOSE 3000/tcp 19 | 20 | COPY --from=builder /home/target/arm-unknown-linux-musleabihf/release/kindle-rss-reader /usr/local/bin/kindlyrss 21 | COPY --from=builder /home/templates/ /home/kindlyrss/static_data/templates/ 22 | COPY --from=builder /home/migrations/ /home/kindlyrss/static_data/migrations/ 23 | COPY --from=builder /home/static/ /home/kindlyrss/static_data/static/ 24 | COPY --from=builder /home/config/config.json /home/kindlyrss/data/config.json 25 | 26 | ENV RUST_LOG=info 27 | ENV MAX_ARTICLES_QTY_TO_DOWNLOAD=0 28 | ENV STATIC_DATA_PATH=/home/kindlyrss/static_data 29 | ENV DATA_PATH=/home/kindlyrss/data 30 | 31 | CMD ["kindlyrss"] 32 | -------------------------------------------------------------------------------- /Dockerfiles/Dockerfile.armv7: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM nicoan/kindly-rss-builder AS builder 2 | 3 | WORKDIR /home 4 | 5 | ENV PATH=$PATH:/build/cross-armv7/bin 6 | ENV CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=armv7-linux-musleabihf-gcc 7 | 8 | RUN rustup target add armv7-unknown-linux-musleabihf 9 | COPY . ./ 10 | 11 | RUN cargo build --target armv7-unknown-linux-musleabihf --release 12 | 13 | FROM alpine:3 AS run 14 | 15 | RUN mkdir -p /home/kindlyrss/static_data \ 16 | && mkdir -p /home/kindlyrss/data 17 | 18 | EXPOSE 3000/tcp 19 | 20 | COPY --from=builder /home/target/armv7-unknown-linux-musleabihf/release/kindle-rss-reader /usr/local/bin/kindlyrss 21 | COPY --from=builder /home/templates/ /home/kindlyrss/static_data/templates/ 22 | COPY --from=builder /home/migrations/ /home/kindlyrss/static_data/migrations/ 23 | COPY --from=builder /home/static/ /home/kindlyrss/static_data/static/ 24 | COPY --from=builder /home/config/config.json /home/kindlyrss/data/config.json 25 | 26 | ENV RUST_LOG=info 27 | ENV MAX_ARTICLES_QTY_TO_DOWNLOAD=0 28 | ENV STATIC_DATA_PATH=/home/kindlyrss/static_data 29 | ENV DATA_PATH=/home/kindlyrss/data 30 | 31 | CMD ["kindlyrss"] 32 | -------------------------------------------------------------------------------- /Dockerfiles/Dockerfile.armv8: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM nicoan/kindly-rss-builder AS builder 2 | 3 | WORKDIR /home 4 | 5 | ENV PATH=$PATH:/build/cross-armv8/bin 6 | ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-musl-gcc 7 | 8 | RUN rustup target add aarch64-unknown-linux-musl 9 | COPY . ./ 10 | 11 | RUN cargo build --target aarch64-unknown-linux-musl --release 12 | 13 | FROM alpine:3 AS run 14 | 15 | RUN mkdir -p /home/kindlyrss/static_data \ 16 | && mkdir -p /home/kindlyrss/data 17 | 18 | EXPOSE 3000/tcp 19 | 20 | COPY --from=builder /home/target/aarch64-unknown-linux-musl/release/kindle-rss-reader /usr/local/bin/kindlyrss 21 | COPY --from=builder /home/templates/ /home/kindlyrss/static_data/templates/ 22 | COPY --from=builder /home/migrations/ /home/kindlyrss/static_data/migrations/ 23 | COPY --from=builder /home/static/ /home/kindlyrss/static_data/static/ 24 | COPY --from=builder /home/config/config.json /home/kindlyrss/data/config.json 25 | 26 | ENV RUST_LOG=info 27 | ENV MAX_ARTICLES_QTY_TO_DOWNLOAD=0 28 | ENV STATIC_DATA_PATH=/home/kindlyrss/static_data 29 | ENV DATA_PATH=/home/kindlyrss/data 30 | 31 | CMD ["kindlyrss"] 32 | -------------------------------------------------------------------------------- /Dockerfiles/Dockerfile.build: -------------------------------------------------------------------------------- 1 | # This image will be used for building the project in different platforms 2 | FROM rust:1.84-bullseye AS builder 3 | 4 | WORKDIR /home 5 | 6 | RUN git clone https://github.com/richfelker/musl-cross-make.git --depth 1 7 | 8 | # armv6 9 | RUN cd musl-cross-make \ 10 | && echo 'TARGET = arm-linux-musleabihf' > config.mak \ 11 | && echo 'OUTPUT = /build/cross-armv6' >> config.mak \ 12 | && make \ 13 | && make install 14 | 15 | # armv7 16 | RUN cd musl-cross-make \ 17 | && echo 'TARGET = armv7-linux-musleabihf' > config.mak \ 18 | && echo 'OUTPUT = /build/cross-armv7' >> config.mak \ 19 | && make \ 20 | && make install 21 | 22 | # arm64v8 23 | RUN cd musl-cross-make \ 24 | && echo 'TARGET = aarch64-linux-musl' > config.mak \ 25 | && echo 'OUTPUT = /build/cross-armv8' >> config.mak \ 26 | && make \ 27 | && make install 28 | 29 | # x86_64 30 | RUN cd musl-cross-make \ 31 | && echo 'TARGET = x86_64-linux-musl' > config.mak \ 32 | && echo 'OUTPUT = /build/cross-x86_64' >> config.mak \ 33 | && make \ 34 | && make install 35 | 36 | ENTRYPOINT ["/bin/bash"] 37 | -------------------------------------------------------------------------------- /Dockerfiles/Dockerfile.x86_64: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM nicoan/kindly-rss-builder AS builder 2 | 3 | WORKDIR /home 4 | 5 | ENV PATH=$PATH:/build/cross-x86_64/bin 6 | ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-linux-musl-gcc 7 | 8 | RUN rustup target add x86_64-unknown-linux-musl 9 | COPY . ./ 10 | 11 | RUN cargo build --target x86_64-unknown-linux-musl --release 12 | 13 | FROM alpine:3 AS run 14 | 15 | RUN mkdir -p /home/kindlyrss/static_data \ 16 | && mkdir -p /home/kindlyrss/data 17 | 18 | EXPOSE 3000/tcp 19 | 20 | COPY --from=builder /home/target/x86_64-unknown-linux-musl/release/kindle-rss-reader /usr/local/bin/kindlyrss 21 | COPY --from=builder /home/templates/ /home/kindlyrss/static_data/templates/ 22 | COPY --from=builder /home/migrations/ /home/kindlyrss/static_data/migrations/ 23 | COPY --from=builder /home/static/ /home/kindlyrss/static_data/static/ 24 | COPY --from=builder /home/config/config.json /home/kindlyrss/data/config.json 25 | 26 | ENV RUST_LOG=info 27 | ENV MAX_ARTICLES_QTY_TO_DOWNLOAD=0 28 | ENV STATIC_DATA_PATH=/home/kindlyrss/static_data 29 | ENV DATA_PATH=/home/kindlyrss/data 30 | 31 | CMD ["kindlyrss"] 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nicolás Antinori 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Extract the version from Cargo.toml 2 | PACKAGE_VERSION=$(shell cat Cargo.toml | grep version | head -n 1 | awk '{print $$3}' | sed -e 's/"//g') 3 | 4 | 5 | # Build for different archs 6 | # I opted to use multiple Dockerfiles to take advantage of the layer caching mechanism. 7 | docker-build: 8 | docker build \ 9 | --tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 \ 10 | -f ./dockerfiles/Dockerfile.x86_64 \ 11 | --platform linux/amd64 \ 12 | . 13 | docker build \ 14 | --tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 \ 15 | -f ./dockerfiles/Dockerfile.armv8 \ 16 | --platform linux/arm64/v8 \ 17 | . 18 | docker build \ 19 | --tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6 \ 20 | --platform linux/arm/v6 \ 21 | -f ./dockerfiles/Dockerfile.armv6 \ 22 | . 23 | docker build \ 24 | --tag nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 \ 25 | --platform linux/arm/v7 \ 26 | -f ./dockerfiles/Dockerfile.armv7 \ 27 | . 28 | 29 | # Builds an image with different linkers to be able to build 30 | # for different architectures 31 | docker-prepare-build-image: 32 | docker build \ 33 | --tag nicoan/kindly-rss-builder \ 34 | -f ./dockerfiles/Dockerfile.build \ 35 | . 36 | 37 | # Push new versions 38 | docker-push: 39 | # Push different architecture images 40 | docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 41 | docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 42 | docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 43 | docker push nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6 44 | # Create manifest for the package version and push 45 | docker manifest create nicoan/kindly-rss-reader:$(PACKAGE_VERSION) \ 46 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 \ 47 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 \ 48 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 \ 49 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6 50 | docker manifest push nicoan/kindly-rss-reader:$(PACKAGE_VERSION) 51 | # Create manifest for the latest tag and push 52 | docker manifest rm nicoan/kindly-rss-reader:latest 53 | docker manifest create nicoan/kindly-rss-reader:latest \ 54 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-x86_64 \ 55 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-arm64v8 \ 56 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv7 \ 57 | --amend nicoan/kindly-rss-reader:$(PACKAGE_VERSION)-armv6 58 | docker manifest push nicoan/kindly-rss-reader:latest 59 | 60 | 61 | git-tag-and-push: 62 | git tag v$(PACKAGE_VERSION) 63 | git push origin v$(PACKAGE_VERSION) 64 | 65 | .PHONY: build-docker docker-push docker-prepare-build-image git-tag-and-push 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | Logo 4 | Logo 5 |
6 | Feed Aggregator for e-ink devices 7 |

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 | Feed list light theme 104 | Article list light theme 105 | Article light theme 106 |

107 | 108 | 109 | ### Dark theme 110 |

111 | Feed list dark theme 112 | Article list dark theme 113 | Article dark theme 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::, _>("author").map(|a| a.to_owned()), 39 | link: row.read::<&str, _>("link").into(), 40 | guid: row.read::<&str, _>("guid").into(), 41 | content: row.read::, _>("content").map(|s| s.to_owned()), 42 | read: row.read::("read") != 0, 43 | html_parsed: row.read::("html_parsed") != 0, 44 | last_updated: DateTime::from_str(row.read::<&str, _>("last_updated")) 45 | .map_err(|e: chrono::ParseError| RepositoryError::Deserialization(e.into()))?, 46 | }) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/models/feed.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)] 11 | pub struct Feed { 12 | pub id: Uuid, 13 | pub title: String, 14 | pub url: String, 15 | pub link: String, 16 | pub favicon_url: Option, 17 | pub last_updated: DateTime, 18 | pub unread_count: u16, 19 | } 20 | 21 | impl TryFrom for Feed { 22 | type Error = RepositoryError; 23 | 24 | fn try_from(row: Row) -> Result { 25 | let id = Uuid::from_str(row.read::<&str, _>("id")) 26 | .map_err(|e| RepositoryError::Deserialization(e.into()))?; 27 | 28 | Ok(Feed { 29 | id, 30 | title: row.read::<&str, _>("title").into(), 31 | url: row.read::<&str, _>("url").into(), 32 | link: row.read::<&str, _>("link").into(), 33 | favicon_url: row 34 | .read::, _>("favicon_path") 35 | .map(|s| s.to_owned()), 36 | last_updated: DateTime::from_str(row.read::<&str, _>("last_updated")) 37 | .map_err(|e: chrono::ParseError| RepositoryError::Deserialization(e.into()))?, 38 | unread_count: row.read::("unread_count").clamp(0, u16::MAX as i64) as u16, 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/models/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod article; 2 | pub mod feed; 3 | pub mod parsed_feed; 4 | pub mod persisted_config; 5 | -------------------------------------------------------------------------------- /src/models/parsed_feed.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | 3 | pub struct ParsedFeed { 4 | pub title: String, 5 | pub link: String, 6 | pub items: Vec, 7 | } 8 | 9 | pub struct ParsedItem { 10 | pub title: String, 11 | pub link: Option, 12 | pub guid: Option, 13 | pub content: Option, 14 | pub author: Option, 15 | pub pub_date: Option>, 16 | } 17 | -------------------------------------------------------------------------------- /src/models/persisted_config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Serialize, Deserialize)] 4 | pub struct PersistedConfig { 5 | pub dark_theme: bool, 6 | pub zoom: f64, 7 | } 8 | 9 | impl Default for PersistedConfig { 10 | fn default() -> Self { 11 | Self { 12 | dark_theme: false, 13 | zoom: 1.0, 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/providers/feed_parser/atom_parser_impl.rs: -------------------------------------------------------------------------------- 1 | use super::{error::FeedParserError, FeedParser, Result}; 2 | use crate::models::parsed_feed::ParsedFeed; 3 | use crate::models::parsed_feed::ParsedItem; 4 | use anyhow::anyhow; 5 | use atom_syndication::Feed; 6 | use chrono::Utc; 7 | use std::io::BufReader; 8 | 9 | pub struct AtomParserImpl; 10 | 11 | impl FeedParser for AtomParserImpl { 12 | fn parse_feed(&self, content: &[u8]) -> Result { 13 | let reader = BufReader::new(content); 14 | let feed = Feed::read_from(reader).map_err(|e| FeedParserError::ParseError(anyhow!(e)))?; 15 | 16 | // Find the alternate link (usually the website URL) 17 | let link = feed 18 | .links() 19 | .iter() 20 | .find(|link| link.rel() == "alternate" || link.rel() == "self") 21 | .map(|link| link.href().to_owned()) 22 | .ok_or_else(|| FeedParserError::MissingField("link"))?; 23 | 24 | let items = feed 25 | .entries() 26 | .iter() 27 | .map(|entry| { 28 | // Find the alternate link for the entry 29 | let entry_link = entry 30 | .links() 31 | .iter() 32 | .find(|link| link.rel() == "alternate") 33 | .map(|link| link.href().to_owned()); 34 | 35 | // Get content or summary 36 | let content = entry 37 | .content() 38 | .and_then(|c| c.value.as_ref().map(|v| v.to_owned())); 39 | 40 | // Get author name if available 41 | let author = entry.authors().first().map(|a| a.name().to_owned()); 42 | 43 | ParsedItem { 44 | title: entry.title().to_string(), 45 | link: entry_link, 46 | guid: Some(entry.id().to_owned()), 47 | content, 48 | author, 49 | pub_date: Some(entry.updated().with_timezone(&Utc)), 50 | } 51 | }) 52 | .collect(); 53 | 54 | Ok(ParsedFeed { 55 | title: feed.title().to_string(), 56 | link, 57 | items, 58 | }) 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests { 64 | use super::*; 65 | 66 | #[test] 67 | fn test_can_parse_valid_atom() { 68 | let atom_content = r#" 69 | 70 | 71 | Test Atom Feed 72 | 73 | 2023-01-01T12:00:00Z 74 | https://example.com/feed 75 | 76 | Test Entry 77 | 78 | https://example.com/entry1 79 | 2023-01-01T12:00:00Z 80 | 81 | 82 | "# 83 | .as_bytes(); 84 | 85 | assert!(AtomParserImpl.parse_feed(atom_content).is_ok()); 86 | } 87 | 88 | #[test] 89 | fn test_cannot_parse_rss() { 90 | let rss_content = r#" 91 | 92 | 93 | 94 | Test RSS Feed 95 | https://example.com/feed 96 | A test RSS feed 97 | 98 | Test Item 99 | https://example.com/item1 100 | https://example.com/item1 101 | Mon, 01 Jan 2023 12:00:00 GMT 102 | 103 | 104 | 105 | "# 106 | .as_bytes(); 107 | 108 | assert!(AtomParserImpl.parse_feed(rss_content).is_err()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/providers/feed_parser/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | pub enum FeedParserError { 5 | #[error("Failed to parse feed: {0}")] 6 | ParseError(#[source] anyhow::Error), 7 | 8 | #[error("Missing required field: {0}")] 9 | MissingField(&'static str), 10 | 11 | #[error("Failed to parse date: {0}")] 12 | DateParseError(#[from] chrono::ParseError), 13 | 14 | #[error("Unexpected error: {0}")] 15 | Unexpected(#[from] anyhow::Error), 16 | } 17 | -------------------------------------------------------------------------------- /src/providers/feed_parser/mod.rs: -------------------------------------------------------------------------------- 1 | mod atom_parser_impl; 2 | mod error; 3 | mod rss_parser_impl; 4 | 5 | pub use atom_parser_impl::AtomParserImpl; 6 | pub use error::FeedParserError; 7 | pub use rss_parser_impl::RssParserImpl; 8 | 9 | use crate::models::parsed_feed::ParsedFeed; 10 | 11 | pub(crate) type Result = std::result::Result; 12 | 13 | /// Trait defining the common interface for all feed parsers 14 | pub trait FeedParser: Send + Sync { 15 | /// Parse feed content and return feed metadata 16 | fn parse_feed(&self, content: &[u8]) -> Result; 17 | } 18 | -------------------------------------------------------------------------------- /src/providers/feed_parser/rss_parser_impl.rs: -------------------------------------------------------------------------------- 1 | use super::{error::FeedParserError, FeedParser, Result}; 2 | use crate::models::parsed_feed::{ParsedFeed, ParsedItem}; 3 | use anyhow::anyhow; 4 | use chrono::{DateTime, Utc}; 5 | use rss::Channel; 6 | use std::io::BufReader; 7 | 8 | pub struct RssParserImpl; 9 | 10 | impl RssParserImpl { 11 | // TODO: This should be in other struct 12 | fn parse_date(&self, date_str: &str) -> Result> { 13 | DateTime::parse_from_rfc2822(date_str) 14 | .map(|dt| dt.with_timezone(&Utc)) 15 | .map_err(FeedParserError::DateParseError) 16 | } 17 | } 18 | 19 | impl FeedParser for RssParserImpl { 20 | fn parse_feed(&self, content: &[u8]) -> Result { 21 | let reader = BufReader::new(content); 22 | let channel = 23 | Channel::read_from(reader).map_err(|e| FeedParserError::ParseError(anyhow!(e)))?; 24 | 25 | let items = channel 26 | .items() 27 | .iter() 28 | .map(|item| { 29 | let pub_date = item.pub_date().and_then(|date| self.parse_date(date).ok()); 30 | 31 | ParsedItem { 32 | title: item.title().unwrap_or("Unknown title").to_owned(), 33 | link: item.link().map(|s| s.to_owned()), 34 | guid: item.guid().map(|g| g.value().to_owned()), 35 | content: item.content().map(|s| s.to_owned()), 36 | author: item.author().map(|s| s.to_owned()), 37 | pub_date, 38 | } 39 | }) 40 | .collect(); 41 | 42 | let link = channel.link().to_owned(); 43 | 44 | Ok(ParsedFeed { 45 | title: channel.title().to_owned(), 46 | link, 47 | items, 48 | }) 49 | } 50 | } 51 | 52 | #[cfg(test)] 53 | mod tests { 54 | use super::*; 55 | 56 | #[test] 57 | fn test_can_parse_valid_rss() { 58 | let rss_content = r#" 59 | 60 | 61 | 62 | Test RSS Feed 63 | https://example.com/feed 64 | A test RSS feed 65 | 66 | Test Item 67 | https://example.com/item1 68 | https://example.com/item1 69 | Mon, 01 Jan 2023 12:00:00 GMT 70 | 71 | 72 | 73 | "# 74 | .as_bytes(); 75 | 76 | assert!(RssParserImpl.parse_feed(rss_content).is_ok()); 77 | } 78 | 79 | #[test] 80 | fn test_cannot_parse_atom() { 81 | let atom_content = r#" 82 | 83 | 84 | Test Atom Feed 85 | 86 | 2023-01-01T12:00:00Z 87 | https://example.com/feed 88 | 89 | Test Entry 90 | 91 | https://example.com/entry1 92 | 2023-01-01T12:00:00Z 93 | 94 | 95 | "# 96 | .as_bytes(); 97 | 98 | assert!(RssParserImpl.parse_feed(atom_content).is_err()); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/providers/html_processor/error.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, thiserror::Error)] 2 | pub enum HtmlProcessorError { 3 | #[error("unable to parse html, this usually means the article have no
and/or
tag(s)")] 4 | UnableToParse, 5 | 6 | #[error("unexpected error ocurred: {0:?}")] 7 | Unexpected(#[source] anyhow::Error), 8 | } 9 | -------------------------------------------------------------------------------- /src/providers/html_processor/html_processor_impl.rs: -------------------------------------------------------------------------------- 1 | //! TODO: Implement a proper parser!!! 2 | use crate::providers::image_processor::ImageProcessor; 3 | 4 | use super::{error::HtmlProcessorError, Result}; 5 | use axum::async_trait; 6 | use regex::Regex; 7 | 8 | use super::HtmlProcessor; 9 | 10 | #[derive(Clone)] 11 | pub struct HtmlProcessorImpl { 12 | img_tag_regex: Regex, 13 | tag_removal_regex: Regex, 14 | attr_removal_regex: Regex, 15 | favicon_url: Regex, 16 | } 17 | 18 | impl HtmlProcessorImpl { 19 | pub fn new() -> Result { 20 | Ok(Self { 21 | img_tag_regex: Regex::new(r#"]*\bsrc\s*=\s*['"]([^'"]+)['"][^>]*>"#) 22 | .map_err(|e| HtmlProcessorError::Unexpected(e.into()))?, 23 | tag_removal_regex: Regex::new( 24 | r"(?si)|/>)||/>)", 25 | ) 26 | .map_err(|e| HtmlProcessorError::Unexpected(e.into()))?, 27 | attr_removal_regex: Regex::new(r#"(?i)\b(on\w+|javascript:)[^"'<>]*=['"][^"']*['"]"#) 28 | .map_err(|e| HtmlProcessorError::Unexpected(e.into()))?, 29 | favicon_url: regex::Regex::new( 30 | r#"(?i)]*rel=["'][^"']*icon[^"']*["'][^>]*href=["']([^"']+)["']"#, 31 | ) 32 | .map_err(|e| HtmlProcessorError::Unexpected(e.into()))?, 33 | }) 34 | } 35 | 36 | fn extract_content_between_tag(html: &str, tag: &str) -> Result> { 37 | // let start_tag = "]*>")) 39 | .map_err(|e| HtmlProcessorError::Unexpected(e.into()))?; 40 | let end_tag = &format!(""); 41 | 42 | let start_tag = if let Some(st) = start_tag.find(html) { 43 | st 44 | } else { 45 | return Ok(None); 46 | }; 47 | 48 | // Locate the start and end positions of the
content 49 | if let Some(end_idx) = html.find(end_tag) { 50 | // Extract the content between the
tags 51 | let start = start_tag.end(); 52 | let article_content = &html[start..end_idx]; 53 | return Ok(Some(article_content.to_string())); 54 | } 55 | 56 | Ok(None) 57 | } 58 | } 59 | 60 | #[async_trait] 61 | impl HtmlProcessor for HtmlProcessorImpl { 62 | fn process_html_article(&self, html: &str) -> Result { 63 | let content = Self::extract_content_between_tag(html, "main")?; 64 | if let Some(content) = content { 65 | return Ok(content); 66 | } 67 | 68 | let content = Self::extract_content_between_tag(html, "article")?; 69 | if let Some(content) = content { 70 | return Ok(content); 71 | } 72 | 73 | Err(HtmlProcessorError::UnableToParse) 74 | } 75 | 76 | async fn fix_img_src

(&self, html: &str, link: &str, image_processor: &P) -> Result 77 | where 78 | P: ImageProcessor + ?Sized, 79 | { 80 | // Regex to find tags and capture the src attribute 81 | let mut fixed_html = String::new(); 82 | let mut last_pos = 0; 83 | 84 | // Iterate over each match 85 | for cap in self.img_tag_regex.captures_iter(html) { 86 | // Append the text before the current match 87 | let mat = cap.get(0).ok_or_else(|| { 88 | HtmlProcessorError::Unexpected(anyhow::anyhow!( 89 | "could not extract information from matched tag" 90 | )) 91 | })?; 92 | fixed_html.push_str(&html[last_pos..mat.start()]); 93 | 94 | // Get the original tag and src value 95 | let full_tag = &cap[0]; 96 | let src_value = &cap[1]; 97 | 98 | if src_value.starts_with("data:image") || src_value.starts_with("/data:image") { 99 | fixed_html.push_str(full_tag); 100 | last_pos = mat.end(); 101 | continue; 102 | } 103 | 104 | let image_url = if src_value.starts_with("http://") || src_value.starts_with("https://") 105 | { 106 | src_value.to_owned() 107 | } else { 108 | match (src_value.starts_with("/"), link.ends_with("/")) { 109 | (true, true) => format!("{}{}", link, &src_value[1..]), 110 | (true, false) | (false, true) => format!("{}{}", link, src_value), 111 | (false, false) => format!("{}/{}", link, src_value), 112 | } 113 | }; 114 | 115 | let processed_image = image_processor.process_image_url(&image_url).await; 116 | 117 | let image_path = match &processed_image { 118 | Ok(path) => path, 119 | Err(e) => { 120 | tracing::error!("unable to process image: {e:?}"); 121 | // TODO: use a self property 122 | "/static/error_processing_image.png" 123 | } 124 | }; 125 | 126 | let corrected_tag = full_tag.replace(src_value, image_path); 127 | 128 | // Append the corrected tag 129 | fixed_html.push_str(&corrected_tag); 130 | 131 | // Update the position after the current match 132 | last_pos = mat.end(); 133 | } 134 | 135 | // Append the remaining part of the HTML 136 | fixed_html.push_str(&html[last_pos..]); 137 | 138 | Ok(fixed_html) 139 | } 140 | 141 | fn sanitize(&self, html: &str) -> Result { 142 | // Step 1: Remove harmful tags like 196 | 197 | 198 | 199 | 200 | 201 | "#; 202 | 203 | let expected = r#" 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | "#; 213 | let result = HtmlProcessorImpl::new().unwrap().sanitize(iframe); 214 | 215 | assert_eq!(expected, result.unwrap()); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/providers/html_processor/mod.rs: -------------------------------------------------------------------------------- 1 | mod error; 2 | mod html_processor_impl; 3 | 4 | use axum::async_trait; 5 | use error::HtmlProcessorError; 6 | pub use html_processor_impl::HtmlProcessorImpl; 7 | 8 | use super::image_processor::ImageProcessor; 9 | 10 | type Result = std::result::Result; 11 | 12 | #[async_trait] 13 | pub trait HtmlProcessor: Sync + Send { 14 | /// This function process an Html article. We call "html articles" those that are obtained by 15 | /// following the link of the RSS feed to an actual html file. In those articles, we only keep 16 | /// the

tag, discarding anything else 17 | fn process_html_article(&self, html: &str) -> Result; 18 | 19 | /// Fixes the src tag of images. 20 | /// Usually blogs link images relatively. This function download the image to the cache and 21 | /// fixes the tag. 22 | async fn fix_img_src

(&self, html: &str, link: &str, image_processor: &P) -> Result 23 | where 24 | P: ImageProcessor + ?Sized; 25 | 26 | /// Sanitizes the HTML 27 | /// Removes potentially harmful tags such as