19 Commits

Author SHA1 Message Date
mathias 8b56174856 fix scroll to mark read bug 2026-06-09 21:30:26 +02:00
mathias 039e0b448c all caught up icon, counter dynamic 2026-06-09 20:20:17 +02:00
mathias b851e0257c fix sync issue, frontend improvement 2026-06-09 19:50:47 +02:00
mathias d826a8f3dc cleanup when syncing 2026-06-08 17:31:10 +02:00
mathias 83fdb464af fix card layout 2026-06-08 16:59:30 +02:00
mathias 0ee9e22109 added docker update to readme 2026-06-08 14:31:06 +02:00
mathias a98a8ba9e6 card view, minor css bugfixes 2026-06-08 14:22:04 +02:00
mathias 3c42ebb972 mark all as read 2026-06-08 07:06:38 +02:00
mathias b4fc86302f hamburger menu, article view 2026-06-08 06:39:47 +02:00
mathias 39f08c7218 added favicon, improve layout 2026-06-07 20:18:31 +02:00
mathias b865fe982e upgraded packages 2026-06-07 17:58:03 +02:00
mathias 6eeb3384fe fixed rootless docker setup 2026-06-07 17:31:46 +02:00
mathias 675bf15828 fixed rootless docker setup 2026-06-07 17:12:17 +02:00
mathias c5e5e463f9 fixed rootless docker setup 2026-06-07 17:07:26 +02:00
mathias 997fc2989a added rootless docker setup 2026-06-07 16:57:12 +02:00
mathias 09eae69041 upgrade postgres 2026-06-07 16:50:14 +02:00
mathias e9d7f63ff3 remove .env 2026-06-07 16:29:52 +02:00
mathias 841e8419b0 updated rust version, minor fixes 2026-06-07 16:26:42 +02:00
mathias b4874ad318 claude rework 2026-06-07 15:43:43 +02:00
85 changed files with 7936 additions and 3553 deletions
+5 -6
View File
@@ -1,6 +1,5 @@
target/ target
tests/ vue/node_modules
Dockerfile vue/dist
scripts/ .git
migrations/ .env
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgres://admin:changeme@localhost/rss
JWT_SECRET=change-this-to-a-long-random-string
FRONTEND_ORIGIN=http://localhost:5173
RUST_LOG=info
POSTGRES_PASSWORD=changeme
+2
View File
@@ -1 +1,3 @@
/target /target
.env
.claude
Generated
+1950 -1531
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+22 -36
View File
@@ -1,51 +1,37 @@
[package] [package]
name = "rss-reader" name = "rss-reader"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2024"
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "rss-reader"
# 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
[dependencies] [dependencies]
reqwest = { version = "0.11", features = ["json", "blocking"] } reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.1" } rss = { version = "2.0.13" }
actix-web = "4.1.0" actix-web = "4.13"
actix-rt = "2.7.0" actix-rt = "2.10"
futures = "0.3.24" futures = "0.3.31"
serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] } serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
serde_derive = "1.0.145" serde_derive = "1.0.228"
actix-service = "2.0.2" actix-service = "2.0.3"
diesel = { version = "2.0.2", features = ["postgres", "chrono", "r2d2"] } diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenv = "0.15.0" dotenv = "0.15.0"
bcrypt = "0.13.0" bcrypt = "0.19"
uuid = {version = "1.2.1", features=["serde", "v4"]} uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0" jwt = "0.16.0"
hmac = "0.12.1" hmac = "0.12"
sha2 = "0.10.6" sha2 = "0.10"
scraper = "0.14.0" log = "0.4.32"
actix-cors = "0.6.4" env_logger = "0.11"
chrono = { version = "0.4.31", features = ["serde"] } scraper = "0.27"
dateparser = "0.2.0" actix-cors = "0.7"
tracing-appender = "0.2.3" chrono = { version = "0.4.45", features = ["serde"] }
once_cell = "1.19.0" dateparser = "0.3"
secrecy = { version = "0.8.0", features = ["serde"] }
tracing-actix-web = "0.7.10"
tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] }
tracing-log = "0.2.0"
config = "0.14.0"
diesel-connection = "4.1.0"
tracing = { version = "0.1.40", features = ["log"] }
tracing-bunyan-formatter = "0.3.9"
[dependencies.serde_json] [dependencies.serde_json]
version = "1.0.86" version = "1.0.150"
default-features = false default-features = false
features = ["alloc"] features = ["alloc"]
+16 -35
View File
@@ -1,41 +1,22 @@
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef # --- builder ---
WORKDIR /app FROM rust:1-slim-bookworm AS builder
RUN apt update && apt install lld clang -y RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev pkg-config libssl-dev \
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin rss-reader
RUN cargo install diesel_cli --no-default-features --features postgres
# Runtime stage
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Install OpenSSL - it is dynamically linked by some of our dependencies
# Install ca-certificates - it is needed to verify TLS certificates
# when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y openssl ca-certificates pkg-config\
&& apt-get install -y libpq5 \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy diesel_cli from builder to runtime WORKDIR /app
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/cargo/bin/diesel COPY . .
RUN cargo build --release
COPY --from=builder /app/target/release/rss-reader rss-reader # --- runtime ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rss-reader /usr/local/bin/rss-reader
EXPOSE 8001 EXPOSE 8001
# COPY configuration configuration CMD ["rss-reader"]
# ENV APP_ENVIRONMENT production
# ENTRYPOINT ["./rss-reader"]
ENTRYPOINT ["sh", "-c", "/app/rss-reader && diesel migration run"]
+315 -12
View File
@@ -1,24 +1,327 @@
## RSS-Reader [WIP] # RSS Reader
# Diesel Setup A self-hosted RSS reader: a Rust/actix-web + Diesel/PostgreSQL backend with a Vue 3 single-page-app frontend. Sync feeds, read articles, and mark them as read — from your desktop or your phone.
setup, first step, or when docker been and DB not found. ## Stack
`diesel setup` - **Backend**: Rust, actix-web, Diesel ORM, PostgreSQL, JWT auth
- **Frontend**: Vue 3, Vite, axios
- **Database**: PostgreSQL 18
generate table ---
`diesel migration generate create_to_do_items` ## Development setup
fill up and down ### Prerequisites
`diesel migration run` - [Rust](https://rustup.rs/) (stable toolchain)
- [Node.js](https://nodejs.org/) 20+ and npm
- PostgreSQL (locally, or via Docker — see below)
- The Diesel CLI: `cargo install diesel_cli --no-default-features --features postgres`
(requires `libpq`; on Debian/Ubuntu: `sudo apt install libpq-dev`)
# docker ### 1. Configure environment variables
`docker exec -it 59ff8bad10c0 psql -d rss -U admin` Copy the example file and fill in your own values:
```sh
cp .env.example .env
```
# Create user `.env` is read by both the backend (via `dotenv`) and the `diesel` CLI, and is gitignored — never commit it.
curl -X POST -H "Content-Type: application/json" -d '{"name": "mace", "email": "safemind@posteo.de", "password": "secret"}' http://localhost:8001/api/v1/user/create
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | Postgres connection string, e.g. `postgres://admin:changeme@localhost/rss` |
| `JWT_SECRET` | Secret used to sign/verify auth tokens — use a long random string |
| `FRONTEND_ORIGIN` | Origin allowed by CORS, e.g. `http://localhost:5173` for the Vite dev server |
| `RUST_LOG` | Log level for `env_logger`, e.g. `info` |
| `POSTGRES_PASSWORD` | Password for the `admin` Postgres user (used by `docker-compose.yml`) |
### 2. Start PostgreSQL
The simplest way during development is to run just the database container:
```sh
docker compose up postgres -d
```
This starts Postgres on `localhost:5432` with the credentials from `.env`. Alternatively, point `DATABASE_URL` at any Postgres instance you already have running.
### 3. Run database migrations
Migrations are embedded in the backend binary and also run automatically on startup, but you can apply them manually with the Diesel CLI (useful when generating new migrations during development):
```sh
diesel setup # creates the database and runs existing migrations
diesel migration run # applies any pending migrations
diesel migration generate <name> # scaffolds a new up.sql/down.sql pair
```
### 4. Run the backend
```sh
cargo run
```
The API listens on `http://0.0.0.0:8001`. Backend logs respect `RUST_LOG`.
### 5. Run the frontend
```sh
cd vue
npm install
npm run dev -- --host
```
The Vite dev server runs on `http://localhost:5173` (the `--host` flag also exposes it on your LAN so you can test from a phone) and proxies `/api` requests to `http://localhost:8001` (configured in `vue/vite.config.js`).
### 6. Try it out
Create a user, then log in through the UI at `http://localhost:5173`:
```sh
curl -X POST -H "Content-Type: application/json" \
-d '{"name": "mace", "email": "you@example.com", "password": "secret"}' \
http://localhost:8001/api/v1/user/create
```
### Useful commands during development
```sh
# Backend
cargo build # compile
cargo test # run the test suite (needs a reachable Postgres + DATABASE_URL)
cargo clippy # lint
cargo fmt # format
```
```sh
# Frontend (run from vue/)
npm run test # run Vitest component tests
npm run lint # eslint
npm run format # prettier
```
```sh
# Inspect the dockerized database directly
docker exec -it rss-postgres psql -d rss -U admin
```
---
## Production setup (Docker)
The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container.
### 1. Configure environment variables
Same as in development — create a root-level `.env` from `.env.example` and fill in **strong, unique** values for `POSTGRES_PASSWORD` and `JWT_SECRET`. Set `FRONTEND_ORIGIN` to the URL the frontend will actually be served from (e.g. `http://<host-ip>:8080` or your domain), since the backend's CORS policy only allows that origin.
### 2. Build and start everything
```sh
docker compose up --build -d
```
This builds three images and starts them on a shared network:
- **`postgres`** — PostgreSQL 18, data persisted in the `postgres_data` volume, reachable on `localhost:5432`
- **`backend`** — multi-stage build (compiles the Rust binary in a `rust:slim` builder, runs it in a slim `debian` runtime image); runs embedded Diesel migrations automatically on startup; listens on `0.0.0.0:8001`
- **`frontend`** — multi-stage build (compiles the Vue app with `node:20-alpine`, serves the static bundle with `nginx:alpine`); listens on `0.0.0.0:8080` and proxies `/api/` to the `backend` service over Docker's internal network
### 3. Use it
- From the host machine: `http://localhost:8080`
- From another device on the same network (e.g. a phone): `http://<host-LAN-IP>:8080`
### Operating the stack
```sh
docker compose ps # check container status
docker compose logs -f backend # follow backend logs
docker compose down # stop everything (keeps the postgres_data volume)
docker compose down -v # stop and wipe all data — careful!
docker compose up --build -d # rebuild after pulling code changes
```
### Optional: hardened deployment — isolated user + rootless Docker
Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group.
Rootless Docker runs as a completely independent daemon — separate socket and storage (`~/.local/share/docker` vs. `/var/lib/docker`) — so it coexists fine with an existing system-wide Docker install on the same host. Just make sure `DOCKER_HOST`/`PATH` point at the rootless daemon when operating on this stack, and that the ports you publish in step 4 below aren't already in use elsewhere.
**1. Create the user:**
```sh
sudo adduser --disabled-password --gecos "" rss-svc
```
> The "normal" rootless Docker setup runs the daemon as a **per-user systemd service** kept alive via `loginctl enable-linger`, which depends on `systemd-logind`/D-Bus. Minimal headless images (DietPi included) often disable or strip those out — and whether re-enabling them survives the distro's own update mechanism is genuinely unclear. Rather than depend on that, the steps below run rootless Docker as an ordinary **system-level** unit with `User=rss-svc` — no logind, no D-Bus, no lingering, nothing that can be reset out from under you.
**2. Install rootless Docker for that user:**
```sh
sudo apt install -y uidmap
sudo -u rss-svc -H curl -fsSL https://get.docker.com/rootless -o /tmp/install-rootless.sh
sudo -u rss-svc -H sh /tmp/install-rootless.sh
```
The installer detects there's no active systemd **user session** for `rss-svc` (we're not logging in interactively), so instead of wiring up a per-user service it falls back to a runtime directory under the user's home — and prints the exact paths to use, e.g.:
```sh
export XDG_RUNTIME_DIR=/home/rss-svc/.docker/run
export PATH=/home/rss-svc/bin:$PATH
export DOCKER_HOST=unix:///home/rss-svc/.docker/run/docker.sock
```
Use the values **the installer prints for you** (add them to `~/.bashrc` as `rss-svc` — e.g. `sudo -u rss-svc -H bash`) — this is actually convenient for us, since `~/.docker/run` is a regular on-disk directory that persists across reboots without any `tmpfiles`/`RuntimeDirectory=` trickery.
Now create the system unit at `/etc/systemd/system/docker-rss-svc.service`, pointing `XDG_RUNTIME_DIR` at that same directory:
```ini
[Unit]
Description=Rootless Docker daemon (rss-svc)
After=network.target
[Service]
User=rss-svc
Group=rss-svc
Environment=PATH=/home/rss-svc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=XDG_RUNTIME_DIR=/home/rss-svc/.docker/run
ExecStart=/home/rss-svc/bin/dockerd-rootless.sh
Restart=always
RestartSec=2
Delegate=yes
Type=notify
NotifyAccess=all
KillMode=mixed
TasksMax=infinity
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
```
Then enable and start it:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now docker-rss-svc
```
Verify it came up (as `rss-svc`, with the `.bashrc` exports loaded):
```sh
docker info
```
> **cgroup driver note**: rootless Docker defaults to the **systemd** cgroup driver, which expects a per-user slice (`user-<uid>.slice`) created by a logind session — something we don't have here by design. If `docker compose up --build` later fails with `open /sys/fs/cgroup/user.slice/user-<uid>.slice/cgroup.controllers: no such file or directory`, switch dockerd to manage cgroups itself instead. As `rss-svc`:
> ```sh
> mkdir -p ~/.config/docker
> cat > ~/.config/docker/daemon.json << 'EOF'
> {
> "exec-opts": ["native.cgroupdriver=cgroupfs"]
> }
> EOF
> ```
> then, as your sudo-capable user, `sudo systemctl restart docker-rss-svc`.
**Updating rootless Docker:** the daemon and CLI live in `/home/rss-svc/bin` as a private, self-contained copy of the static binaries — entirely decoupled from the system package manager, so `apt upgrade` never touches them. To update, stop the daemon, re-run the same installer (it just re-downloads the current stable bundle for your architecture and overwrites the files in `~/bin` in place), then start it back up:
```sh
sudo systemctl stop docker-rss-svc
sudo -u rss-svc -H curl -fsSL https://get.docker.com/rootless -o /tmp/install-rootless.sh
sudo -u rss-svc -H sh /tmp/install-rootless.sh
sudo systemctl start docker-rss-svc
sudo -u rss-svc -H docker info # confirm the new version
```
**3. Deploy the stack as `rss-svc`:**
```sh
git clone <your-repo-url> ~/rss-reader
cd ~/rss-reader
cp .env.example .env
chmod 600 .env
```
Fill in `.env` with strong, unique secrets — `openssl rand -hex 32` is a convenient way to generate `JWT_SECRET`/`POSTGRES_PASSWORD`.
**4. Bind published ports to localhost only.** The only thing that needs to reach this stack from outside is the reverse proxy below, and it runs on the same host. Edit `docker-compose.yml`:
```yaml
postgres:
ports:
- "127.0.0.1:5432:5432"
backend:
ports:
- "127.0.0.1:8001:8001"
frontend:
ports:
- "127.0.0.1:8080:80"
```
**5. Bring it up:**
```sh
docker compose up --build -d
```
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
```sh
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
With this in place: only SSH and HTTPS are reachable from the network, the reverse proxy is the sole entry point into the app, the whole stack runs in its own user/container/network namespaces with no elevated privileges, and secrets live in a `chmod 600` `.env` owned by an account that can't do anything else on the system.
### 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
- Migrations run automatically at backend startup — no manual `diesel` step needed in production.
- Secrets (`POSTGRES_PASSWORD`, `JWT_SECRET`, `FRONTEND_ORIGIN`) come from the root `.env`, which is gitignored and interpolated into `docker-compose.yml` via `${VAR}` — never commit real secrets.
- If you need the app reachable from multiple origins (e.g. `localhost:8080` on desktop and `<lan-ip>:8080` on a phone), the current single-origin CORS check (`FRONTEND_ORIGIN`) won't allow both — pick the one you'll actually use, or relax the CORS policy in `src/main.rs` for a self-hosted, trusted-network setup.
-8
View File
@@ -1,8 +0,0 @@
application:
port: 8001
database:
host: "localhost"
port: 5432
username: "admin"
password: "secret+123"
database_name: "rss"
-2
View File
@@ -1,2 +0,0 @@
application:
host: 127.0.0.1
-2
View File
@@ -1,2 +0,0 @@
application:
host: 0.0.0.0
+26 -30
View File
@@ -1,44 +1,40 @@
version: "3.7"
services: services:
# vue-app:
# build:
# context: ./vue/
# dockerfile: Dockerfile
# ports:
# - "8080:8080" # Adjust the port as needed for your Rust application
# networks:
# - app-network
postgres: postgres:
restart: always
container_name: "rss-postgres" container_name: "rss-postgres"
image: "postgres:15" image: "postgres:18"
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
- "POSTGRES_USER=admin" - "POSTGRES_USER=admin"
- "POSTGRES_DB=rss" - "POSTGRES_DB=rss"
- "POSTGRES_PASSWORD=secret+123" - "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
networks:
- app-network
# rust-app: backend:
# build: container_name: "rss-backend"
# context: . # Specify the path to your Rust application's Dockerfile build:
# dockerfile: Dockerfile context: .
# ports: dockerfile: Dockerfile
# - "8001:8001" # Adjust the port as needed for your Rust application depends_on:
# depends_on: - postgres
# - postgres environment:
# networks: - "DATABASE_URL=postgres://admin:${POSTGRES_PASSWORD}@postgres/rss"
# - app-network - "JWT_SECRET=${JWT_SECRET}"
- "FRONTEND_ORIGIN=${FRONTEND_ORIGIN}"
- "RUST_LOG=${RUST_LOG}"
ports:
- "0.0.0.0:8001:8001"
networks: frontend:
app-network: container_name: "rss-frontend"
driver: bridge build:
context: ./vue
dockerfile: Dockerfile
depends_on:
- backend
ports:
- "0.0.0.0:8080:80"
volumes: volumes:
postgres_data: postgres_data:
-36
View File
@@ -1,36 +0,0 @@
const loginButton = document.getElementById('loginButton');
const username = document.getElementById(
'defaultLoginFormUsername');
const password = document.getElementById(
'defaultLoginFormPassword');
const message = document.getElementById("loginMessage");
loginButton.addEventListener("click", () => {
let xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/auth/login", true);
xhr.setRequestHeader("Content-Type",
"application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
let token = xhr.getResponseHeader("token");
localStorage.setItem("user-token", token);
console.log("status 200: " + document.location.origin);
window.location.replace(
document.location.origin);
} else {
message.innerText =
"login failed please try again";
}
}
};
let data = JSON.stringify({
"username": username.value,
"password": password.value
});
xhr.send(data);
message.innerText = "logging in";
})
-59
View File
@@ -1,59 +0,0 @@
if (localStorage.getItem("user-token") == null) {
window.location.replace(document.location.origin + "/login");
} else {
getArticles();
}
function apiCall(url, method) {
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.addEventListener("readystatechange", function () {
if (this.readyState === this.DONE) {
if (this.status === 401) {
window.location.replace(document.location.origin + "/login/");
} else {
runRenderProcess(JSON.parse(this.responseText));
localStorage.setItem("item-cache-date", new Date());
localStorage.setItem("item-cache-data", this.responseText);
}
}
});
xhr.open(method, "/api/v1" + url);
xhr.setRequestHeader("content-type", "application/json");
xhr.setRequestHeader("user-token", localStorage.getItem("user-token"));
return xhr;
}
function runRenderProcess(data) {
params = renderArticle(data["feeds"]);
// document.getElementById("mainContainer").innerHtml = params;
}
function renderArticle(feeds) {
let placeholder = "<div>";
for (i = 0; i < feeds.length; i++) {
let title = feeds[i]["title"];
placeholder += '<div class="itemContainer">' + "<p>" + title + "</p>";
let items = feeds[i]["items"];
for (t = 0; t < items.length; t++) {
placeholder +=
'<div class="article">' +
"<h2>" +
items[t].title +
"</h2>" +
"<p>" +
items[t].content +
"</p>";
}
placeholder += "</div>" + "</div>";
}
placeholder += "</div>";
document.getElementById("mainContainer").innerHTML = placeholder;
}
function getArticles() {
let call = apiCall("/article/get", "GET");
call.send();
}
+10 -4
View File
@@ -3,29 +3,36 @@ extern crate jwt;
extern crate sha2; extern crate sha2;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::env;
use actix_web::HttpRequest; use actix_web::HttpRequest;
use dotenv::dotenv;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey}; use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use sha2::Sha256; use sha2::Sha256;
pub struct JwtToken { pub struct JwtToken {
pub user_id: i32, pub user_id: i32,
pub body: String,
} }
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 {
dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
HmacSha256::new_from_slice(secret.as_bytes()).unwrap()
}
impl JwtToken { impl JwtToken {
pub fn encode(user_id: i32) -> String { pub fn encode(user_id: i32) -> String {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap(); let key: HmacSha256 = signing_key();
let mut claims = BTreeMap::new(); let mut claims = BTreeMap::new();
claims.insert("user_id", user_id); claims.insert("user_id", user_id);
claims.sign_with_key(&key).unwrap() claims.sign_with_key(&key).unwrap()
} }
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> { pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap(); let key: HmacSha256 = signing_key();
let token_str: &str = encoded_token.as_str(); let token_str: &str = encoded_token.as_str();
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> = let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
VerifyWithKey::verify_with_key(token_str, &key); VerifyWithKey::verify_with_key(token_str, &key);
@@ -36,7 +43,6 @@ impl JwtToken {
let claims = token.claims(); let claims = token.claims();
Ok(JwtToken { Ok(JwtToken {
user_id: claims["user_id"], user_id: claims["user_id"],
body: encoded_token,
}) })
} }
Err(_err) => Err("could not decode token"), Err(_err) => Err("could not decode token"),
+2 -3
View File
@@ -4,8 +4,7 @@ pub mod processes;
use crate::auth::processes::check_password; use crate::auth::processes::check_password;
use crate::auth::processes::extract_header_token; use crate::auth::processes::extract_header_token;
#[tracing::instrument(name = "Process token")] pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
pub fn process_token(request: &ServiceRequest) -> Result<String, &'static str> {
match extract_header_token(request) { match extract_header_token(request) {
Ok(token) => check_password(token), Ok(token) => check_password(token),
Err(message) => Err(message), Err(message) => Err(message),
@@ -27,7 +26,7 @@ mod mod_test {
.to_srv_request(); .to_srv_request();
match process_token(&request) { match process_token(&request) {
Ok(message) => assert_eq!("passed", message), Ok(user_id) => assert_eq!(32, user_id),
Err(_) => panic!("process token failed"), Err(_) => panic!("process token failed"),
} }
} }
+13 -12
View File
@@ -1,19 +1,21 @@
use super::jwt; use super::jwt;
use actix_web::dev::ServiceRequest; use actix_web::dev::ServiceRequest;
use secrecy::{ExposeSecret, Secret};
pub fn check_password(password: Secret<String>) -> Result<String, &'static str> { pub fn check_password(password: String) -> Result<i32, &'static str> {
match jwt::JwtToken::decode(password.expose_secret().to_string()) { match jwt::JwtToken::decode(password) {
Ok(_token) => Ok(String::from("passed")), Ok(token) => Ok(token.user_id),
Err(message) => Err(message), Err(message) => Err(message),
} }
} }
#[tracing::instrument(name = "Extract Header Token")] pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>, &'static str> { log::info!("Request: {:?}", request);
match request.headers().get("user-token") { match request.headers().get("user-token") {
Some(token) => match token.to_str() { Some(token) => match token.to_str() {
Ok(processed_password) => Ok(Secret::new(String::from(processed_password))), Ok(processed_password) => {
log::info!("Token provided: {}", processed_password);
Ok(String::from(processed_password))
}
Err(_processed_password) => Err("there was an error processing token"), Err(_processed_password) => Err("there was an error processing token"),
}, },
None => Err("there is no token"), None => Err("there is no token"),
@@ -23,7 +25,6 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>,
#[cfg(test)] #[cfg(test)]
mod processes_test { mod processes_test {
use actix_web::test::TestRequest; use actix_web::test::TestRequest;
use secrecy::{ExposeSecret, Secret};
use crate::auth::jwt::JwtToken; use crate::auth::jwt::JwtToken;
@@ -31,19 +32,19 @@ mod processes_test {
#[test] #[test]
fn check_correct_password() { fn check_correct_password() {
let password_string: Secret<String> = Secret::new(JwtToken::encode(32)); let password_string: String = JwtToken::encode(32);
let result = check_password(password_string); let result = check_password(password_string);
match result { match result {
Ok(check) => assert_eq!("passed", check), Ok(user_id) => assert_eq!(32, user_id),
_ => panic!("Check correct password failed."), _ => panic!("Check correct password failed."),
} }
} }
#[test] #[test]
fn incorrect_check_password() { fn incorrect_check_password() {
let password: Secret<String> = Secret::new(String::from("test")); let password: String = String::from("test");
match check_password(password) { match check_password(password) {
Err(message) => assert_eq!("could not decode token", message), Err(message) => assert_eq!("could not decode token", message),
@@ -58,7 +59,7 @@ mod processes_test {
.to_srv_request(); .to_srv_request();
match super::extract_header_token(&request) { match super::extract_header_token(&request) {
Ok(processed_password) => assert_eq!("token", processed_password.expose_secret()), Ok(processed_password) => assert_eq!("token", processed_password),
_ => panic!("failed extract_header_token"), _ => panic!("failed extract_header_token"),
} }
} }
-118
View File
@@ -1,118 +0,0 @@
use config::{Config, ConfigError};
use secrecy::{ExposeSecret, Secret};
#[derive(serde::Deserialize, Debug)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(serde::Deserialize, Debug)]
pub struct ApplicationSettings {
pub port: u16,
pub host: String,
}
#[derive(serde::Deserialize, Debug)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
pub port: u16,
pub host: String,
pub database_name: String,
}
impl TryFrom<Config> for Settings {
type Error = ConfigError;
fn try_from(builder: config::Config) -> Result<Self, Self::Error> {
// Extract values from the builder and construct Settings
let database = builder.get::<DatabaseSettings>("database")?;
let application = builder.get::<ApplicationSettings>("application")?;
Ok(Settings {
database,
application,
})
}
}
impl DatabaseSettings {
pub fn connection_string(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}/{}",
self.username,
self.password.expose_secret(),
self.host,
self.port,
self.database_name
))
}
pub fn connection_string_without_db(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}",
self.username,
self.password.expose_secret(),
self.host,
self.port
))
}
}
pub fn get_configuration() -> Result<Settings, ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory.");
let configuration_directory = base_path.join("configuration");
// Detect the running environment
// Default to `local`
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT.");
let environment_filename = format!("{}.yaml", environment.as_str());
// Initialise our configuration reader
let settings = config::Config::builder()
// Add configuration values from a file named `configuration.yaml`.
.add_source(config::File::from(
configuration_directory.join("base.yaml"),
))
.add_source(config::File::from(
configuration_directory.join(environment_filename),
))
.build()?;
// Try to convert the configuration values it read into
// our Settings type
settings.try_deserialize::<Settings>()
}
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{} is not a supported environement. \
Use either 'local' or 'production'.",
other
)),
}
}
}
-9
View File
@@ -1,9 +0,0 @@
application:
port: 8000
host: 127.0.0.1
database:
host: "127.0.0.1"
port: 5432
username: "postgres"
password: "password"
database_name: "newsletter"
+18 -9
View File
@@ -1,12 +1,21 @@
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use std::env;
pub fn get_connection_pool(url: &str) -> Pool<ConnectionManager<PgConnection>> { pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
let manager = ConnectionManager::<PgConnection>::new(url);
// Refer to the `r2d2` documentation for more methods to use pub fn establish_connection() -> PgConnection {
// when building a connection pool dotenv().ok();
Pool::builder()
.test_on_check_out(true) let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
.build(manager) PgConnection::establish(&database_url)
.expect("Could not build connection pool") .unwrap_or_else(|e| panic!("Error connecting to database {}: {}", database_url, e))
}
pub fn run_migrations(connection: &mut PgConnection) {
connection
.run_pending_migrations(MIGRATIONS)
.expect("Failed to run database migrations");
} }
+1 -1
View File
@@ -1,5 +1,5 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder}; use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize; use serde::Serialize;
use crate::reader::structs::feed::FeedAggregate; use crate::reader::structs::feed::FeedAggregate;
-1
View File
@@ -1,7 +1,6 @@
pub mod articles; pub mod articles;
pub mod login; pub mod login;
pub mod new_feed; pub mod new_feed;
pub mod new_feed_item;
pub mod new_user; pub mod new_user;
pub mod read_feed_item; pub mod read_feed_item;
pub mod readable; pub mod readable;
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize)]
pub struct NewFeedSchema { pub struct NewFeedSchema {
pub title: String, pub title: String,
pub url: String, pub url: String,
-9
View File
@@ -1,9 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewFeedItemSchema {
pub content: String,
pub feed_id: i32,
pub url: String,
pub title: String,
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize)]
pub struct NewUserSchema { pub struct NewUserSchema {
pub name: String, pub name: String,
pub email: String, pub email: String,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_derive::Deserialize; use serde_derive::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize)]
pub struct ReadItem { pub struct ReadItem {
pub id: i32, pub id: i32,
} }
+1 -1
View File
@@ -1,5 +1,5 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder}; use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize; use serde::Serialize;
#[derive(Serialize)] #[derive(Serialize)]
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize)]
pub struct UrlJson { pub struct UrlJson {
pub url: String, pub url: String,
} }
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_derive::Deserialize; use serde_derive::Deserialize;
#[derive(Deserialize, Debug)] #[derive(Deserialize)]
pub struct JsonUser { pub struct JsonUser {
pub user_id: i32, pub user_id: i32,
} }
-13
View File
@@ -1,13 +0,0 @@
extern crate diesel;
extern crate dotenv;
pub mod auth;
pub mod configuration;
pub mod database;
pub mod json_serialization;
pub mod models;
pub mod reader;
pub mod schema;
pub mod startup;
pub mod telemetry;
pub mod views;
+72 -23
View File
@@ -1,32 +1,81 @@
use std::net::TcpListener; extern crate diesel;
extern crate dotenv;
use diesel::{ use actix_cors::Cors;
r2d2::{ConnectionManager, Pool}, use actix_service::Service;
PgConnection, use actix_web::{App, HttpResponse, HttpServer};
}; use dotenv::dotenv;
use rss_reader::{ use futures::future::{ok, Either};
configuration::get_configuration, use std::env;
database::get_connection_pool, mod auth;
startup::run, mod database;
telemetry::{get_subscriber, init_subscriber}, mod json_serialization;
}; mod models;
use secrecy::ExposeSecret; mod reader;
mod schema;
#[cfg(test)]
mod test_helpers;
mod views;
#[actix_rt::main] #[actix_rt::main]
async fn main() -> std::io::Result<()> { async fn main() -> std::io::Result<()> {
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout); dotenv().ok();
init_subscriber(subscriber); env_logger::init();
let configuration = get_configuration().expect("Failed to read configuration."); database::run_migrations(&mut database::establish_connection());
let connection_pool: Pool<ConnectionManager<PgConnection>> = let frontend_origin =
get_connection_pool(configuration.database.connection_string().expose_secret()); env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| String::from("http://localhost:5173"));
let address = format!( HttpServer::new(move || {
"{}:{}", let cors = Cors::default()
configuration.application.host, configuration.application.port .allowed_origin(&frontend_origin)
); .allow_any_method()
.allow_any_header()
.supports_credentials();
let listener = TcpListener::bind(address)?; App::new()
run(listener, connection_pool)?.await .wrap_fn(|req, srv| {
let mut passed: bool;
let request_url: String = String::from(req.uri().path());
log::info!("Request Url: {}", request_url);
if req.path().contains("/article/") {
match auth::process_token(&req) {
Ok(user_id) => {
log::info!("Authenticated user {} for {}", user_id, request_url);
passed = true;
}
Err(_message) => passed = false,
}
} else {
log::warn!("No auth check done.");
passed = true;
}
if req.path().contains("user/create") {
passed = true;
}
log::info!("passed: {:?}", passed);
let end_result = match passed {
true => Either::Left(srv.call(req)),
false => Either::Right(ok(req.into_response(
HttpResponse::Unauthorized().finish().map_into_boxed_body(),
))),
};
async move {
let result = end_result.await?;
log::info!("{} -> {}", request_url, &result.status());
Ok(result)
}
})
.wrap(cors)
.configure(views::views_factory)
})
.bind("0.0.0.0:8001")?
.run()
.await
} }
+2 -4
View File
@@ -2,7 +2,6 @@ extern crate bcrypt;
use bcrypt::{hash, DEFAULT_COST}; use bcrypt::{hash, DEFAULT_COST};
use diesel::Insertable; use diesel::Insertable;
use secrecy::{ExposeSecret, Secret};
use uuid::Uuid; use uuid::Uuid;
use crate::schema::users; use crate::schema::users;
@@ -17,9 +16,8 @@ pub struct NewUser {
} }
impl NewUser { impl NewUser {
pub fn new(username: String, email: String, password: Secret<String>) -> NewUser { pub fn new(username: String, email: String, password: String) -> NewUser {
let hashed_password: String = let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
hash(password.expose_secret().as_str(), DEFAULT_COST).unwrap();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
NewUser { NewUser {
username, username,
+1 -1
View File
@@ -18,6 +18,6 @@ pub struct User {
impl User { impl User {
pub fn verify(self, password: String) -> bool { pub fn verify(self, password: String) -> bool {
return verify(password.as_str(), &self.password).unwrap(); verify(password.as_str(), &self.password).unwrap()
} }
} }
+37 -15
View File
@@ -1,23 +1,15 @@
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::{ use diesel::RunQueryDsl;
r2d2::{ConnectionManager, Pool},
PgConnection, RunQueryDsl,
};
use crate::{ use crate::{
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed, database::establish_connection, json_serialization::new_feed::NewFeedSchema,
models::feed::new_feed::NewFeed, schema::feed,
}; };
use super::feeds; use super::feeds;
#[tracing::instrument(name = "Add new feed", skip(pool))] pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
pub async fn add( let mut connection = establish_connection();
new_feed: web::Json<NewFeedSchema>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
let title: String = new_feed.title.clone(); let title: String = new_feed.title.clone();
let url: String = new_feed.url.clone(); let url: String = new_feed.url.clone();
let user_id: i32 = new_feed.user_id; let user_id: i32 = new_feed.user_id;
@@ -25,11 +17,13 @@ pub async fn add(
let result = feeds::get_feed(&url).await; let result = feeds::get_feed(&url).await;
match result { match result {
Ok(channel) => { Ok(channel) => {
log::info!("valid channel");
if channel.items.is_empty() { if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap(); return HttpResponse::ServiceUnavailable().await.unwrap();
} }
} }
Err(_) => { Err(e) => {
log::error!("{:?}", e);
return HttpResponse::NotFound().await.unwrap(); return HttpResponse::NotFound().await.unwrap();
} }
} }
@@ -42,6 +36,34 @@ pub async fn add(
match insert_result { match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(), Ok(_) => HttpResponse::Created().await.unwrap(),
Err(_) => HttpResponse::Conflict().await.unwrap(), Err(e) => {
log::error!("{e}");
HttpResponse::Conflict().await.unwrap()
}
}
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::add;
use crate::test_helpers::unique_suffix;
#[actix_web::test]
async fn add_fails_for_unfetchable_feed_url() {
let app = test::init_service(App::new().route("/add", web::post().to(add))).await;
let req = test::TestRequest::post()
.uri("/add")
.set_json(serde_json::json!({
"title": "Bad feed",
"url": format!("not-a-valid-url-{}", unique_suffix()),
"user_id": 1
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
} }
} }
+1 -1
View File
@@ -2,9 +2,9 @@ use std::error::Error;
use rss::Channel; use rss::Channel;
#[tracing::instrument(name = "Get Channel Feed")]
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> { pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
let content = reqwest::get(feed).await?.bytes().await?; let content = reqwest::get(feed).await?.bytes().await?;
let channel = Channel::read_from(&content[..])?; let channel = Channel::read_from(&content[..])?;
log::debug!("{:?}", channel);
Ok(channel) Ok(channel)
} }
+63 -29
View File
@@ -4,30 +4,23 @@ use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::reader::structs::feed::FeedAggregate; use crate::reader::structs::feed::FeedAggregate;
use crate::schema::feed_item::{feed_id, id, read}; use crate::schema::feed_item::{feed_id, id, read};
use crate::{ use crate::{
database::establish_connection,
json_serialization::articles::Articles, json_serialization::articles::Articles,
schema::feed::{self, user_id}, schema::feed::{self, user_id},
schema::feed_item, schema::feed_item,
}; };
use actix_web::{web, HttpRequest, Responder}; use actix_web::{web, HttpRequest, Responder};
use chrono::Local; use chrono::Local;
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::prelude::*;
use diesel::{prelude::*, r2d2};
use super::structs::article::Article; use super::structs::article::Article;
#[tracing::instrument(name = "Get feeds", skip(pool))] pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
pub async fn get(
path: web::Path<JsonUser>,
req: HttpRequest,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> impl Responder {
let request = req.clone(); let request = req.clone();
let req_user_id = path.user_id; let req_user_id = path.user_id;
// Clone the Arc containing the connection pool log::info!("Received user_id: {}", req_user_id);
let pool_arc = pool.get_ref().clone();
// Acquire a connection from the pool
let mut connection = pool_arc.get().expect("Failed to get database connection");
let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)
@@ -35,28 +28,19 @@ pub async fn get(
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new(); let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
for feed in feeds { for feed in feeds {
feed_aggregates.push(get_feed_aggregate(feed, &mut connection))
}
let articles: Articles = Articles {
feeds: feed_aggregates,
};
articles.respond_to(&request)
}
#[tracing::instrument(name = "Get feed aggregate", skip(connection))]
pub fn get_feed_aggregate(
feed: Feed,
connection: &mut r2d2::PooledConnection<ConnectionManager<PgConnection>>,
) -> FeedAggregate {
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))
.filter(read.eq(false)) .filter(read.eq(false))
.order(id.asc()) .order(id.asc())
.load(connection) .load(&mut connection)
.unwrap(); .unwrap();
log::info!(
"Load {} feed items for feed: {}",
existing_item.len(),
feed.url
);
let article_list: Vec<Article> = existing_item let article_list: Vec<Article> = existing_item
.into_iter() .into_iter()
.map(|feed_item: FeedItem| { .map(|feed_item: FeedItem| {
@@ -74,8 +58,58 @@ pub fn get_feed_aggregate(
}) })
.collect(); .collect();
FeedAggregate { log::info!("article list with {} items generated.", article_list.len());
feed_aggregates.push(FeedAggregate {
title: feed.title, title: feed.title,
items: article_list, items: article_list,
})
}
let articles: Articles = Articles {
feeds: feed_aggregates,
};
articles.respond_to(&request)
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::get;
use crate::database::establish_connection;
use crate::test_helpers::{
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
};
#[actix_web::test]
async fn get_returns_only_unread_items() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let unread = insert_feed_item(&mut connection, feed.id, false);
let read = insert_feed_item(&mut connection, feed.id, true);
let app =
test::init_service(App::new().route("/get/{user_id}", web::get().to(get))).await;
let req = test::TestRequest::get()
.uri(&format!("/get/{}", user.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains(&unread.title));
assert!(!body_str.contains(&read.title));
delete_feed_item(&mut connection, unread.id);
delete_feed_item(&mut connection, read.id);
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
} }
} }
+64 -17
View File
@@ -1,36 +1,83 @@
use crate::schema::feed_item::{id, read}; use crate::schema::feed_item::{id, read};
use crate::{ use crate::{
json_serialization::read_feed_item::ReadItem, models::feed_item::rss_feed_item::FeedItem, database::establish_connection, json_serialization::read_feed_item::ReadItem,
schema::feed_item, models::feed_item::rss_feed_item::FeedItem, schema::feed_item,
}; };
use actix_web::{web, HttpRequest, HttpResponse}; use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::RunQueryDsl;
use diesel::{ExpressionMethods, QueryDsl}; use diesel::{ExpressionMethods, QueryDsl};
use diesel::{PgConnection, RunQueryDsl};
#[tracing::instrument(name = "Mark as read", skip(pool))]
pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Responder {
let mut connection = establish_connection();
log::info!("Id: {}", path.id);
let feed_items: Vec<FeedItem> = feed_item::table let feed_items: Vec<FeedItem> = feed_item::table
.filter(id.eq(path.id)) .filter(id.eq(path.id))
.load::<FeedItem>(&mut connection) .load::<FeedItem>(&mut connection)
.unwrap(); .unwrap();
if feed_items.len() != 1 { if feed_items.len() != 1 {
return HttpResponse::NotFound().await.unwrap(); return HttpResponse::NotFound();
} }
let feed_item: &FeedItem = feed_items.first().unwrap(); let feed_item: &FeedItem = feed_items.first().unwrap();
let _result: Result<usize, diesel::result::Error> = diesel::update(feed_item) let result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
.set(read.eq(true)) .set(read.eq(true))
.execute(&mut connection); .execute(&mut connection);
HttpResponse::Ok().await.unwrap() log::info!("Mark as read: {:?}", result);
HttpResponse::Ok()
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use diesel::prelude::*;
use super::mark_read;
use crate::database::establish_connection;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item;
use crate::test_helpers::{
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
};
#[actix_web::test]
async fn mark_read_flips_the_read_flag() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, feed.id, false);
let app =
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
let req = test::TestRequest::put()
.uri(&format!("/read/{}", item.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
let updated: FeedItem = feed_item::table
.find(item.id)
.first(&mut connection)
.unwrap();
assert!(updated.read);
delete_feed_item(&mut connection, item.id);
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn mark_read_returns_not_found_for_unknown_id() {
let app =
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
let req = test::TestRequest::put().uri("/read/999999999").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
} }
+4 -2
View File
@@ -4,13 +4,15 @@ use crate::json_serialization::{readable::Readable, url::UrlJson};
use super::scraper::content::do_throttled_request; use super::scraper::content::do_throttled_request;
#[tracing::instrument(name = "Read Feed")]
pub async fn read(_req: HttpRequest, data: web::Json<UrlJson>) -> impl Responder { pub async fn read(_req: HttpRequest, data: web::Json<UrlJson>) -> impl Responder {
let result = do_throttled_request(&data.url); let result = do_throttled_request(&data.url);
let content = match result.await { let content = match result.await {
Ok(cont) => cont, Ok(cont) => cont,
Err(e) => e.to_string(), Err(e) => {
log::error!("Could not scrap url {}", data.url);
e.to_string()
}
}; };
Readable { content } Readable { content }
+387 -55
View File
@@ -4,68 +4,111 @@ use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem; use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem; use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item::{feed_id, title}; use crate::schema::feed_item::{feed_id, title};
use crate::schema::{ use crate::{
database::establish_connection,
schema::{
feed::{self, user_id}, feed::{self, user_id},
feed_item, feed_item,
},
}; };
use actix_web::{web, HttpRequest, HttpResponse}; use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::{DateTime, Local, NaiveDateTime}; use chrono::{DateTime, Duration, Local, NaiveDateTime};
use dateparser::parse; use dateparser::parse;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use rss::Item; use rss::Item;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
#[tracing::instrument(name = "Get Date")]
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> { fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
// let format_string = "%a, %d %b %Y %H:%M:%S %z"; if let Ok(result) = parse(date_str) {
let format_string = "%Y-%m-%dT%H:%M:%S%Z"; log::info!("Date: {:?}", result);
return Ok(result.with_timezone(&Local).naive_local());
let result = parse(date_str).unwrap();
match NaiveDateTime::parse_from_str(&result.to_string(), format_string) {
Ok(r) => Ok(r),
Err(_) => {
let datetime = DateTime::parse_from_rfc2822(date_str);
match datetime {
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
Err(_) => match DateTime::parse_from_rfc2822(date_str) {
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
Err(e) => Err(e),
},
}
} }
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,
} }
} }
#[tracing::instrument(name = "Create Feed Item", skip(connection))] fn escape_html_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
// Some feeds (e.g. Deutsche Welle) don't embed an <img> in the item content at
// all — they carry the article image as an RSS <enclosure> instead. Build an
// <img> tag from it so those feeds get a preview image too.
fn enclosure_image_html(item: &Item) -> Option<String> {
let enclosure = item.enclosure()?;
if !enclosure.mime_type().to_lowercase().starts_with("image/") {
return None;
}
Some(format!(
r#"<img src="{}">"#,
escape_html_attr(enclosure.url())
))
}
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);
let base_content: &str = match item.content() { // Resolve the publication date before any HTML parsing or DB work so we can
Some(c) => c, // bail out early for old articles. Items without a pub_date are treated as
None => match item.description() { // current (inserted unconditionally) — feeds that don't publish dates are
Some(c) => c, // typically small/curated enough that this is fine.
None => "", let mut time: NaiveDateTime = Local::now().naive_local();
}, if let Some(pub_date) = item.pub_date() {
time = match get_date(pub_date) {
Ok(date) => date,
Err(err) => {
log::error!("could not parse pub date: {}", err);
time
}
}; };
}
let cutoff = Local::now().naive_local() - Duration::days(14);
if time < cutoff {
log::info!("Skipping item {} (older than 2 weeks).", item_title);
return;
}
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
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") { match frag.select(&selector_img).find(image_src_is_resolvable) {
content.push_str(&element.html()); Some(image) => {
content.push_str("<br>") content.push_str(&image.html());
content.push_str("<br>");
}
None => {
if let Some(image_html) = enclosure_image_html(&item) {
content.push_str(&image_html);
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))
@@ -74,13 +117,6 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
.unwrap(); .unwrap();
if existing_item.is_empty() { if existing_item.is_empty() {
let mut time: NaiveDateTime = Local::now().naive_local();
if item.pub_date().is_some() {
time = match get_date(item.pub_date().unwrap()) {
Ok(date) => date,
Err(_err) => time,
};
}
let new_feed_item = NewFeedItem::new( let new_feed_item = NewFeedItem::new(
feed.id, feed.id,
content.clone(), content.clone(),
@@ -88,20 +124,31 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
item.link.unwrap(), item.link.unwrap(),
Some(time), Some(time),
); );
let _insert_result = diesel::insert_into(feed_item::table) let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item) .values(&new_feed_item)
.execute(connection); .execute(connection);
log::info!("Insert Result: {:?}", insert_result);
} else {
log::info!("Item {} already exists.", item_title);
} }
} }
#[tracing::instrument(name = "sync", skip(pool))] // Items without a `created_ts` (e.g. ones inserted before this column existed,
pub async fn sync( // or whose feed didn't provide a publish date) are left alone — `lt` never
_req: HttpRequest, // matches NULL, so there's nothing to special-case here.
data: web::Json<JsonUser>, fn delete_old_feed_items(connection: &mut PgConnection) {
pool: web::Data<Pool<ConnectionManager<PgConnection>>>, let cutoff = Local::now().naive_local() - Duration::days(14);
) -> HttpResponse { let result = diesel::delete(feed_item::table.filter(feed_item::created_ts.lt(cutoff)))
let pool_arc = pool.get_ref().clone(); .execute(connection);
let mut connection = pool_arc.get().expect("Failed to get database connection");
log::info!("Deleted old feed items (older than 2 weeks): {:?}", result);
}
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
let mut connection: diesel::PgConnection = establish_connection();
delete_old_feed_items(&mut connection);
let req_user_id: i32 = data.user_id; let req_user_id: i32 = data.user_id;
@@ -110,18 +157,303 @@ pub async fn sync(
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)
.unwrap(); .unwrap();
log::info!("Found {} feeds to sync.", feeds.len());
for feed in feeds { for feed in feeds {
log::info!("Try to get url: {}", feed.url);
let result = feeds::get_feed(&feed.url).await; let result = feeds::get_feed(&feed.url).await;
match result { match result {
Ok(channel) => { Ok(channel) => {
for item in channel.into_items() { for item in channel.into_items() {
log::info!("{:?}", item);
create_feed_item(item, &feed, &mut connection); create_feed_item(item, &feed, &mut connection);
} }
} }
Err(_e) => return HttpResponse::InternalServerError().await.unwrap(), Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
} }
} }
HttpResponse::Ok().await.unwrap() HttpResponse::Ok()
}
#[cfg(test)]
mod tests {
use crate::models::feed::new_feed::NewFeed;
use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::users;
use crate::test_helpers::unique_suffix;
use super::*;
#[test]
fn get_date_parses_iso8601_dates() {
assert!(get_date("2024-01-01T12:00:00Z").is_ok());
}
#[test]
fn get_date_parses_rfc2822_dates() {
assert!(get_date("Tue, 03 Jun 2025 10:00:00 GMT").is_ok());
}
#[test]
fn get_date_returns_err_for_unparseable_dates() {
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());
}
#[test]
fn enclosure_image_html_builds_img_for_image_enclosures() {
let mut item = Item::default();
item.set_enclosure(rss::Enclosure {
url: "https://static.dw.com/image/73880499_302.jpg".to_string(),
length: "2000".to_string(),
mime_type: "image/jpeg".to_string(),
});
assert_eq!(
Some(r#"<img src="https://static.dw.com/image/73880499_302.jpg">"#.to_string()),
enclosure_image_html(&item)
);
}
#[test]
fn enclosure_image_html_ignores_non_image_enclosures() {
let mut item = Item::default();
item.set_enclosure(rss::Enclosure {
url: "https://example.test/episode.mp3".to_string(),
length: "2000".to_string(),
mime_type: "audio/mpeg".to_string(),
});
assert_eq!(None, enclosure_image_html(&item));
}
#[test]
fn enclosure_image_html_escapes_url_attribute() {
let mut item = Item::default();
item.set_enclosure(rss::Enclosure {
url: "https://example.test/img.jpg?a=1&b=\"x\"".to_string(),
length: "2000".to_string(),
mime_type: "image/jpeg".to_string(),
});
assert_eq!(
Some(r#"<img src="https://example.test/img.jpg?a=1&amp;b=&quot;x&quot;">"#.to_string()),
enclosure_image_html(&item)
);
}
#[actix_web::test]
async fn delete_old_feed_items_removes_items_older_than_two_weeks_but_keeps_recent_ones() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("cleanup_test_{suffix}"),
format!("cleanup_{suffix}@example.test"),
"secret".to_string(),
);
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("Cleanup test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
let now = Local::now().naive_local();
let old_item = NewFeedItem::new(
feed.id,
"old content".to_string(),
format!("Old article {suffix}"),
format!("https://example.test/article/old-{suffix}"),
Some(now - Duration::days(20)),
);
let recent_item = NewFeedItem::new(
feed.id,
"recent content".to_string(),
format!("Recent article {suffix}"),
format!("https://example.test/article/recent-{suffix}"),
Some(now - Duration::days(1)),
);
diesel::insert_into(feed_item::table)
.values(&old_item)
.execute(&mut connection)
.unwrap();
let recent: FeedItem = diesel::insert_into(feed_item::table)
.values(&recent_item)
.get_result(&mut connection)
.unwrap();
delete_old_feed_items(&mut connection);
let remaining: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, remaining.len(), "only the recent item should survive cleanup");
assert_eq!(recent.id, remaining[0].id);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
#[actix_web::test]
async fn create_feed_item_skips_articles_older_than_two_weeks() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("age_skip_test_{suffix}"),
format!("age_skip_{suffix}@example.test"),
"secret".to_string(),
);
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("Age skip test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
// Item with a pub_date 20 days ago — should be ignored by create_feed_item.
let old_date = (Local::now() - Duration::days(20))
.format("%a, %d %b %Y %H:%M:%S %z")
.to_string();
let mut old_item = Item::default();
old_item.set_title(Some(format!("Old article {suffix}")));
old_item.set_link(Some(format!("https://example.test/old/{suffix}")));
old_item.set_pub_date(Some(old_date));
old_item.set_content(Some("<p>old</p>".to_string()));
// Item without a pub_date — treated as current, should be inserted.
let mut fresh_item = Item::default();
fresh_item.set_title(Some(format!("Fresh article {suffix}")));
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
create_feed_item(old_item, &feed, &mut connection);
create_feed_item(fresh_item, &feed, &mut connection);
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, items.len(), "old item should have been skipped");
assert!(
items[0].title.contains("Fresh article"),
"only the fresh item should be present"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
#[actix_web::test]
async fn create_feed_item_does_not_duplicate_existing_items() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("sync_test_{suffix}"),
format!("sync_{suffix}@example.test"),
"secret".to_string(),
);
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("Sync test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
let mut item = Item::default();
item.set_title(Some(format!("Sync test article {suffix}")));
item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
create_feed_item(item.clone(), &feed, &mut connection);
create_feed_item(item, &feed, &mut connection);
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, items.len(), "duplicate feed items should not be created");
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
} }
-56
View File
@@ -1,56 +0,0 @@
use std::net::TcpListener;
use actix_service::Service;
use actix_web::web;
use actix_web::{dev::Server, App, HttpResponse, HttpServer};
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::PgConnection;
use futures::future::{ok, Either};
use crate::auth;
use crate::views;
#[tracing::instrument(name = "Run application", skip(connection, listener))]
pub fn run(
listener: TcpListener,
connection: Pool<ConnectionManager<PgConnection>>,
) -> Result<Server, std::io::Error> {
let wrapper = web::Data::new(connection);
let server = HttpServer::new(move || {
App::new()
.wrap_fn(|req, srv| {
let mut passed: bool;
if req.path().contains("/article/") {
match auth::process_token(&req) {
Ok(_token) => passed = true,
Err(_message) => passed = false,
}
} else {
passed = true;
}
if req.path().contains("user/create") {
passed = true;
}
let end_result = match passed {
true => Either::Left(srv.call(req)),
false => Either::Right(ok(req.into_response(
HttpResponse::Unauthorized().finish().map_into_boxed_body(),
))),
};
async move {
let result = end_result.await?;
Ok(result)
}
})
.app_data(wrapper.clone())
.configure(views::views_factory)
})
.listen(listener)?
.run();
Ok(server)
}
-27
View File
@@ -1,27 +0,0 @@
use tracing::{dispatcher::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
pub fn get_subscriber<Sink>(
name: String,
env_filter: String,
sink: Sink,
) -> impl Subscriber + Send + Sync
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
let formatting_layer = BunyanFormattingLayer::new(name, sink);
Registry::default()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger.");
set_global_default(subscriber.into()).expect("Failed to set subscriber.");
}
+91
View File
@@ -0,0 +1,91 @@
#![cfg(test)]
use diesel::prelude::*;
use uuid::Uuid;
use crate::models::feed::new_feed::NewFeed;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::{feed, feed_item, users};
// Test fixtures are written through the same models/schema as the app and
// cleaned up explicitly afterwards (rather than wrapped in a rolled-back
// transaction), because every handler opens its own DB connection via
// `establish_connection`, so a transaction held by the test would not be
// visible to the handler under test. Random suffixes keep parallel test runs
// from colliding on the unique username/email/url constraints.
pub fn unique_suffix() -> String {
Uuid::new_v4().to_string()
}
pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("test_user_{suffix}"),
format!("test_{suffix}@example.test"),
password.to_string(),
);
diesel::insert_into(users::table)
.values(&new_user)
.get_result(connection)
.expect("failed to insert test user")
}
pub fn delete_user(connection: &mut PgConnection, user_id: i32) {
diesel::delete(users::table.filter(users::id.eq(user_id)))
.execute(connection)
.ok();
}
pub fn insert_feed(connection: &mut PgConnection, owner_id: i32) -> Feed {
let suffix = unique_suffix();
let new_feed = NewFeed::new(
format!("Test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
owner_id,
);
diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(connection)
.expect("failed to insert test feed")
}
pub fn delete_feed(connection: &mut PgConnection, id: i32) {
diesel::delete(feed::table.filter(feed::id.eq(id)))
.execute(connection)
.ok();
}
pub fn insert_feed_item(connection: &mut PgConnection, owning_feed_id: i32, read: bool) -> FeedItem {
let suffix = unique_suffix();
let new_item = NewFeedItem::new(
owning_feed_id,
format!("Content {suffix}"),
format!("Title {suffix}"),
format!("https://example.test/article/{suffix}"),
None,
);
let item: FeedItem = diesel::insert_into(feed_item::table)
.values(&new_item)
.get_result(connection)
.expect("failed to insert test feed item");
if read {
diesel::update(&item)
.set(feed_item::read.eq(true))
.execute(connection)
.expect("failed to mark test feed item as read");
}
item
}
pub fn delete_feed_item(connection: &mut PgConnection, id: i32) {
diesel::delete(feed_item::table.filter(feed_item::id.eq(id)))
.execute(connection)
.ok();
}
+75 -8
View File
@@ -1,3 +1,4 @@
use crate::database::establish_connection;
use crate::diesel; use crate::diesel;
use crate::json_serialization::login::Login; use crate::json_serialization::login::Login;
use crate::models::user::rss_user::User; use crate::models::user::rss_user::User;
@@ -5,18 +6,13 @@ use crate::schema::users;
use crate::{auth::jwt::JwtToken, schema::users::username}; use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::prelude::*; use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
pub async fn login(
credentials: web::Json<Login>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
let username_cred: String = credentials.username.clone(); let username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone(); let password: String = credentials.password.clone();
let mut connection = establish_connection();
let users: Vec<User> = users::table let users: Vec<User> = users::table
.filter(username.eq(username_cred.as_str())) .filter(username.eq(username_cred.as_str()))
.load::<User>(&mut connection) .load::<User>(&mut connection)
@@ -25,6 +21,10 @@ pub async fn login(
if users.is_empty() { if users.is_empty() {
return HttpResponse::NotFound().await.unwrap(); return HttpResponse::NotFound().await.unwrap();
} else if users.len() > 1 { } else if users.len() > 1 {
log::error!(
"multiple user have the usernam: {}",
credentials.username.clone()
);
return HttpResponse::Conflict().await.unwrap(); return HttpResponse::Conflict().await.unwrap();
} }
@@ -32,6 +32,7 @@ pub async fn login(
match user.clone().verify(password) { match user.clone().verify(password) {
true => { true => {
log::info!("verified password successfully for user {}", user.id);
let token: String = JwtToken::encode(user.clone().id); let token: String = JwtToken::encode(user.clone().id);
HttpResponse::Ok() HttpResponse::Ok()
.insert_header(("token", token)) .insert_header(("token", token))
@@ -42,3 +43,69 @@ pub async fn login(
false => HttpResponse::Unauthorized().await.unwrap(), false => HttpResponse::Unauthorized().await.unwrap(),
} }
} }
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::login;
use crate::database::establish_connection;
use crate::test_helpers::{delete_user, insert_user, unique_suffix};
#[actix_web::test]
async fn login_succeeds_with_correct_credentials() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "correct-password");
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
let req = test::TestRequest::post()
.uri("/login")
.set_json(serde_json::json!({
"username": user.username,
"password": "correct-password"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
assert!(resp.headers().contains_key("token"));
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn login_fails_with_wrong_password() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "correct-password");
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
let req = test::TestRequest::post()
.uri("/login")
.set_json(serde_json::json!({
"username": user.username,
"password": "wrong-password"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::UNAUTHORIZED, resp.status());
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn login_fails_for_unknown_user() {
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
let req = test::TestRequest::post()
.uri("/login")
.set_json(serde_json::json!({
"username": format!("does-not-exist-{}", unique_suffix()),
"password": "whatever"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
}
+24 -2
View File
@@ -1,3 +1,25 @@
pub async fn logout() -> String { use actix_web::HttpResponse;
"logout view".to_string()
// JWT auth is stateless and there is no token blacklist, so logging out is
// purely a client-side action (discarding the stored token). This endpoint
// exists so the frontend has something to call and gets a clean response.
pub async fn logout() -> HttpResponse {
HttpResponse::Ok().finish()
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::logout;
#[actix_web::test]
async fn logout_returns_ok() {
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
let req = test::TestRequest::post().uri("/logout").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
}
} }
+63 -14
View File
@@ -1,25 +1,16 @@
use crate::database::establish_connection;
use crate::diesel; use crate::diesel;
use crate::json_serialization::new_user::NewUserSchema; use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser; use crate::models::user::new_user::NewUser;
use crate::schema::users; use crate::schema::users;
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::{ use diesel::prelude::*;
prelude::*,
r2d2::{ConnectionManager, Pool},
};
use secrecy::Secret;
#[tracing::instrument(name = "Create new User", skip(pool))]
pub async fn create(
new_user: web::Json<NewUserSchema>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
let mut connection = establish_connection();
let name: String = new_user.name.clone(); let name: String = new_user.name.clone();
let email: String = new_user.email.clone(); let email: String = new_user.email.clone();
let new_password: Secret<String> = Secret::new(new_user.password.clone()); let new_password: String = new_user.password.clone();
let new_user = NewUser::new(name, email, new_password); let new_user = NewUser::new(name, email, new_password);
@@ -32,3 +23,61 @@ pub async fn create(
Err(_) => HttpResponse::Conflict().await.unwrap(), Err(_) => HttpResponse::Conflict().await.unwrap(),
} }
} }
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use diesel::prelude::*;
use super::create;
use crate::database::establish_connection;
use crate::schema::users;
use crate::test_helpers::{delete_user, insert_user, unique_suffix};
#[actix_web::test]
async fn create_succeeds_for_new_user() {
let suffix = unique_suffix();
let username = format!("new_user_{suffix}");
let email = format!("new_{suffix}@example.test");
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
let req = test::TestRequest::post()
.uri("/create")
.set_json(serde_json::json!({
"name": username,
"email": email,
"password": "secret"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::CREATED, resp.status());
let mut connection = establish_connection();
diesel::delete(users::table.filter(users::username.eq(&username)))
.execute(&mut connection)
.ok();
}
#[actix_web::test]
async fn create_fails_for_duplicate_user() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
let req = test::TestRequest::post()
.uri("/create")
.set_json(serde_json::json!({
"name": user.username,
"email": user.email,
"password": "secret"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::CONFLICT, resp.status());
delete_user(&mut connection, user.id);
}
}
-12
View File
@@ -1,12 +0,0 @@
.header {
background: #034f84;
margin-bottom: 0.3rem;
}
.header p {
color: white;
display: inline-block;
margin: 0.5rem;
margin-right: 0.4rem;
margin-left: 0.4rem;
}
-4
View File
@@ -1,4 +0,0 @@
<div class="header">
<p>complete tasks: </p><p id="completeNum"></p>
<p>pending tasks: </p><p id="pendingNum"></p>
</div>
-51
View File
@@ -1,51 +0,0 @@
<html>
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1.0" />
<meta name="description" content="This is a simple to do app" />
<meta httpEquiv="X-UA-Compatiable" content="ie=edge" />
<title>Login</title>
</head>
<style>
{{BASE_CSS}}
{{CSS}}
.loginButtonStyle {
display: inline-block;
background: #f7786b;
border: none;
padding: 0.5rem;
padding-left: 2rem;
padding-right: 2rem;
color: white;
}
.loginButtonStyle:hover {
background: #f7686b;
color: black;
}
</style>
<body>
<div class="mainContainer">
<h2 class="ContainerTitle" style="text-align:center;">Login</h2>
<p id="loginMessage" class="FeedbackMessage" style="text-align:center;"></p>
<form style="text-align:center;" action="submit">
<input type="text" value="" placeholder="Username" class="formInputContainer"
id="defaultLoginFormUsername"><br>
<p></p>
<input type="password" value="" placeholder="Password" class="formInputContainer"
id="defaultLoginFormPassword"><br><br>
<input type="button" value="Submit" class="loginButtonStyle" id="loginButton" style="text-align:center;">
</form>
</div>
</body>
<script>
{{JAVASCRIPT}}
</script>
</html>
-26
View File
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width,
initial-scale=1.0"
/>
<meta httpEquiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="This is a simple to do app" />
<title>ToDo App</title>
</head>
<style>
BASE_CSS
CSS
HEADER_CSS
</style>
<body>
<div id="mainContainer" class="mainContainer"></div>
</body>
<script>
JAVASCRIPT;
</script>
</html>
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=http://localhost:8001
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=http://rust-app:8001
+11 -17
View File
@@ -1,22 +1,16 @@
FROM node:lts-alpine # --- builder ---
FROM node:20-alpine AS builder
# install simple http server for serving static content
RUN npm install -g http-server
# make the 'app' folder the current working directory
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./
# copy both 'package.json' and 'package-lock.json' (if available) RUN npm ci
COPY package*.json ./
# install project dependencies
RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . . COPY . .
# build app for production with minification
RUN npm run build RUN npm run build
EXPOSE 8080 # --- runtime ---
CMD [ "http-server", "dist" ] FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+2 -1
View File
@@ -3,7 +3,8 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS-Reader</title> <title>RSS-Reader</title>
</head> </head>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2872 -540
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -6,24 +6,26 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.4", "@mozilla/readability": "^0.6.0",
"axios": "^1.5.0", "axios": "^1.17.0",
"vue": "^3.3.4", "vue": "^3.5.35",
"vue-router": "^4.2.4", "vue-router": "^5.1.0"
"vue-sessionstorage": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.16.1",
"@vitejs/plugin-vue": "^4.3.1", "@vitejs/plugin-vue": "^6.0.7",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^8.0.0",
"dotenv": "^16.4.5", "@vue/test-utils": "^2.4.11",
"eslint": "^8.46.0", "eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1", "eslint-plugin-vue": "^9.16.1",
"prettier": "^3.0.0", "jsdom": "^29.1.1",
"vite": "^4.4.9" "prettier": "^3.8.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#1a8f5e"/>
<circle cx="20" cy="44" r="6" fill="#ffffff"/>
<path d="M14 28a22 22 0 0 1 22 22h-8a14 14 0 0 0-14-14z" fill="#ffffff"/>
<path d="M14 14a36 36 0 0 1 36 36h-8a28 28 0 0 0-28-28z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

+5 -72
View File
@@ -1,78 +1,11 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import AppNav from './components/AppNav.vue'
const route = useRoute()
</script> </script>
<template> <template>
<!-- <header> --> <AppNav v-if="route.meta.requiresAuth" />
<!-- <div class="wrapper"> -->
<!-- <nav> -->
<!-- <p onclick="$refs.sync">Sync</p> -->
<!-- <p>Login</p> -->
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
<!-- </nav> -->
<!-- </div> -->
<!-- </header> -->
<RouterView /> <RouterView />
</template> </template>
<style>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav p.router-link-exact-active {
color: var(--color-text);
}
nav p.router-link-exact-active:hover {
background-color: transparent;
}
nav p {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
nav p:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>
+11 -1
View File
@@ -33,6 +33,13 @@
--color-heading: var(--vt-c-text-light-1); --color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1); --color-text: var(--vt-c-text-light-1);
--color-accent: hsla(160, 100%, 37%, 1);
--color-accent-hover: hsla(160, 100%, 37%, 0.2);
--color-accent-2: hsla(200, 90%, 45%, 1);
--color-accent-2-hover: hsla(200, 90%, 35%, 1);
--color-info: #3498db;
--color-info-text: white;
--section-gap: 160px; --section-gap: 160px;
} }
@@ -47,6 +54,9 @@
--color-heading: var(--vt-c-text-dark-1); --color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2); --color-text: var(--vt-c-text-dark-2);
--color-accent-2: hsla(200, 90%, 65%, 1);
--color-accent-2-hover: hsla(200, 90%, 75%, 1);
} }
} }
@@ -66,7 +76,7 @@ body {
line-height: 1.6; line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px; font-size: clamp(14px, 2.5vw, 16px);
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
+68 -65
View File
@@ -3,7 +3,7 @@
#app { #app {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 0.5rem;
font-weight: normal; font-weight: normal;
} }
@@ -11,102 +11,105 @@
a, a,
.green { .green {
text-decoration: none; text-decoration: none;
color: hsla(160, 100%, 37%, 1); color: var(--color-accent);
transition: 0.4s; transition: 0.4s;
} }
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.feed-actions p {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.5rem 1rem;
margin: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
}
.feed-actions p:hover {
border-color: var(--color-border-hover);
}
.message { .message {
background-color: #3498db; background-color: var(--color-info);
color: white; color: var(--color-info-text);
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;
position: fixed; position: fixed;
top: 10px; top: 10px;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
max-width: min(90vw, 28rem);
overflow-wrap: break-word;
z-index: 9999; z-index: 9999;
} }
@media (hover: hover) { @media (hover: hover) {
a:hover { a:hover {
background-color: hsla(160, 100%, 37%, 0.2); background-color: var(--color-accent-hover);
} }
} }
.feed-source {
margin: 0;
padding: 1em 1em 0;
font-size: clamp(0.75rem, 2vw, 0.85rem);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-accent);
}
.feed-title { .feed-title {
cursor: pointer; cursor: pointer;
font-family: 'Courier New'; font-family: 'Courier New';
font-size: 22px; font-size: clamp(1.25rem, 4.5vw, 1.6rem);
font-weight: bold;
color: var(--color-accent-2);
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
padding: 1em; padding: 0.25em 1em 1em;
min-height: 44px;
transition: color 0.2s;
}
.feed-title:hover {
color: var(--color-accent-2-hover);
} }
.feed-content { .feed-content {
font-family: Georgia, 'Times New Roman', Times, serif; font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 20px; font-size: clamp(1rem, 3.5vw, 1.25rem);
padding: 1em; padding: 0 1em 1em;
display: flex; overflow-wrap: break-word;
flex-direction: column;
align-items: left;
text-align: left;
} }
.feed-content p {
padding: 1em;
}
.feed-content h2,
h3,
h4,
h5,
h6 {
padding: 1em;
font-size: 21px;
font-weight: bold;
}
.feed-content img { .feed-content img {
max-width: 100%; max-width: 100%;
margin-bottom: 10px; height: auto;
/* Adjust spacing between image and text */ }
.feed-content p {
padding: 0.5em 0;
}
.feed-content h3 {
padding: 0.5em 0;
font-size: clamp(1rem, 3vw, 1.3rem);
font-weight: bold;
} }
h3 { h3 {
font-size: 14px; font-size: clamp(0.85rem, 2.5vw, 1rem);
} }
@media (min-width: 768px) {
.form-group { #app {
margin-bottom: 15px; padding: 0.75rem;
} }
label {
display: block;
margin-bottom: 5px;
}
input[type="text"],
input[type="password"] {
/* width: 100%; */
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
.error {
color: red;
} }
+25 -6
View File
@@ -1,6 +1,11 @@
input { input {
margin: 15px; display: block;
width: 100%;
min-height: 44px;
margin: 0.5rem 0 1rem;
padding: 0.5rem;
font-size: 1rem;
} }
.modal-mask { .modal-mask {
@@ -16,10 +21,13 @@ input {
} }
.modal-container { .modal-container {
width: 300px; width: clamp(280px, 90vw, 420px);
max-height: 90vh;
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;
@@ -27,15 +35,26 @@ input {
.modal-header h3 { .modal-header h3 {
margin-top: 0; margin-top: 0;
color: #42b983; color: var(--color-heading);
} }
.modal-body { .modal-body {
margin: 20px 0; margin: 20px 0;
} }
.modal-default-button { .modal-footer {
float: right; display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.modal-footer button {
flex: 1 1 auto;
min-height: 44px;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
} }
/* /*
+208
View File
@@ -0,0 +1,208 @@
<script setup>
import { ref, computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
const router = useRouter()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
function logout() {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
closeMenu()
router.push({ name: 'login' })
}
function handleSync() {
sync()
closeMenu()
}
function handleMarkAllRead() {
markAllRead()
closeMenu()
}
function openAddModal() {
showModal.value = true
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
}
function handleToggleLayout() {
toggleLayout()
closeMenu()
}
</script>
<template>
<header class="app-nav">
<div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<button
class="app-nav__hamburger"
type="button"
:aria-expanded="menuOpen"
aria-controls="app-nav-menu"
:aria-label="menuOpen ? 'Close menu' : 'Open menu'"
@click="toggleMenu"
>
<span class="app-nav__hamburger-icon" aria-hidden="true">
<span></span><span></span><span></span>
</span>
</button>
</div>
<Transition name="app-nav-menu">
<nav
v-if="menuOpen"
id="app-nav-menu"
class="app-nav__menu"
@click.self="closeMenu"
>
<div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
</header>
</template>
<style scoped>
.app-nav {
position: relative;
}
.app-nav__wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.app-nav__title {
font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
}
.app-nav__unread {
font-weight: normal;
opacity: 0.6;
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.app-nav__hamburger-icon {
display: inline-flex;
flex-direction: column;
justify-content: space-between;
width: 22px;
height: 16px;
}
.app-nav__hamburger-icon span {
display: block;
height: 2px;
border-radius: 1px;
background: var(--color-text);
}
.app-nav__menu {
position: absolute;
inset: 100% 0 auto 0;
z-index: 30;
display: flex;
justify-content: flex-end;
padding: 0 1rem 1rem;
}
.app-nav__menu-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: clamp(220px, 60vw, 280px);
padding: 0.75rem;
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.app-nav__menu-item {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
text-decoration: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.app-nav__menu-item:hover {
border-color: var(--color-border-hover);
}
.app-nav__menu-item.router-link-exact-active {
font-weight: bold;
}
.app-nav-menu-enter-active,
.app-nav-menu-leave-active {
transition: opacity 0.2s ease;
}
.app-nav-menu-enter-from,
.app-nav-menu-leave-to {
opacity: 0;
}
@media (min-width: 768px) {
.app-nav__wrapper {
padding: 1rem 2rem;
}
}
</style>
-44
View File
@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true
}
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
+47 -32
View File
@@ -2,57 +2,40 @@
import axios from 'axios' import axios from 'axios'
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const error = ref('')
const router = useRouter() const router = useRouter()
async function login() { async function login() {
error.value = ''
const loginData = {
"username": username.value,
"password": password.value,
}
const jsonData = JSON.stringify(loginData)
console.log('test')
try { try {
const response = await axios.post('login/rss', jsonData, { const response = await axios.post('/api/v1/auth/login', {
username: username.value,
password: password.value,
}, {
headers: { headers: {
'Content-Type': 'application/json', // Set the content type to JSON 'Content-Type': 'application/json',
'crossDomain': true,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
'strict-origin-when-cross-origin': false
}, },
}); });
// Handle the response data here
console.log('Response:', response.data);
// You can also access the HTTP status code
console.log('HTTP Status Code:', response.status);
if (response.status == 200) { if (response.status == 200) {
let token = response.headers.token localStorage.setItem("user-token", response.headers.token)
let user_id = response.headers.user_id localStorage.setItem("user-id", response.headers.user_id)
localStorage.setItem("user-token", token)
localStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-token", token)
router.push({ name: 'feeds' }) router.push({ name: 'feeds' })
} }
// Handle success } catch (err) {
} catch (error) { console.error('Login failed:', err)
// Handle any errors here error.value = 'Login failed. Please check your username and password.'
console.error('Error:', error);
} }
// Implement your login logic here (e.g., send a request to your backend)
// If login is successful, you can redirect the user to the dashboard:
} }
</script> </script>
<template> <template>
<div> <div class="login-page">
<h1>Login Page</h1> <h1>Login</h1>
<form @submit.prevent="login"> <form @submit.prevent="login">
<div class="form-group"> <div class="form-group">
<label for="username">Username/Email:</label> <label for="username">Username/Email:</label>
@@ -62,7 +45,39 @@ async function login() {
<label for="password">Password:</label> <label for="password">Password:</label>
<input v-model="password" type="password" id="password" name="password" required /> <input v-model="password" type="password" id="password" name="password" required />
</div> </div>
<p v-if="error" class="login-error">{{ error }}</p>
<button type="submit">Login</button> <button type="submit">Login</button>
</form> </form>
</div> </div>
</template> </template>
<style scoped>
.login-page {
max-width: 420px;
margin: 2rem auto;
padding: 0 1rem;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.form-group input {
min-height: 44px;
padding: 0.5rem;
font-size: 1rem;
}
.login-page button {
min-height: 44px;
padding: 0.5rem 1.5rem;
font-size: 1rem;
cursor: pointer;
}
.login-error {
color: #c0392b;
}
</style>
+235 -157
View File
@@ -1,173 +1,48 @@
<script setup> <script setup>
import { ref, unref, onMounted, nextTick } from 'vue'; import { onMounted } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
import Modal from './modal/AddUrl.vue'; import Modal from './modal/AddUrl.vue';
import { useFeeds } from '@/composables/useFeeds';
const showMessage = ref(false) const {
const feeds = ref([]); feeds,
const message = ref('') showMessage,
const showModal = ref(false) message,
showModal,
viewMode,
currentIndex,
leaveArticleView,
layout,
nextArticle,
prevArticle,
fetchData,
getReadable,
setInitialLoad,
showMessageForXSeconds,
} = useFeeds()
async function getReadable(feed, index) { const shareLabel = navigator.share ? 'Share' : 'Copy link'
try {
const response = await axios.post("feeds/read", {
url: feed.url
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
const doc = new DOMParser().parseFromString(response.data.content, 'text/html'); async function shareUrl(url) {
const article = new Readability(doc).parse(); if (navigator.share) {
feeds.value[index].content = article.content; await navigator.share({ url })
} catch (error) { } else {
console.error('Error fetching data:', error) await navigator.clipboard.writeText(url)
showMessageForXSeconds(error, 5) showMessageForXSeconds('Link copied.', 2)
} }
} }
async function markRead(id) { onMounted(async () => {
try { setInitialLoad(false)
const response = await axios.put("feeds/read/" + id, await fetchData()
null,
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
}
)
console.log(response.status)
} catch (error) {
console.log(error)
}
}
function showMessageForXSeconds(text, seconds) {
message.value = text;
showMessage.value = true;
// Set a timeout to hide the message after x seconds
setTimeout(() => {
showMessage.value = false;
message.value = '';
}, seconds * 1000); // Convert seconds to milliseconds
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try {
const response = await axios.get("feeds/get/" + user_id, {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
});
const sortedItems = response.data.feeds.flatMap(feed => feed.items)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
feeds.value.push(...sortedItems);
await nextTick();
setupIntersectionObserver();
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
};
async function sync() {
try {
const response = await axios.post('feeds/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
if (response.status == 200) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
} catch (error) {
console.error('Error sync', error)
showMessageForXSeconds(error, 5)
}
}
let observer; // Declare observer outside the setup function
function setupIntersectionObserver() {
observer = new IntersectionObserver(handleIntersection, {
root: null, // Use the viewport as the root
rootMargin: '0px',
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
});
const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) {
observedDivs.forEach(observedDiv => {
observer.observe(observedDiv);
})
}
}
async function handleIntersection(entries) {
// The callback function for when the target element enters or exits the viewport
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is in sight');
} else if (initialLoad === true) {
console.log(entry.isIntersecting)
// Element is out of sight
if (entry.isVisible === false && entry.boundingClientRect.y < 0) {
console.log('Element is out of sight ' + entry.intersectionRatio);
//console.log(feeds.value[entry.target.id])
markRead(feeds.value[entry.target.id].id).await
removeFeed(entry.target.id)
document.getElementById(0).scrollIntoView()
}
}
})
}
function removeFeed(index) {
const array = unref(feeds);
array.splice(index, 1);
}
let initialLoad = false
onMounted(() => {
initialLoad = false
fetchData().await
setTimeout(function () { setTimeout(function () {
initialLoad = true setInitialLoad(true)
console.log('set to true') console.log('set to true')
}, 2000); }, 2000);
}); });
</script> </script>
<template> <template>
<header>
<div class="wrapper">
<nav>
<p @click="sync">Sync</p>
<!-- <p @click="updateShow(true)">Add RSS</p> -->
<p @click="showModal = true">Add RSS</p>
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
</nav>
</div>
</header>
<Teleport to="body"> <Teleport to="body">
<!-- use the modal component, pass in the prop -->
<modal :show="showModal" @close="showModal = false"> <modal :show="showModal" @close="showModal = false">
<template #header> <template #header>
<h3>Add RSS Feed</h3> <h3>Add RSS Feed</h3>
@@ -175,17 +50,220 @@ onMounted(() => {
</modal> </modal>
</Teleport> </Teleport>
<div> <div>
<h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
<div v-if="showMessage" class="message">{{ message }}</div> <div v-if="showMessage" class="message">{{ message }}</div>
<div id='article' class='article'>
<p v-if="feeds.length == 0">No unread articles.</p> <div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<polyline points="7 12.5 10.5 16 17 9"/>
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-for="( feed, index ) in feeds "> <template v-for="( feed, index ) in feeds ">
<div v-bind:id="index" class="observe"> <div v-bind:id="index" class="observe">
<p class="feed-source">{{ feed.feedTitle }}</p>
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2> <h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h3>{{ feed.timestamp }}</h3> <h3>{{ feed.timestamp }}</h3>
<p v-if="!feed.readable" class="feed-original-link">
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
</p>
<p class="feed-content" v-html='feed.content'></p> <p class="feed-content" v-html='feed.content'></p>
</div> </div>
</template> </template>
</div> </div>
<div v-else class="article-single">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<polyline points="7 12.5 10.5 16 17 9"/>
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else>
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2>
<h3>{{ feeds[currentIndex].timestamp }}</h3>
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="feed-content" v-html="feeds[currentIndex].content"></p>
</template>
<div class="article-nav">
<button
type="button"
class="article-nav__btn"
:disabled="currentIndex === 0"
aria-label="Previous article"
@click="prevArticle"
>&uarr;</button>
<button
type="button"
class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article"
@click="nextArticle"
>&darr;</button>
</div>
</div>
</div> </div>
</template> </template>
<style scoped>
/* Plain vertical stack of bordered "cards" deliberately not flex/grid, and
with no truncation/max-height: normal block flow lets each card grow to fit
its own full content (images included), with no cross-element interaction. */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem 1rem;
gap: 1rem;
opacity: 0.4;
}
.empty-state__icon {
width: 64px;
height: 64px;
}
.empty-state__label {
font-size: 1.1rem;
margin: 0;
}
.article--cards .observe {
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-background-soft);
}
.article--cards .observe + .observe {
margin-top: 1rem;
}
.article--cards .feed-title {
border-bottom: none;
}
.article--cards h3 {
margin: 0;
padding: 0 1em 0.5em;
}
/* `v-html` content isn't part of the component's render output, so it never
gets the scoped `data-v-*` attribute `:deep()` is required for this rule
to actually reach the injected <img> tags (without it, the selector silently
never matches). Cap the height so a large article photo reads as a tidy
preview thumbnail; the card itself is left to grow to whatever height its
content (image included) naturally needs no clamping, no max-height. */
.article--cards .feed-content :deep(img) {
max-height: 220px;
width: auto;
}
.feed-original-link {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.feed-original-link a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.25em 1em;
color: var(--color-accent);
text-decoration: none;
}
.feed-original-link a:hover {
text-decoration: underline;
}
.feed-share-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border: none;
background: transparent;
color: var(--color-text);
opacity: 0.45;
cursor: pointer;
transition: opacity 0.15s;
}
.feed-share-btn:hover {
opacity: 1;
}
.article-single {
position: relative;
padding-bottom: 5rem;
}
.article-single__back {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.article-single__back:hover {
border-color: var(--color-border-hover);
}
.article-nav {
position: fixed;
right: 1rem;
bottom: 1.5rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.article-nav__btn {
min-width: 56px;
min-height: 56px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.article-nav__btn:hover:not(:disabled),
.article-nav__btn:focus-visible {
border-color: var(--color-border-hover);
opacity: 1;
}
.article-nav__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
-86
View File
@@ -1,86 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
-86
View File
@@ -1,86 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
+202
View File
@@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import AppNav from '../AppNav.vue'
import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
describe('AppNav', () => {
let router
beforeEach(async () => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: { template: '<div />' } },
{ path: '/feeds', name: 'feeds', component: { template: '<div />' } },
],
})
router.push('/feeds')
await router.isReady()
})
async function mountWithMenuOpen() {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await wrapper.find('.app-nav__hamburger').trigger('click')
await flushPromises()
return wrapper
}
it('toggles the menu open and closed via the hamburger button', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
await wrapper.find('.app-nav__hamburger').trigger('click')
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
await wrapper.find('.app-nav__hamburger').trigger('click')
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('clears stored credentials and redirects to login on logout', async () => {
const wrapper = await mountWithMenuOpen()
await wrapper.find('.app-nav__logout').trigger('click')
await flushPromises()
expect(localStorage.getItem('user-token')).toBeNull()
expect(localStorage.getItem('user-id')).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
it('triggers a sync from the menu', async () => {
axios.get.mockResolvedValue({ data: { feeds: [] } })
axios.post.mockResolvedValueOnce({ status: 200 })
const wrapper = await mountWithMenuOpen()
const syncButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Sync')
await syncButton.trigger('click')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/sync',
{ user_id: 7 },
expect.anything(),
)
// Menu auto-closes after an action
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('opens the add-feed modal from the menu', async () => {
const wrapper = await mountWithMenuOpen()
const { showModal } = useFeeds()
const addButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Add RSS')
await addButton.trigger('click')
expect(showModal.value).toBe(true)
})
it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds()
const viewButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Article view')
await viewButton.trigger('click')
expect(viewMode.value).toBe('article')
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('switches the list layout from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen()
const { layout } = useFeeds()
const layoutButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Card layout')
await layoutButton.trigger('click')
expect(layout.value).toBe('cards')
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('hides the layout toggle while in article view', async () => {
const { viewMode } = useFeeds()
viewMode.value = 'article'
const wrapper = await mountWithMenuOpen()
expect(wrapper.findAll('.app-nav__menu-item').find(el => el.text().includes('layout'))).toBeUndefined()
})
it('marks all articles as read from the menu after confirmation', async () => {
const { feeds } = useFeeds()
feeds.value = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
axios.put.mockResolvedValue({ status: 200 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = await mountWithMenuOpen()
const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read')
await markAllButton.trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
expect(feeds.value).toHaveLength(0)
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
confirmSpy.mockRestore()
})
it('shows the unread count in the title when there are articles', async () => {
const { feeds } = useFeeds()
feeds.value = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('excludes already-read articles from the counter while in article view', async () => {
const { feeds } = useFeeds()
feeds.value = [
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
})
it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
await flushPromises()
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
})
it('does not mark articles as read when the confirmation is dismissed', async () => {
const { feeds } = useFeeds()
feeds.value = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const wrapper = await mountWithMenuOpen()
const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read')
await markAllButton.trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value).toHaveLength(1)
confirmSpy.mockRestore()
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import LoginPage from '../LoginPage.vue'
vi.mock('axios')
describe('LoginPage', () => {
let router
beforeEach(async () => {
localStorage.clear()
vi.clearAllMocks()
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: { template: '<div />' } },
{ path: '/feeds', name: 'feeds', component: { template: '<div />' } },
],
})
router.push('/login')
await router.isReady()
})
it('stores the token and redirects to feeds on successful login', async () => {
axios.post.mockResolvedValueOnce({
status: 200,
headers: { token: 'abc123', user_id: '7' },
})
const wrapper = mount(LoginPage, { global: { plugins: [router] } })
await wrapper.find('#username').setValue('alice')
await wrapper.find('#password').setValue('secret')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/auth/login',
{ username: 'alice', password: 'secret' },
expect.anything(),
)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(localStorage.getItem('user-id')).toBe('7')
expect(router.currentRoute.value.name).toBe('feeds')
})
it('shows an error message and does not redirect when login fails', async () => {
axios.post.mockRejectedValueOnce(new Error('Request failed'))
const wrapper = mount(LoginPage, { global: { plugins: [router] } })
await wrapper.find('#username').setValue('alice')
await wrapper.find('#password').setValue('wrong')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.text()).toContain('Login failed')
expect(localStorage.getItem('user-token')).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
})
@@ -0,0 +1,334 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import RssFeeds from '../RssFeeds.vue'
import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
// jsdom does not implement IntersectionObserver, but the component sets one up
// once the feed list has rendered.
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('RssFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
// useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests.
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
})
it('fetches the current user articles and shows the empty state', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mount(RssFeeds)
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
expect(wrapper.text()).toContain('All caught up')
})
it('renders the fetched feed items', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>hello</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
const wrapper = mount(RssFeeds)
await flushPromises()
expect(wrapper.text()).toContain('Article one')
expect(wrapper.text()).toContain('My Feed')
expect(wrapper.text()).not.toContain('All caught up')
})
it('renders the list as cards when the card layout is selected', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>hello</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
const { layout } = useFeeds()
layout.value = 'cards'
const wrapper = mount(RssFeeds)
await flushPromises()
expect(wrapper.find('.article').classes()).toContain('article--cards')
})
it('shows the full article content in cards with no truncation, growing the card to fit', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<img src="https://example.test/photo.jpg" alt="photo"><br>short summary',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
const { layout } = useFeeds()
layout.value = 'cards'
const wrapper = mount(RssFeeds)
await flushPromises()
// Preview images are shown (not hidden/truncated) and the snippet isn't clamped.
expect(wrapper.find('.feed-content img').exists()).toBe(true)
expect(wrapper.find('.feed-content').classes()).not.toContain('feed-content--clamped')
expect(wrapper.text()).toContain('short summary')
await wrapper.find('.feed-title').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('full text')
})
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('shows a link to the original article until the readable version is loaded', async () => {
// The API returns each item with a short summary already in `content` —
// the link must key off the `readable` flag (set once Readability has
// parsed the full article), not off `content` truthiness.
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>short summary</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
const wrapper = mount(RssFeeds)
await flushPromises()
const link = wrapper.find('.feed-original-link a')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBe('https://example.test/1')
expect(link.attributes('target')).toBe('_blank')
await wrapper.find('.feed-title').trigger('click')
await flushPromises()
expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
})
it('switches to article view and navigates between articles', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>one</p>',
url: 'https://example.test/1',
timestamp: '2026-02-01 10:00:00',
},
{
id: 2,
title: 'Article two',
content: '<p>two</p>',
url: 'https://example.test/2',
timestamp: '2026-01-01 10:00:00',
},
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
axios.post.mockResolvedValue({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
const wrapper = mount(RssFeeds)
await flushPromises()
// The view-toggle button now lives in AppNav's hamburger menu, not here —
// switch modes directly through the shared composable, as AppNav would.
useFeeds().toggleViewMode()
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
// Same as in list view: the readable content is loaded on demand by
// clicking the headline, not fetched automatically on entering the view.
expect(axios.post).not.toHaveBeenCalled()
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
await wrapper.find('.article-single .feed-title').trigger('click')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(false)
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article two')
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
})
it('drops articles read while paging through article view once back in the list', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>one</p>',
url: 'https://example.test/1',
timestamp: '2026-03-01 10:00:00',
},
{
id: 2,
title: 'Article two',
content: '<p>two</p>',
url: 'https://example.test/2',
timestamp: '2026-02-01 10:00:00',
},
{
id: 3,
title: 'Article three',
content: '<p>three</p>',
url: 'https://example.test/3',
timestamp: '2026-01-01 10:00:00',
},
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
const { toggleViewMode, leaveArticleView } = useFeeds()
// Enter article view (marks "Article one" read), page forward to "Article
// two" (marks it read too), then leave without visiting "Article three".
toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
leaveArticleView()
await flushPromises()
const titles = wrapper.findAll('.feed-title').map(el => el.text())
expect(titles).toEqual(['Article three'])
})
})
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
-7
View File
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
-19
View File
@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+3 -3
View File
@@ -15,7 +15,7 @@ async function save() {
submitted.value = true; submitted.value = true;
console.log('saved ' + url.value) console.log('saved ' + url.value)
try { try {
const response = await axios.post("feeds/add", { const response = await axios.post("/api/v1/article/add", {
url: url.value, url: url.value,
title: title.value, title: title.value,
user_id: parseInt(localStorage.getItem("user-id")) user_id: parseInt(localStorage.getItem("user-id"))
@@ -44,7 +44,7 @@ async function save() {
<div class="modal-header"> <div class="modal-header">
<slot name="header">Add RSS Feed</slot> <slot name="header">Add RSS Feed</slot>
</div> </div>
<form @submit.prevent="submitForm"> <form @submit.prevent="save">
<label for="name">URL:</label> <label for="name">URL:</label>
<input v-model="url" id="url" type="text" required /> <input v-model="url" id="url" type="text" required />
<label for="name">Title:</label> <label for="name">Title:</label>
@@ -56,7 +56,7 @@ async function save() {
<div class="modal-footer"> <div class="modal-footer">
<slot name="footer"> <slot name="footer">
<button type="submit" @click="save">Save</button> <button type="submit">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button> <button class="modal-default-button" @click="$emit('close')">Close</button>
</slot> </slot>
</div> </div>
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AddUrl from '../AddUrl.vue'
vi.mock('axios')
describe('AddUrl', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
it('posts the entered url and title and shows a success message', async () => {
axios.post.mockResolvedValueOnce({ status: 201 })
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/add',
{ url: 'https://example.test/feed.xml', title: 'Example feed', user_id: 7 },
expect.anything(),
)
expect(wrapper.text()).toContain('saved successfully')
})
it('surfaces the error message when the request fails', async () => {
axios.post.mockRejectedValueOnce({ message: 'Network Error' })
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Network Error')
})
})
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import axios from 'axios'
import { useFeeds } from '../useFeeds'
vi.mock('axios')
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => {
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
feeds.value = []
showMessage.value = false
message.value = ''
showModal.value = false
})
it('fetches and flattens articles for the current user', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>hello</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01 10:00:00',
},
],
},
],
},
})
await fetchData()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
expect(feeds.value).toHaveLength(1)
expect(feeds.value[0]).toMatchObject({ title: 'Article one', feedTitle: 'My Feed' })
})
it('sorts articles by timestamp across feeds, newest first', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'Old Feed',
items: [
{ id: 1, title: 'Older article', content: '', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
],
},
{
title: 'New Feed',
items: [
{ id: 2, title: 'Newer article', content: '', url: 'https://example.test/2', timestamp: '2026-02-01 10:00:00' },
],
},
],
},
})
await fetchData()
expect(feeds.value.map(f => f.title)).toEqual(['Newer article', 'Older article'])
})
it('syncs feeds for the current user and refetches', async () => {
axios.post.mockResolvedValueOnce({ status: 200 })
axios.get.mockResolvedValue({ data: { feeds: [] } })
await sync()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/sync',
{ user_id: 7 },
expect.anything(),
)
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
})
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<img data-format="MASTER_LANDSCAPE" data-id="76212061"
data-url="https://static.dw.com/image/76212061_\${formatId}.jpg"
data-aspect-ratio="16/9" alt="Merz and Trump"
src="https://static.dw.com/image/76212061_$%7BformatId%7D.jpg">
<p>some article text long enough for readability to keep the image and paragraph together in the parsed output, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes for the main article body.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_MASTER_LANDSCAPE.jpg"')
// The rendered `src` is what matters — `data-url` retaining the raw
// template is harmless since browsers don't load images from data-* attrs.
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
})
it('resolves templated images regardless of the placeholder name and scrubs other lazy-load attributes', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<img data-format="MASTER_LANDSCAPE" data-id="76212061"
data-url="https://static.dw.com/image/76212061_\${size}.jpg"
data-src="https://static.dw.com/image/76212061_\${size}.jpg"
alt="Merz and Trump" src="data:image/gif;base64,placeholder">
<p>some article text long enough for readability to keep the image and paragraph together in the parsed output, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes for the main article body.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
// `${size}` isn't the hardcoded `${formatId}` placeholder, but `data-format`
// is still the right substitution — and lazy-load attributes like `data-src`
// (which Readability may promote into `src`) must be cleaned too, not just
// `data-url`, so a stale template can't resurface in the rendered output.
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
})
it('drops unresolvable templated images instead of leaving a broken src', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://example.test/article-one',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<img data-url="https://example.test/img_\${size}.jpg" src="https://example.test/img_%7Bsize%7D.jpg">
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('%7Bsize%7D')
expect(feeds.value[0].content).not.toContain('<img')
})
})
+269
View File
@@ -0,0 +1,269 @@
import { ref, unref, nextTick } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
// Module-level state — declared outside useFeeds() so every caller shares the
// same singleton refs (a Pinia-free "store" for the feed list and its UI state).
const showMessage = ref(false)
const feeds = ref([]);
const message = ref('')
const showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
let observer; // Declare observer outside the setup function
let initialLoad = false
function authHeaders() {
return {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
}
}
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
// lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an
// unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D`
// form — that their own frontend fills in from the sibling `data-format`
// attribute before loading; verbatim they 404. Resolve every such attribute
// the same way (so Readability's own lazy-image handling can't resurrect a
// stale template into `src`), preferring `data-url` as the source of truth
// for `src`, and drop the <img> entirely if a template still remains.
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
function resolveTemplatedImage(img) {
const format = img.getAttribute('data-format')
const dataUrl = img.getAttribute('data-url')
if (format) {
if (dataUrl && TEMPLATE_PATTERN.test(dataUrl)) {
img.setAttribute('src', dataUrl.replace(TEMPLATE_PATTERN_GLOBAL, format))
}
for (const attr of [...img.attributes]) {
if (attr.name !== 'src' && TEMPLATE_PATTERN.test(attr.value)) {
img.setAttribute(attr.name, attr.value.replace(TEMPLATE_PATTERN_GLOBAL, format))
}
}
}
if (TEMPLATE_PATTERN.test(img.getAttribute('src') ?? '')) {
img.remove()
}
}
function showMessageForXSeconds(text, seconds) {
message.value = text;
showMessage.value = true;
// Set a timeout to hide the message after x seconds
setTimeout(() => {
showMessage.value = false;
message.value = '';
}, seconds * 1000); // Convert seconds to milliseconds
}
async function getReadable(feed, index) {
try {
const response = await axios.post("/api/v1/article/read", {
url: feed.url
}, authHeaders())
const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
// Scraped articles often contain image/link URLs that are relative to the
// source site. A <base> tag makes the browser (and Readability) resolve
// them against the article's original URL instead of our own origin.
const base = doc.createElement('base');
base.setAttribute('href', feed.url);
doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
doc.querySelectorAll('video, audio').forEach(el => el.remove());
const article = new Readability(doc).parse();
feeds.value[index].content = article.content;
feeds.value[index].readable = true;
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
}
async function markRead(id) {
try {
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
console.log(response.status)
} catch (error) {
console.log(error)
}
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try {
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
const items = [];
response.data.feeds.forEach(feed => {
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
});
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
// lexicographic comparison sorts them chronologically.
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
feeds.value = items;
await nextTick();
setupIntersectionObserver();
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
};
async function sync() {
try {
const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
}, authHeaders())
if (response.status == 200) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
} catch (error) {
console.error('Error sync', error)
showMessageForXSeconds(error, 5)
}
}
function setupIntersectionObserver() {
if (observer) {
observer.disconnect();
}
observer = new IntersectionObserver(handleIntersection, {
root: null, // Use the viewport as the root
rootMargin: '0px',
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
});
const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) {
observedDivs.forEach(observedDiv => {
observer.observe(observedDiv);
})
}
}
async function handleIntersection(entries) {
// The callback function for when the target element enters or exits the viewport
for (const entry of entries) {
// An article that has scrolled above the viewport (not intersecting,
// bounding box above the top edge) has been read — mark it and remove it.
if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) {
await markRead(feeds.value[entry.target.id].id)
removeFeed(entry.target.id)
document.getElementById(0)?.scrollIntoView()
}
}
}
function removeFeed(index) {
const array = unref(feeds);
array.splice(index, 1);
}
function setInitialLoad(value) {
initialLoad = value
}
async function markAllRead() {
if (feeds.value.length === 0) return
if (!window.confirm('Mark all articles as read?')) return
const ids = feeds.value.map(feed => feed.id)
feeds.value = []
currentIndex.value = 0
// markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id)))
showMessageForXSeconds('All articles marked as read.', 5)
}
function markCurrentArticleRead() {
const feed = feeds.value[currentIndex.value]
// Marking read here (rather than via removeFeed, as the scroll-based list
// view does) keeps the array stable so currentIndex stays valid while paging.
// The local `read` flag lets leaveArticleView() drop these once we're done.
if (feed) {
feed.read = true
markRead(feed.id)
}
}
async function leaveArticleView() {
// Articles paged past in article view were marked read but deliberately kept
// in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view.
feeds.value = feeds.value.filter(feed => !feed.read)
currentIndex.value = 0
viewMode.value = 'list'
// The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering.
await nextTick()
setupIntersectionObserver()
}
function toggleViewMode() {
if (viewMode.value === 'article') {
leaveArticleView()
} else {
viewMode.value = 'article'
currentIndex.value = 0
markCurrentArticleRead()
}
}
function toggleLayout() {
layout.value = layout.value === 'list' ? 'cards' : 'list'
localStorage.setItem('layout', layout.value)
}
function nextArticle() {
if (currentIndex.value < feeds.value.length - 1) {
currentIndex.value += 1
markCurrentArticleRead()
}
}
function prevArticle() {
if (currentIndex.value > 0) {
currentIndex.value -= 1
markCurrentArticleRead()
}
}
export function useFeeds() {
return {
feeds,
showMessage,
message,
showModal,
viewMode,
currentIndex,
toggleViewMode,
leaveArticleView,
layout,
toggleLayout,
nextArticle,
prevArticle,
fetchData,
sync,
getReadable,
markRead,
markAllRead,
showMessageForXSeconds,
setupIntersectionObserver,
removeFeed,
setInitialLoad,
}
}
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest'
import router from '../index'
describe('router auth guard', () => {
beforeEach(() => {
localStorage.clear()
})
it('redirects unauthenticated users away from protected routes', async () => {
await router.push('/feeds')
expect(router.currentRoute.value.name).toBe('login')
})
it('lets authenticated users reach protected routes', async () => {
localStorage.setItem('user-token', 'abc123')
await router.push('/feeds')
expect(router.currentRoute.value.name).toBe('feeds')
})
it('redirects the root path to the feeds route', async () => {
localStorage.setItem('user-token', 'abc123')
await router.push('/')
expect(router.currentRoute.value.name).toBe('feeds')
})
})
+3 -17
View File
@@ -1,32 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'home', redirect: '/feeds',
component: HomeView,
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
}, },
{ {
path: '/feeds', path: '/feeds',
name: 'feeds', name: 'feeds',
// route level code-splitting // route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route // this generates a separate chunk (Feed.[hash].js) for this route
// which is lazy-loaded when the route is visited. // which is lazy-loaded when the route is visited.
component: () => import('../views/FeedView.vue'), component: () => import('../views/FeedView.vue'),
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication meta: { requiresAuth: true },
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
}, },
{ {
path: '/login', path: '/login',
@@ -38,13 +26,11 @@ const router = createRouter({
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
// TODO Check if the user is authenticated (e.g., check for a valid token)
let isAuthenticated = false; let isAuthenticated = false;
if (localStorage.getItem("user-token") != null){ if (localStorage.getItem("user-token") != null){
isAuthenticated = true; isAuthenticated = true;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to the login page // Redirect to the login page
next('/login'); next('/login');
-15
View File
@@ -1,15 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<script setup>
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>
+5 -35
View File
@@ -2,14 +2,6 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import dotenv from 'dotenv';
console.log('process.env:', process.env);
console.log('TEst:', process.env.VITE_API_BASE_URL);
// Load environment variables based on the environment mode
dotenv.config({
path: `.env.${process.env.NODE_ENV || 'development'}`
});
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -24,39 +16,17 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/login/rss': { '/api': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/auth/login`, target: 'http://localhost:8001',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path.replace(/^\/login\/rss/, ''),
}, },
'/feeds/get': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/get`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/get/, ''),
},
'/feeds/sync': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/sync`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/sync/, ''),
},
'/feeds/read': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/read`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/read/, ''),
},
'/feeds/add': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/add`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/add/, ''),
}, },
}, },
cors: false test: {
environment: 'jsdom',
globals: true,
}, },
}) })