Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3af42c3c5 | |||
| 8b56174856 | |||
| 039e0b448c | |||
| b851e0257c | |||
| d826a8f3dc | |||
| 83fdb464af | |||
| 0ee9e22109 | |||
| a98a8ba9e6 | |||
| 3c42ebb972 | |||
| b4fc86302f | |||
| 39f08c7218 | |||
| b865fe982e | |||
| 6eeb3384fe | |||
| 675bf15828 | |||
| c5e5e463f9 | |||
| 997fc2989a | |||
| 09eae69041 | |||
| e9d7f63ff3 | |||
| 841e8419b0 | |||
| b4874ad318 |
+5
-6
@@ -1,6 +1,5 @@
|
||||
target/
|
||||
tests/
|
||||
Dockerfile
|
||||
scripts/
|
||||
migrations/
|
||||
|
||||
target
|
||||
vue/node_modules
|
||||
vue/dist
|
||||
.git
|
||||
.env
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
/target
|
||||
.env
|
||||
.claude
|
||||
|
||||
Generated
+1950
-1531
File diff suppressed because it is too large
Load Diff
Regular → Executable
+22
-36
@@ -1,51 +1,37 @@
|
||||
[package]
|
||||
name = "rss-reader"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
path = "src/main.rs"
|
||||
name = "rss-reader"
|
||||
edition = "2024"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["json", "blocking"] }
|
||||
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rss = { version = "2.0.1" }
|
||||
actix-web = "4.1.0"
|
||||
actix-rt = "2.7.0"
|
||||
futures = "0.3.24"
|
||||
serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
|
||||
serde_derive = "1.0.145"
|
||||
actix-service = "2.0.2"
|
||||
diesel = { version = "2.0.2", features = ["postgres", "chrono", "r2d2"] }
|
||||
rss = { version = "2.0.13" }
|
||||
actix-web = "4.13"
|
||||
actix-rt = "2.10"
|
||||
futures = "0.3.31"
|
||||
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
|
||||
serde_derive = "1.0.228"
|
||||
actix-service = "2.0.3"
|
||||
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
||||
diesel_migrations = "2.3"
|
||||
dotenv = "0.15.0"
|
||||
bcrypt = "0.13.0"
|
||||
uuid = {version = "1.2.1", features=["serde", "v4"]}
|
||||
bcrypt = "0.19"
|
||||
uuid = {version = "1.23", features=["serde", "v4"]}
|
||||
jwt = "0.16.0"
|
||||
hmac = "0.12.1"
|
||||
sha2 = "0.10.6"
|
||||
scraper = "0.14.0"
|
||||
actix-cors = "0.6.4"
|
||||
chrono = { version = "0.4.31", features = ["serde"] }
|
||||
dateparser = "0.2.0"
|
||||
tracing-appender = "0.2.3"
|
||||
once_cell = "1.19.0"
|
||||
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"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
log = "0.4.32"
|
||||
env_logger = "0.11"
|
||||
scraper = "0.27"
|
||||
actix-cors = "0.7"
|
||||
chrono = { version = "0.4.45", features = ["serde"] }
|
||||
dateparser = "0.3"
|
||||
|
||||
[dependencies.serde_json]
|
||||
version = "1.0.86"
|
||||
version = "1.0.150"
|
||||
default-features = false
|
||||
features = ["alloc"]
|
||||
|
||||
|
||||
+16
-35
@@ -1,41 +1,22 @@
|
||||
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
|
||||
WORKDIR /app
|
||||
# --- builder ---
|
||||
FROM rust:1-slim-bookworm AS builder
|
||||
|
||||
RUN apt update && apt install lld clang -y
|
||||
|
||||
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 \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy diesel_cli from builder to runtime
|
||||
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/cargo/bin/diesel
|
||||
WORKDIR /app
|
||||
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
|
||||
# COPY configuration configuration
|
||||
# ENV APP_ENVIRONMENT production
|
||||
# ENTRYPOINT ["./rss-reader"]
|
||||
ENTRYPOINT ["sh", "-c", "/app/rss-reader && diesel migration run"]
|
||||
CMD ["rss-reader"]
|
||||
|
||||
@@ -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
|
||||
curl -X POST -H "Content-Type: application/json" -d '{"name": "mace", "email": "safemind@posteo.de", "password": "secret"}' http://localhost:8001/api/v1/user/create
|
||||
`.env` is read by both the backend (via `dotenv`) and the `diesel` CLI, and is gitignored — never commit it.
|
||||
|
||||
| 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.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
application:
|
||||
port: 8001
|
||||
database:
|
||||
host: "localhost"
|
||||
port: 5432
|
||||
username: "admin"
|
||||
password: "secret+123"
|
||||
database_name: "rss"
|
||||
@@ -1,2 +0,0 @@
|
||||
application:
|
||||
host: 127.0.0.1
|
||||
@@ -1,2 +0,0 @@
|
||||
application:
|
||||
host: 0.0.0.0
|
||||
+26
-30
@@ -1,44 +1,40 @@
|
||||
version: "3.7"
|
||||
services:
|
||||
|
||||
# vue-app:
|
||||
# build:
|
||||
# context: ./vue/
|
||||
# dockerfile: Dockerfile
|
||||
# ports:
|
||||
# - "8080:8080" # Adjust the port as needed for your Rust application
|
||||
# networks:
|
||||
# - app-network
|
||||
|
||||
postgres:
|
||||
restart: always
|
||||
container_name: "rss-postgres"
|
||||
image: "postgres:15"
|
||||
image: "postgres:18"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- "POSTGRES_USER=admin"
|
||||
- "POSTGRES_DB=rss"
|
||||
- "POSTGRES_PASSWORD=secret+123"
|
||||
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- app-network
|
||||
- postgres_data:/var/lib/postgresql
|
||||
|
||||
# rust-app:
|
||||
# build:
|
||||
# context: . # Specify the path to your Rust application's Dockerfile
|
||||
# dockerfile: Dockerfile
|
||||
# ports:
|
||||
# - "8001:8001" # Adjust the port as needed for your Rust application
|
||||
# depends_on:
|
||||
# - postgres
|
||||
# networks:
|
||||
# - app-network
|
||||
backend:
|
||||
container_name: "rss-backend"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- "DATABASE_URL=postgres://admin:${POSTGRES_PASSWORD}@postgres/rss"
|
||||
- "JWT_SECRET=${JWT_SECRET}"
|
||||
- "FRONTEND_ORIGIN=${FRONTEND_ORIGIN}"
|
||||
- "RUST_LOG=${RUST_LOG}"
|
||||
ports:
|
||||
- "0.0.0.0:8001:8001"
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
frontend:
|
||||
container_name: "rss-frontend"
|
||||
build:
|
||||
context: ./vue
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "0.0.0.0:8080:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -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";
|
||||
|
||||
})
|
||||
@@ -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
@@ -3,29 +3,36 @@ extern crate jwt;
|
||||
extern crate sha2;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use dotenv::dotenv;
|
||||
use hmac::{Hmac, Mac};
|
||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||
use sha2::Sha256;
|
||||
|
||||
pub struct JwtToken {
|
||||
pub user_id: i32,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
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 {
|
||||
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();
|
||||
claims.insert("user_id", user_id);
|
||||
claims.sign_with_key(&key).unwrap()
|
||||
}
|
||||
|
||||
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: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
||||
VerifyWithKey::verify_with_key(token_str, &key);
|
||||
@@ -36,7 +43,6 @@ impl JwtToken {
|
||||
let claims = token.claims();
|
||||
Ok(JwtToken {
|
||||
user_id: claims["user_id"],
|
||||
body: encoded_token,
|
||||
})
|
||||
}
|
||||
Err(_err) => Err("could not decode token"),
|
||||
|
||||
+2
-3
@@ -4,8 +4,7 @@ pub mod processes;
|
||||
use crate::auth::processes::check_password;
|
||||
use crate::auth::processes::extract_header_token;
|
||||
|
||||
#[tracing::instrument(name = "Process token")]
|
||||
pub fn process_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
||||
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
|
||||
match extract_header_token(request) {
|
||||
Ok(token) => check_password(token),
|
||||
Err(message) => Err(message),
|
||||
@@ -27,7 +26,7 @@ mod mod_test {
|
||||
.to_srv_request();
|
||||
|
||||
match process_token(&request) {
|
||||
Ok(message) => assert_eq!("passed", message),
|
||||
Ok(user_id) => assert_eq!(32, user_id),
|
||||
Err(_) => panic!("process token failed"),
|
||||
}
|
||||
}
|
||||
|
||||
+13
-12
@@ -1,19 +1,21 @@
|
||||
use super::jwt;
|
||||
use actix_web::dev::ServiceRequest;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
|
||||
pub fn check_password(password: Secret<String>) -> Result<String, &'static str> {
|
||||
match jwt::JwtToken::decode(password.expose_secret().to_string()) {
|
||||
Ok(_token) => Ok(String::from("passed")),
|
||||
pub fn check_password(password: String) -> Result<i32, &'static str> {
|
||||
match jwt::JwtToken::decode(password) {
|
||||
Ok(token) => Ok(token.user_id),
|
||||
Err(message) => Err(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Extract Header Token")]
|
||||
pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>, &'static str> {
|
||||
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
||||
log::info!("Request: {:?}", request);
|
||||
match request.headers().get("user-token") {
|
||||
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"),
|
||||
},
|
||||
None => Err("there is no token"),
|
||||
@@ -23,7 +25,6 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>,
|
||||
#[cfg(test)]
|
||||
mod processes_test {
|
||||
use actix_web::test::TestRequest;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
|
||||
use crate::auth::jwt::JwtToken;
|
||||
|
||||
@@ -31,19 +32,19 @@ mod processes_test {
|
||||
|
||||
#[test]
|
||||
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);
|
||||
|
||||
match result {
|
||||
Ok(check) => assert_eq!("passed", check),
|
||||
Ok(user_id) => assert_eq!(32, user_id),
|
||||
_ => panic!("Check correct password failed."),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incorrect_check_password() {
|
||||
let password: Secret<String> = Secret::new(String::from("test"));
|
||||
let password: String = String::from("test");
|
||||
|
||||
match check_password(password) {
|
||||
Err(message) => assert_eq!("could not decode token", message),
|
||||
@@ -58,7 +59,7 @@ mod processes_test {
|
||||
.to_srv_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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,12 +1,21 @@
|
||||
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>> {
|
||||
let manager = ConnectionManager::<PgConnection>::new(url);
|
||||
// Refer to the `r2d2` documentation for more methods to use
|
||||
// when building a connection pool
|
||||
Pool::builder()
|
||||
.test_on_check_out(true)
|
||||
.build(manager)
|
||||
.expect("Could not build connection pool")
|
||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||
|
||||
pub fn establish_connection() -> PgConnection {
|
||||
dotenv().ok();
|
||||
|
||||
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
PgConnection::establish(&database_url)
|
||||
.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,5 +1,5 @@
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::reader::structs::feed::FeedAggregate;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pub mod articles;
|
||||
pub mod feed_info;
|
||||
pub mod login;
|
||||
pub mod new_feed;
|
||||
pub mod new_feed_item;
|
||||
pub mod new_user;
|
||||
pub mod read_feed_item;
|
||||
pub mod readable;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewFeedSchema {
|
||||
pub title: String,
|
||||
pub url: String,
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewUserSchema {
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReadItem {
|
||||
pub id: i32,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{HttpResponse, Responder};
|
||||
use reqwest::StatusCode;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct UrlJson {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_derive::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct JsonUser {
|
||||
pub user_id: i32,
|
||||
}
|
||||
|
||||
-13
@@ -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
@@ -1,32 +1,81 @@
|
||||
use std::net::TcpListener;
|
||||
extern crate diesel;
|
||||
extern crate dotenv;
|
||||
|
||||
use diesel::{
|
||||
r2d2::{ConnectionManager, Pool},
|
||||
PgConnection,
|
||||
};
|
||||
use rss_reader::{
|
||||
configuration::get_configuration,
|
||||
database::get_connection_pool,
|
||||
startup::run,
|
||||
telemetry::{get_subscriber, init_subscriber},
|
||||
};
|
||||
use secrecy::ExposeSecret;
|
||||
use actix_cors::Cors;
|
||||
use actix_service::Service;
|
||||
use actix_web::{App, HttpResponse, HttpServer};
|
||||
use dotenv::dotenv;
|
||||
use futures::future::{ok, Either};
|
||||
use std::env;
|
||||
mod auth;
|
||||
mod database;
|
||||
mod json_serialization;
|
||||
mod models;
|
||||
mod reader;
|
||||
mod schema;
|
||||
#[cfg(test)]
|
||||
mod test_helpers;
|
||||
mod views;
|
||||
|
||||
#[actix_rt::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
|
||||
init_subscriber(subscriber);
|
||||
dotenv().ok();
|
||||
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>> =
|
||||
get_connection_pool(configuration.database.connection_string().expose_secret());
|
||||
let frontend_origin =
|
||||
env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| String::from("http://localhost:5173"));
|
||||
|
||||
let address = format!(
|
||||
"{}:{}",
|
||||
configuration.application.host, configuration.application.port
|
||||
);
|
||||
HttpServer::new(move || {
|
||||
let cors = Cors::default()
|
||||
.allowed_origin(&frontend_origin)
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.supports_credentials();
|
||||
|
||||
let listener = TcpListener::bind(address)?;
|
||||
run(listener, connection_pool)?.await
|
||||
App::new()
|
||||
.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,7 +2,6 @@ extern crate bcrypt;
|
||||
|
||||
use bcrypt::{hash, DEFAULT_COST};
|
||||
use diesel::Insertable;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::schema::users;
|
||||
@@ -17,9 +16,8 @@ pub struct NewUser {
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
pub fn new(username: String, email: String, password: Secret<String>) -> NewUser {
|
||||
let hashed_password: String =
|
||||
hash(password.expose_secret().as_str(), DEFAULT_COST).unwrap();
|
||||
pub fn new(username: String, email: String, password: String) -> NewUser {
|
||||
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
|
||||
let uuid = Uuid::new_v4();
|
||||
NewUser {
|
||||
username,
|
||||
|
||||
@@ -18,6 +18,6 @@ pub struct User {
|
||||
|
||||
impl User {
|
||||
pub fn verify(self, password: String) -> bool {
|
||||
return verify(password.as_str(), &self.password).unwrap();
|
||||
verify(password.as_str(), &self.password).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
+37
-15
@@ -1,23 +1,15 @@
|
||||
use actix_web::{web, HttpResponse};
|
||||
use diesel::{
|
||||
r2d2::{ConnectionManager, Pool},
|
||||
PgConnection, RunQueryDsl,
|
||||
};
|
||||
use diesel::RunQueryDsl;
|
||||
|
||||
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;
|
||||
|
||||
#[tracing::instrument(name = "Add new feed", skip(pool))]
|
||||
pub async fn add(
|
||||
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");
|
||||
|
||||
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
||||
let mut connection = establish_connection();
|
||||
let title: String = new_feed.title.clone();
|
||||
let url: String = new_feed.url.clone();
|
||||
let user_id: i32 = new_feed.user_id;
|
||||
@@ -25,11 +17,13 @@ pub async fn add(
|
||||
let result = feeds::get_feed(&url).await;
|
||||
match result {
|
||||
Ok(channel) => {
|
||||
log::info!("valid channel");
|
||||
if channel.items.is_empty() {
|
||||
return HttpResponse::ServiceUnavailable().await.unwrap();
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
Err(e) => {
|
||||
log::error!("{:?}", e);
|
||||
return HttpResponse::NotFound().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -42,6 +36,34 @@ pub async fn add(
|
||||
|
||||
match insert_result {
|
||||
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
@@ -2,9 +2,9 @@ use std::error::Error;
|
||||
|
||||
use rss::Channel;
|
||||
|
||||
#[tracing::instrument(name = "Get Channel Feed")]
|
||||
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
|
||||
let content = reqwest::get(feed).await?.bytes().await?;
|
||||
let channel = Channel::read_from(&content[..])?;
|
||||
log::debug!("{:?}", channel);
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
+63
-29
@@ -4,30 +4,23 @@ use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||
use crate::reader::structs::feed::FeedAggregate;
|
||||
use crate::schema::feed_item::{feed_id, id, read};
|
||||
use crate::{
|
||||
database::establish_connection,
|
||||
json_serialization::articles::Articles,
|
||||
schema::feed::{self, user_id},
|
||||
schema::feed_item,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, Responder};
|
||||
use chrono::Local;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use diesel::{prelude::*, r2d2};
|
||||
use diesel::prelude::*;
|
||||
|
||||
use super::structs::article::Article;
|
||||
|
||||
#[tracing::instrument(name = "Get feeds", skip(pool))]
|
||||
pub async fn get(
|
||||
path: web::Path<JsonUser>,
|
||||
req: HttpRequest,
|
||||
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
|
||||
) -> impl Responder {
|
||||
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
|
||||
let request = req.clone();
|
||||
let req_user_id = path.user_id;
|
||||
// Clone the Arc containing the connection pool
|
||||
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");
|
||||
log::info!("Received user_id: {}", req_user_id);
|
||||
|
||||
let mut connection: diesel::PgConnection = establish_connection();
|
||||
let feeds: Vec<Feed> = feed::table
|
||||
.filter(user_id.eq(req_user_id))
|
||||
.load::<Feed>(&mut connection)
|
||||
@@ -35,28 +28,19 @@ pub async fn get(
|
||||
|
||||
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
|
||||
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
|
||||
.filter(feed_id.eq(feed.id))
|
||||
.filter(read.eq(false))
|
||||
.order(id.asc())
|
||||
.load(connection)
|
||||
.load(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
log::info!(
|
||||
"Load {} feed items for feed: {}",
|
||||
existing_item.len(),
|
||||
feed.url
|
||||
);
|
||||
|
||||
let article_list: Vec<Article> = existing_item
|
||||
.into_iter()
|
||||
.map(|feed_item: FeedItem| {
|
||||
@@ -74,8 +58,58 @@ pub fn get_feed_aggregate(
|
||||
})
|
||||
.collect();
|
||||
|
||||
FeedAggregate {
|
||||
log::info!("article list with {} items generated.", article_list.len());
|
||||
|
||||
feed_aggregates.push(FeedAggregate {
|
||||
title: feed.title,
|
||||
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
@@ -1,36 +1,83 @@
|
||||
use crate::schema::feed_item::{id, read};
|
||||
use crate::{
|
||||
json_serialization::read_feed_item::ReadItem, models::feed_item::rss_feed_item::FeedItem,
|
||||
schema::feed_item,
|
||||
database::establish_connection, json_serialization::read_feed_item::ReadItem,
|
||||
models::feed_item::rss_feed_item::FeedItem, schema::feed_item,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||
use diesel::RunQueryDsl;
|
||||
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
|
||||
.filter(id.eq(path.id))
|
||||
.load::<FeedItem>(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
if feed_items.len() != 1 {
|
||||
return HttpResponse::NotFound().await.unwrap();
|
||||
return HttpResponse::NotFound();
|
||||
}
|
||||
|
||||
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))
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ use actix_web::web;
|
||||
|
||||
use crate::views::path::Path;
|
||||
mod add;
|
||||
mod delete_feed;
|
||||
pub mod feeds;
|
||||
mod get;
|
||||
mod list_feeds;
|
||||
mod mark_read;
|
||||
mod read;
|
||||
mod scraper;
|
||||
@@ -19,6 +21,14 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
|
||||
&base_path.define(String::from("/get/{user_id}")),
|
||||
web::get().to(get::get),
|
||||
);
|
||||
app.route(
|
||||
&base_path.define(String::from("/feeds/{user_id}")),
|
||||
web::get().to(list_feeds::list_feeds),
|
||||
);
|
||||
app.route(
|
||||
&base_path.define(String::from("/feed/{feed_id}")),
|
||||
web::delete().to(delete_feed::delete_feed),
|
||||
);
|
||||
app.route(
|
||||
&base_path.define(String::from("/add")),
|
||||
web::post().to(add::add),
|
||||
|
||||
+4
-2
@@ -4,13 +4,15 @@ use crate::json_serialization::{readable::Readable, url::UrlJson};
|
||||
|
||||
use super::scraper::content::do_throttled_request;
|
||||
|
||||
#[tracing::instrument(name = "Read Feed")]
|
||||
pub async fn read(_req: HttpRequest, data: web::Json<UrlJson>) -> impl Responder {
|
||||
let result = do_throttled_request(&data.url);
|
||||
|
||||
let content = match result.await {
|
||||
Ok(cont) => cont,
|
||||
Err(e) => e.to_string(),
|
||||
Err(e) => {
|
||||
log::error!("Could not scrap url {}", data.url);
|
||||
e.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
Readable { content }
|
||||
|
||||
+386
-54
@@ -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::rss_feed_item::FeedItem;
|
||||
use crate::schema::feed_item::{feed_id, title};
|
||||
use crate::schema::{
|
||||
use crate::{
|
||||
database::establish_connection,
|
||||
schema::{
|
||||
feed::{self, user_id},
|
||||
feed_item,
|
||||
},
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use chrono::{DateTime, Local, NaiveDateTime};
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDateTime};
|
||||
use dateparser::parse;
|
||||
use diesel::prelude::*;
|
||||
use diesel::r2d2::{ConnectionManager, Pool};
|
||||
use rss::Item;
|
||||
use scraper::{Html, Selector};
|
||||
|
||||
#[tracing::instrument(name = "Get Date")]
|
||||
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
||||
// let format_string = "%a, %d %b %Y %H:%M:%S %z";
|
||||
let format_string = "%Y-%m-%dT%H:%M:%S%Z";
|
||||
|
||||
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),
|
||||
},
|
||||
if let Ok(result) = parse(date_str) {
|
||||
log::info!("Date: {:?}", result);
|
||||
return Ok(result.with_timezone(&Local).naive_local());
|
||||
}
|
||||
|
||||
DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local())
|
||||
}
|
||||
|
||||
// Some feeds (e.g. Deutsche Welle) embed responsive-image templates such as
|
||||
// `src="https://example.com/img_${formatId}.jpg"` that their own frontend
|
||||
// JavaScript fills in before loading — verbatim, they 404. Skip those and
|
||||
// pick the first <img> with a real, directly loadable URL instead.
|
||||
fn image_src_is_resolvable(element: &scraper::ElementRef) -> bool {
|
||||
match element.value().attr("src") {
|
||||
Some(src) => !src.contains('{') && !src.to_lowercase().contains("%7b"),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "Create Feed Item", skip(connection))]
|
||||
fn escape_html_attr(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
let item_title = item.title.clone().unwrap();
|
||||
log::info!("Create feed item: {}", item_title);
|
||||
|
||||
let base_content: &str = match item.content() {
|
||||
Some(c) => c,
|
||||
None => match item.description() {
|
||||
Some(c) => c,
|
||||
None => "",
|
||||
},
|
||||
// Resolve the publication date before any HTML parsing or DB work so we can
|
||||
// bail out early for old articles. Items without a pub_date are treated as
|
||||
// current (inserted unconditionally) — feeds that don't publish dates are
|
||||
// typically small/curated enough that this is fine.
|
||||
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 mut content = "".to_string();
|
||||
let frag_clone = frag.clone();
|
||||
frag.tree.into_iter().for_each(|node| {
|
||||
let selector_img = Selector::parse("img").unwrap();
|
||||
|
||||
for element in frag_clone.select(&selector_img) {
|
||||
if !content.starts_with("<img") {
|
||||
content.push_str(&element.html());
|
||||
content.push_str("<br>")
|
||||
let selector_img = Selector::parse("img").unwrap();
|
||||
match frag.select(&selector_img).find(image_src_is_resolvable) {
|
||||
Some(image) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let existing_item: Vec<FeedItem> = feed_item::table
|
||||
.filter(feed_id.eq(feed.id))
|
||||
@@ -74,13 +117,6 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
||||
.unwrap();
|
||||
|
||||
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(
|
||||
feed.id,
|
||||
content.clone(),
|
||||
@@ -88,20 +124,31 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
||||
item.link.unwrap(),
|
||||
Some(time),
|
||||
);
|
||||
let _insert_result = diesel::insert_into(feed_item::table)
|
||||
let insert_result = diesel::insert_into(feed_item::table)
|
||||
.values(&new_feed_item)
|
||||
.execute(connection);
|
||||
|
||||
log::info!("Insert Result: {:?}", insert_result);
|
||||
} else {
|
||||
log::info!("Item {} already exists.", item_title);
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(name = "sync", skip(pool))]
|
||||
pub async fn sync(
|
||||
_req: HttpRequest,
|
||||
data: web::Json<JsonUser>,
|
||||
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");
|
||||
// Items without a `created_ts` (e.g. ones inserted before this column existed,
|
||||
// or whose feed didn't provide a publish date) are left alone — `lt` never
|
||||
// matches NULL, so there's nothing to special-case here.
|
||||
fn delete_old_feed_items(connection: &mut PgConnection) {
|
||||
let cutoff = Local::now().naive_local() - Duration::days(14);
|
||||
let result = diesel::delete(feed_item::table.filter(feed_item::created_ts.lt(cutoff)))
|
||||
.execute(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;
|
||||
|
||||
@@ -110,18 +157,303 @@ pub async fn sync(
|
||||
.load::<Feed>(&mut connection)
|
||||
.unwrap();
|
||||
|
||||
log::info!("Found {} feeds to sync.", feeds.len());
|
||||
|
||||
for feed in feeds {
|
||||
log::info!("Try to get url: {}", feed.url);
|
||||
let result = feeds::get_feed(&feed.url).await;
|
||||
|
||||
match result {
|
||||
Ok(channel) => {
|
||||
for item in channel.into_items() {
|
||||
log::info!("{:?}", item);
|
||||
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&b="x"">"#.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
use crate::database::establish_connection;
|
||||
use crate::diesel;
|
||||
use crate::json_serialization::login::Login;
|
||||
use crate::models::user::rss_user::User;
|
||||
@@ -5,18 +6,13 @@ use crate::schema::users;
|
||||
use crate::{auth::jwt::JwtToken, schema::users::username};
|
||||
use actix_web::{web, HttpResponse};
|
||||
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 password: String = credentials.password.clone();
|
||||
|
||||
let mut connection = establish_connection();
|
||||
|
||||
let users: Vec<User> = users::table
|
||||
.filter(username.eq(username_cred.as_str()))
|
||||
.load::<User>(&mut connection)
|
||||
@@ -25,6 +21,10 @@ pub async fn login(
|
||||
if users.is_empty() {
|
||||
return HttpResponse::NotFound().await.unwrap();
|
||||
} else if users.len() > 1 {
|
||||
log::error!(
|
||||
"multiple user have the usernam: {}",
|
||||
credentials.username.clone()
|
||||
);
|
||||
return HttpResponse::Conflict().await.unwrap();
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub async fn login(
|
||||
|
||||
match user.clone().verify(password) {
|
||||
true => {
|
||||
log::info!("verified password successfully for user {}", user.id);
|
||||
let token: String = JwtToken::encode(user.clone().id);
|
||||
HttpResponse::Ok()
|
||||
.insert_header(("token", token))
|
||||
@@ -42,3 +43,69 @@ pub async fn login(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
pub async fn logout() -> String {
|
||||
"logout view".to_string()
|
||||
use actix_web::HttpResponse;
|
||||
|
||||
// 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
@@ -1,25 +1,16 @@
|
||||
use crate::database::establish_connection;
|
||||
use crate::diesel;
|
||||
use crate::json_serialization::new_user::NewUserSchema;
|
||||
use crate::models::user::new_user::NewUser;
|
||||
use crate::schema::users;
|
||||
use actix_web::{web, HttpResponse};
|
||||
use diesel::{
|
||||
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");
|
||||
use diesel::prelude::*;
|
||||
|
||||
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
|
||||
let mut connection = establish_connection();
|
||||
let name: String = new_user.name.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);
|
||||
|
||||
@@ -32,3 +23,61 @@ pub async fn create(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<div class="header">
|
||||
<p>complete tasks: </p><p id="completeNum"></p>
|
||||
<p>pending tasks: </p><p id="pendingNum"></p>
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_API_BASE_URL=http://localhost:8001
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
VITE_API_BASE_URL=http://rust-app:8001
|
||||
|
||||
+11
-17
@@ -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
|
||||
|
||||
# copy both 'package.json' and 'package-lock.json' (if available)
|
||||
COPY package*.json ./
|
||||
|
||||
# install project dependencies
|
||||
RUN npm install
|
||||
|
||||
# copy project files and folders to the current working directory (i.e. 'app' folder)
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
|
||||
# build app for production with minification
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 8080
|
||||
CMD [ "http-server", "dist" ]
|
||||
# --- runtime ---
|
||||
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
@@ -3,7 +3,8 @@
|
||||
|
||||
<head>
|
||||
<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">
|
||||
<title>RSS-Reader</title>
|
||||
</head>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+2872
-540
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -6,24 +6,26 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mozilla/readability": "^0.4.4",
|
||||
"axios": "^1.5.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"vue-sessionstorage": "^1.0.0"
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"axios": "^1.17.0",
|
||||
"vue": "^3.5.35",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
"@vitejs/plugin-vue": "^4.3.1",
|
||||
"@rushstack/eslint-patch": "^1.16.1",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-plugin-vue": "^9.16.1",
|
||||
"prettier": "^3.0.0",
|
||||
"vite": "^4.4.9"
|
||||
"jsdom": "^29.1.1",
|
||||
"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 |
@@ -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
@@ -1,78 +1,11 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- <header> -->
|
||||
<!-- <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> -->
|
||||
|
||||
<AppNav v-if="route.meta.requiresAuth" />
|
||||
<RouterView />
|
||||
</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
@@ -33,6 +33,13 @@
|
||||
--color-heading: 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;
|
||||
}
|
||||
|
||||
@@ -47,6 +54,9 @@
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--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;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
font-size: 15px;
|
||||
font-size: clamp(14px, 2.5vw, 16px);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
+67
-64
@@ -3,7 +3,7 @@
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
@@ -11,102 +11,105 @@
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
color: var(--color-accent);
|
||||
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 {
|
||||
background-color: #3498db;
|
||||
color: white;
|
||||
background-color: var(--color-info);
|
||||
color: var(--color-info-text);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(90vw, 28rem);
|
||||
overflow-wrap: break-word;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@media (hover: 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 {
|
||||
cursor: pointer;
|
||||
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;
|
||||
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 {
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 20px;
|
||||
padding: 1em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: left;
|
||||
text-align: left;
|
||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
||||
padding: 0 1em 1em;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.feed-content p {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.feed-content h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
padding: 1em;
|
||||
font-size: 21px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
||||
.feed-content img {
|
||||
max-width: 100%;
|
||||
margin-bottom: 10px;
|
||||
/* Adjust spacing between image and text */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 14px;
|
||||
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||
}
|
||||
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
@media (min-width: 768px) {
|
||||
#app {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
|
||||
input {
|
||||
margin: 15px;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin: 0.5rem 0 1rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
@@ -16,10 +21,13 @@ input {
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 300px;
|
||||
width: clamp(280px, 90vw, 420px);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
margin: auto;
|
||||
padding: 20px 30px;
|
||||
background-color: #fff;
|
||||
background-color: var(--color-background-soft);
|
||||
color: var(--color-text);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
|
||||
transition: all 0.3s ease;
|
||||
@@ -27,15 +35,26 @@ input {
|
||||
|
||||
.modal-header h3 {
|
||||
margin-top: 0;
|
||||
color: #42b983;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.modal-default-button {
|
||||
float: right;
|
||||
.modal-footer {
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<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>
|
||||
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
||||
<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>
|
||||
@@ -1,44 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve 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>
|
||||
@@ -2,57 +2,40 @@
|
||||
import axios from 'axios'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const router = useRouter()
|
||||
|
||||
async function login() {
|
||||
error.value = ''
|
||||
|
||||
const loginData = {
|
||||
"username": username.value,
|
||||
"password": password.value,
|
||||
}
|
||||
const jsonData = JSON.stringify(loginData)
|
||||
console.log('test')
|
||||
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: {
|
||||
'Content-Type': 'application/json', // Set the content type to JSON
|
||||
'crossDomain': true,
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Credentials': true,
|
||||
'strict-origin-when-cross-origin': false
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 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) {
|
||||
let token = response.headers.token
|
||||
let 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)
|
||||
localStorage.setItem("user-token", response.headers.token)
|
||||
localStorage.setItem("user-id", response.headers.user_id)
|
||||
router.push({ name: 'feeds' })
|
||||
}
|
||||
// Handle success
|
||||
} catch (error) {
|
||||
// Handle any errors here
|
||||
console.error('Error:', error);
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err)
|
||||
error.value = 'Login failed. Please check your username and password.'
|
||||
}
|
||||
// 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>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Login Page</h1>
|
||||
<div class="login-page">
|
||||
<h1>Login</h1>
|
||||
<form @submit.prevent="login">
|
||||
<div class="form-group">
|
||||
<label for="username">Username/Email:</label>
|
||||
@@ -62,7 +45,39 @@ async function login() {
|
||||
<label for="password">Password:</label>
|
||||
<input v-model="password" type="password" id="password" name="password" required />
|
||||
</div>
|
||||
<p v-if="error" class="login-error">{{ error }}</p>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</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
@@ -1,173 +1,48 @@
|
||||
<script setup>
|
||||
import { ref, unref, onMounted, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import { onMounted } from 'vue';
|
||||
import Modal from './modal/AddUrl.vue';
|
||||
import { useFeeds } from '@/composables/useFeeds';
|
||||
|
||||
const showMessage = ref(false)
|
||||
const feeds = ref([]);
|
||||
const message = ref('')
|
||||
const showModal = ref(false)
|
||||
const {
|
||||
feeds,
|
||||
showMessage,
|
||||
message,
|
||||
showModal,
|
||||
viewMode,
|
||||
currentIndex,
|
||||
leaveArticleView,
|
||||
layout,
|
||||
nextArticle,
|
||||
prevArticle,
|
||||
fetchData,
|
||||
getReadable,
|
||||
setInitialLoad,
|
||||
showMessageForXSeconds,
|
||||
} = useFeeds()
|
||||
|
||||
async function getReadable(feed, index) {
|
||||
try {
|
||||
const response = await axios.post("feeds/read", {
|
||||
url: feed.url
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'user-token': localStorage.getItem("user-token")
|
||||
}
|
||||
})
|
||||
const shareLabel = navigator.share ? 'Share' : 'Copy link'
|
||||
|
||||
const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
|
||||
const article = new Readability(doc).parse();
|
||||
feeds.value[index].content = article.content;
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error)
|
||||
showMessageForXSeconds(error, 5)
|
||||
async function shareUrl(url) {
|
||||
if (navigator.share) {
|
||||
await navigator.share({ url })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(url)
|
||||
showMessageForXSeconds('Link copied.', 2)
|
||||
}
|
||||
}
|
||||
|
||||
async function markRead(id) {
|
||||
try {
|
||||
const response = await axios.put("feeds/read/" + id,
|
||||
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
|
||||
onMounted(async () => {
|
||||
setInitialLoad(false)
|
||||
await fetchData()
|
||||
setTimeout(function () {
|
||||
initialLoad = true
|
||||
setInitialLoad(true)
|
||||
console.log('set to true')
|
||||
}, 2000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<!-- use the modal component, pass in the prop -->
|
||||
<modal :show="showModal" @close="showModal = false">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
@@ -175,17 +50,220 @@ onMounted(() => {
|
||||
</modal>
|
||||
</Teleport>
|
||||
<div>
|
||||
<h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
|
||||
<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 ">
|
||||
<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>
|
||||
<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 ↗</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>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else class="article-single">
|
||||
<button type="button" class="article-single__back" @click="leaveArticleView">← 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 ↗</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"
|
||||
>↑</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-nav__btn"
|
||||
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
|
||||
aria-label="Next article"
|
||||
@click="nextArticle"
|
||||
>↓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vue’s
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -15,7 +15,7 @@ async function save() {
|
||||
submitted.value = true;
|
||||
console.log('saved ' + url.value)
|
||||
try {
|
||||
const response = await axios.post("feeds/add", {
|
||||
const response = await axios.post("/api/v1/article/add", {
|
||||
url: url.value,
|
||||
title: title.value,
|
||||
user_id: parseInt(localStorage.getItem("user-id"))
|
||||
@@ -44,7 +44,7 @@ async function save() {
|
||||
<div class="modal-header">
|
||||
<slot name="header">Add RSS Feed</slot>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm">
|
||||
<form @submit.prevent="save">
|
||||
<label for="name">URL:</label>
|
||||
<input v-model="url" id="url" type="text" required />
|
||||
<label for="name">Title:</label>
|
||||
@@ -56,7 +56,7 @@ async function save() {
|
||||
|
||||
<div class="modal-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>
|
||||
</slot>
|
||||
</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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
+7
-15
@@ -1,32 +1,26 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
||||
redirect: '/feeds',
|
||||
},
|
||||
{
|
||||
path: '/feeds',
|
||||
name: 'feeds',
|
||||
// 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.
|
||||
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: '/admin',
|
||||
name: 'admin',
|
||||
component: () => import('../views/AdminView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -38,13 +32,11 @@ const router = createRouter({
|
||||
})
|
||||
router.beforeEach((to, from, next) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
// TODO Check if the user is authenticated (e.g., check for a valid token)
|
||||
let isAuthenticated = false;
|
||||
if (localStorage.getItem("user-token") != null){
|
||||
isAuthenticated = true;
|
||||
}
|
||||
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to the login page
|
||||
next('/login');
|
||||
|
||||
@@ -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>
|
||||
@@ -1,9 +0,0 @@
|
||||
<script setup>
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
</template>
|
||||
+5
-35
@@ -2,14 +2,6 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
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/
|
||||
export default defineConfig({
|
||||
@@ -24,39 +16,17 @@ export default defineConfig({
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
'/login/rss': {
|
||||
target: `${process.env.VITE_API_BASE_URL}/api/v1/auth/login`,
|
||||
'/api': {
|
||||
target: 'http://localhost:8001',
|
||||
changeOrigin: true,
|
||||
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,
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user