Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43269bf1d7 | ||
|
|
5ed780baa3 | ||
|
|
8f7ddb77c4 | ||
|
|
e51bce4244 | ||
|
|
f31fccdd16 | ||
|
|
a7cfd267c6 | ||
|
|
c584ac4f60 | ||
|
|
9c042411fb | ||
|
|
4d9e899519 | ||
|
|
c290513ed1 | ||
|
|
41a17eba6d | ||
|
|
169c294e62 | ||
|
|
e2df60d112 | ||
|
|
5f816bb0f5 | ||
|
|
67b970df9b | ||
|
|
6c8b4e9e32 | ||
|
|
e2fb32f898 | ||
|
|
a2c947f870 | ||
|
|
abb6002078 | ||
|
|
f447a9a163 | ||
|
|
a26f31a150 | ||
|
|
c911258578 | ||
|
|
d52fa90b6b | ||
|
|
bfb926cec6 | ||
|
|
77438d0532 | ||
|
|
49ceec5125 | ||
|
|
15ac8c287b | ||
|
|
13d9c2c908 | ||
|
|
01f8a30505 | ||
|
|
a898e7ac33 | ||
|
|
671ed86b48 | ||
|
|
af5e2b8137 | ||
|
|
2b1d1c57ff | ||
|
|
31991ea1f8 | ||
|
|
0e3142bac9 | ||
|
|
a73a1b57de | ||
|
|
3642635b20 | ||
|
|
b3cf5e4787 | ||
|
|
fe0adcf68e | ||
|
|
3f9de099aa | ||
|
|
b9c0951f2b | ||
|
|
7a24980101 | ||
|
|
dfc2e29e36 | ||
|
|
a90d10368e | ||
|
|
5417176dd4 | ||
|
|
4d3f5d3285 | ||
|
|
967803c326 | ||
|
|
e9c865a254 | ||
|
|
570db2d948 | ||
|
|
a37d845875 | ||
|
|
8e57e2f02a | ||
|
|
3671b90b81 | ||
|
|
a399ede401 | ||
|
|
82ec6ea902 | ||
|
|
fbf3597984 | ||
|
|
e9580037ef | ||
|
|
b457b8abaa | ||
|
|
0820ce6ef7 | ||
|
|
ed1241490d | ||
|
|
177d975b4d | ||
|
|
52ea84747a | ||
|
|
0420cf0dd5 | ||
|
|
972e967432 | ||
|
|
400648c3d1 | ||
|
|
6ae6490dec | ||
|
|
f3af42c3c5 | ||
|
|
8b56174856 | ||
|
|
039e0b448c | ||
|
|
b851e0257c | ||
|
|
d826a8f3dc | ||
|
|
83fdb464af | ||
|
|
0ee9e22109 | ||
|
|
a98a8ba9e6 | ||
|
|
3c42ebb972 | ||
|
|
b4fc86302f | ||
|
|
39f08c7218 | ||
|
|
b865fe982e | ||
|
|
6eeb3384fe | ||
|
|
675bf15828 | ||
|
|
c5e5e463f9 | ||
|
|
997fc2989a | ||
|
|
09eae69041 | ||
|
|
e9d7f63ff3 | ||
|
|
841e8419b0 | ||
|
|
b4874ad318 |
@@ -0,0 +1,5 @@
|
|||||||
|
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,7 @@
|
|||||||
/target
|
/target
|
||||||
|
.env
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
LEARNINGS.md
|
||||||
|
PLAN.md
|
||||||
|
/memory
|
||||||
|
|||||||
Generated
+2221
-930
File diff suppressed because it is too large
Load Diff
+27
-23
@@ -1,36 +1,40 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.11", features = ["json", "blocking"] }
|
anyhow = "1"
|
||||||
|
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rss = { version = "2.0.1" }
|
rss = { version = "2.0.13" }
|
||||||
actix-web = "4.1.0"
|
actix-web = "4.13"
|
||||||
actix-rt = "2.7.0"
|
actix-rt = "2.10"
|
||||||
futures = "0.3.24"
|
futures = "0.3.31"
|
||||||
serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
|
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
|
||||||
serde_derive = "1.0.145"
|
serde_derive = "1.0.228"
|
||||||
actix-service = "2.0.2"
|
actix-service = "2.0.3"
|
||||||
diesel = { version = "2.0.2", features = ["postgres", "chrono"] }
|
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
||||||
dotenv = "0.15.0"
|
diesel_migrations = "2.3"
|
||||||
bcrypt = "0.13.0"
|
dotenvy = "0.15"
|
||||||
uuid = {version = "1.2.1", features=["serde", "v4"]}
|
bcrypt = "0.19"
|
||||||
|
uuid = {version = "1.23", features=["serde", "v4"]}
|
||||||
jwt = "0.16.0"
|
jwt = "0.16.0"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12"
|
||||||
sha2 = "0.10.6"
|
sha2 = "0.10"
|
||||||
log = "0.4.17"
|
log = "0.4.32"
|
||||||
env_logger = "0.9.3"
|
env_logger = "0.11"
|
||||||
scraper = "0.14.0"
|
scraper = "0.27"
|
||||||
actix-cors = "0.6.4"
|
actix-cors = "0.7"
|
||||||
chrono = { version = "0.4.31", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
dateparser = "0.2.0"
|
dateparser = "0.3"
|
||||||
|
ammonia = "4.1.2"
|
||||||
|
actix-governor = "0.10.0"
|
||||||
|
|
||||||
[dependencies.serde_json]
|
[dependencies.serde_json]
|
||||||
version = "1.0.86"
|
version = "1.0.150"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["alloc"]
|
features = ["alloc"]
|
||||||
|
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# --- builder ---
|
||||||
|
FROM rust:1-slim-bookworm AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq-dev pkg-config libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release && \
|
||||||
|
cp target/release/rss-reader /usr/local/bin/rss-reader && \
|
||||||
|
rm -rf target
|
||||||
|
|
||||||
|
# --- 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 /usr/local/bin/rss-reader /usr/local/bin/rss-reader
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
CMD ["rss-reader"]
|
||||||
@@ -1,24 +1,359 @@
|
|||||||
## RSS-Reader [WIP]
|
# RSS Reader
|
||||||
|
|
||||||
# Diesel Setup
|
A self-hosted RSS reader: a Rust/actix-web + Diesel/PostgreSQL backend with a Vue 3 single-page-app frontend. Sync feeds, read articles, and mark them as read — from your desktop or your phone.
|
||||||
|
|
||||||
setup, first step, or when docker been and DB not found.
|
## Stack
|
||||||
|
|
||||||
`diesel setup`
|
- **Backend**: Rust, actix-web, Diesel ORM, PostgreSQL, JWT auth
|
||||||
|
- **Frontend**: Vue 3, Vite, axios
|
||||||
|
- **Database**: PostgreSQL 18
|
||||||
|
|
||||||
generate table
|
---
|
||||||
|
|
||||||
`diesel migration generate create_to_do_items`
|
## Development setup
|
||||||
|
|
||||||
fill up and down
|
### Prerequisites
|
||||||
|
|
||||||
`diesel migration run`
|
- [Rust](https://rustup.rs/) (stable toolchain)
|
||||||
|
- [Node.js](https://nodejs.org/) 20+ and npm
|
||||||
|
- PostgreSQL (locally, or via Docker — see below)
|
||||||
|
- The Diesel CLI: `cargo install diesel_cli --no-default-features --features postgres`
|
||||||
|
(requires `libpq`; on Debian/Ubuntu: `sudo apt install libpq-dev`)
|
||||||
|
|
||||||
# docker
|
### 1. Configure environment variables
|
||||||
|
|
||||||
`docker exec -it 59ff8bad10c0 psql -d rss -U admin`
|
Copy the example file and fill in your own values:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
# Create user
|
`.env` is read by both the backend (via `dotenv`) and the `diesel` CLI, and is gitignored — never commit it.
|
||||||
curl -X POST -H "Content-Type: application/json" -d '{"name": "mace", "email": "safemind@posteo.de", "password": "secret"}' http://localhost:8001/api/v1/user/create
|
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | Postgres connection string, e.g. `postgres://admin:changeme@localhost/rss` |
|
||||||
|
| `JWT_SECRET` | Secret used to sign/verify auth tokens — use a long random string |
|
||||||
|
| `FRONTEND_ORIGIN` | Origin allowed by CORS, e.g. `http://localhost:5173` for the Vite dev server |
|
||||||
|
| `RUST_LOG` | Log level for `env_logger`, e.g. `info` |
|
||||||
|
| `POSTGRES_PASSWORD` | Password for the `admin` Postgres user (used by `docker-compose.yml`) |
|
||||||
|
|
||||||
|
### 2. Start PostgreSQL
|
||||||
|
|
||||||
|
The simplest way during development is to run just the database container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up postgres -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts Postgres on `localhost:5432` with the credentials from `.env`. Alternatively, point `DATABASE_URL` at any Postgres instance you already have running.
|
||||||
|
|
||||||
|
### 3. Run database migrations
|
||||||
|
|
||||||
|
Migrations are embedded in the backend binary and also run automatically on startup, but you can apply them manually with the Diesel CLI (useful when generating new migrations during development):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
diesel setup # creates the database and runs existing migrations
|
||||||
|
diesel migration run # applies any pending migrations
|
||||||
|
diesel migration generate <name> # scaffolds a new up.sql/down.sql pair
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the backend
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on `http://0.0.0.0:8001`. Backend logs respect `RUST_LOG`.
|
||||||
|
|
||||||
|
### 5. Run the frontend
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd vue
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server runs on `http://localhost:5173` (the `--host` flag also exposes it on your LAN so you can test from a phone) and proxies `/api` requests to `http://localhost:8001` (configured in `vue/vite.config.js`).
|
||||||
|
|
||||||
|
### 6. Try it out
|
||||||
|
|
||||||
|
Create a user, then log in through the UI at `http://localhost:5173`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST -H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "mace", "email": "you@example.com", "password": "secret1"}' \
|
||||||
|
http://localhost:8001/api/v1/user/create
|
||||||
|
```
|
||||||
|
|
||||||
|
Passwords must be at least 6 characters.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- **Sessions**: login returns a JWT (`token` header) valid for 24 hours. Logging out
|
||||||
|
(`POST /api/v1/auth/logout`, requires the current token) bumps the user's
|
||||||
|
`token_version`, which immediately invalidates *all* outstanding tokens for that
|
||||||
|
account — there's no per-session revocation, so logging out on one device logs out
|
||||||
|
every device.
|
||||||
|
- **Login rate limiting**: `POST /api/v1/auth/login` is limited to a burst of 5 requests
|
||||||
|
per IP, replenishing one every 2 seconds (`actix-governor`).
|
||||||
|
- **Outbound fetches**: feed syncs and the article-reader endpoint only fetch
|
||||||
|
`http`/`https` URLs that resolve to public IP addresses (no loopback/private/
|
||||||
|
link-local, e.g. `127.0.0.1` or the `169.254.169.254` cloud metadata address).
|
||||||
|
Redirects are followed (up to 5 hops), but each redirect target is checked against the
|
||||||
|
same rules before being fetched, so a redirect can't be used to reach an internal
|
||||||
|
address.
|
||||||
|
- **Stored feed content**: `<img>` tags from synced feed content are sanitized
|
||||||
|
(`ammonia`) down to `src`/`alt`/`title` before being stored, since they're later
|
||||||
|
rendered with `v-html` in the frontend.
|
||||||
|
- **If `JWT_SECRET` or `POSTGRES_PASSWORD` are ever leaked** (e.g. committed to git),
|
||||||
|
rotate them in `.env` and restart the backend — rotating `JWT_SECRET` invalidates every
|
||||||
|
outstanding token as a side effect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
|
||||||
|
```
|
||||||
|
|
||||||
|
> Each `docker compose up --build` leaves the previous build's cache layers and images
|
||||||
|
> behind, which adds up quickly given how much disk `cargo build` needs. Run the prune
|
||||||
|
> command above after each rebuild (or on a cron job) to reclaim that space.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
|
||||||
|
```
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|||||||
+31
-4
@@ -1,16 +1,43 @@
|
|||||||
version: "3.7"
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
container_name: "rss-postgres"
|
container_name: "rss-postgres"
|
||||||
image: "postgres:15"
|
image: "postgres:18"
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
- "POSTGRES_USER=admin"
|
- "POSTGRES_USER=admin"
|
||||||
- "POSTGRES_DB=rss"
|
- "POSTGRES_DB=rss"
|
||||||
- "POSTGRES_PASSWORD=secret+123"
|
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: "rss-backend"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
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"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
container_name: "rss-frontend"
|
||||||
|
build:
|
||||||
|
context: ./vue
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8080:80"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
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();
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN token_version;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Your SQL goes here
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- This file should undo anything in `up.sql`
|
||||||
|
ALTER TABLE feed_item
|
||||||
|
DROP CONSTRAINT feed_item_feed_id_url_unique;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Your SQL goes here
|
||||||
|
|
||||||
|
-- Concurrent syncs for the same feed could previously race past the
|
||||||
|
-- application-level "does this article already exist" check and both
|
||||||
|
-- insert, producing duplicate feed items. Before enforcing uniqueness,
|
||||||
|
-- collapse any duplicates that already exist, keyed on the article's link
|
||||||
|
-- (rather than its title) since that's the stable identity RSS items are
|
||||||
|
-- built around — some feeds edit a headline after publishing while keeping
|
||||||
|
-- the same link, which title-based matching would treat as a new article.
|
||||||
|
|
||||||
|
-- If any duplicate in a group was already read, propagate that onto the
|
||||||
|
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
|
||||||
|
-- already-read article look unread again.
|
||||||
|
UPDATE feed_item AS keep
|
||||||
|
SET read = true
|
||||||
|
FROM feed_item AS dupe
|
||||||
|
WHERE keep.feed_id = dupe.feed_id
|
||||||
|
AND keep.url = dupe.url
|
||||||
|
AND keep.id <> dupe.id
|
||||||
|
AND keep.id = (
|
||||||
|
SELECT MIN(candidate.id)
|
||||||
|
FROM feed_item AS candidate
|
||||||
|
WHERE candidate.feed_id = dupe.feed_id
|
||||||
|
AND candidate.url = dupe.url
|
||||||
|
)
|
||||||
|
AND dupe.read = true;
|
||||||
|
|
||||||
|
-- Keep the oldest (lowest id) row of each duplicate set.
|
||||||
|
DELETE FROM feed_item a
|
||||||
|
USING feed_item b
|
||||||
|
WHERE a.feed_id = b.feed_id
|
||||||
|
AND a.url = b.url
|
||||||
|
AND a.id > b.id;
|
||||||
|
|
||||||
|
ALTER TABLE feed_item
|
||||||
|
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use std::future::{ready, Ready};
|
||||||
|
|
||||||
|
use actix_web::dev::Payload;
|
||||||
|
use actix_web::{error::ErrorUnauthorized, Error, FromRequest, HttpMessage, HttpRequest};
|
||||||
|
|
||||||
|
/// The user id of the caller, as established by the auth middleware after
|
||||||
|
/// verifying the `user-token` header. Extracting this (instead of trusting a
|
||||||
|
/// client-supplied `user_id` in the path/body) is the source of truth for
|
||||||
|
/// "who is making this request".
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct AuthUser(pub i32);
|
||||||
|
|
||||||
|
impl FromRequest for AuthUser {
|
||||||
|
type Error = Error;
|
||||||
|
type Future = Ready<Result<Self, Self::Error>>;
|
||||||
|
|
||||||
|
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
|
||||||
|
match req.extensions().get::<AuthUser>() {
|
||||||
|
Some(auth_user) => ready(Ok(*auth_user)),
|
||||||
|
None => ready(Err(ErrorUnauthorized("missing authenticated user"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
-16
@@ -2,41 +2,72 @@ extern crate hmac;
|
|||||||
extern crate jwt;
|
extern crate jwt;
|
||||||
extern crate sha2;
|
extern crate sha2;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::env;
|
||||||
|
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use dotenvy::dotenv;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
/// How long a freshly issued token remains valid for.
|
||||||
|
const TOKEN_LIFETIME_HOURS: i64 = 730;
|
||||||
|
|
||||||
pub struct JwtToken {
|
pub struct JwtToken {
|
||||||
pub user_id: i32,
|
pub user_id: i32,
|
||||||
pub body: String,
|
pub token_version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
user_id: i32,
|
||||||
|
/// Must match `users.token_version` for the token to be accepted; bumping
|
||||||
|
/// the column (e.g. on logout) revokes every token issued before that point.
|
||||||
|
tv: i32,
|
||||||
|
/// Unix timestamp after which the token is rejected, independent of signature validity.
|
||||||
|
exp: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
fn signing_key() -> HmacSha256 {
|
||||||
|
dotenv().ok();
|
||||||
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
// HMAC-SHA256 accepts a key of any length, so this cannot fail.
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length")
|
||||||
|
}
|
||||||
|
|
||||||
impl JwtToken {
|
impl JwtToken {
|
||||||
pub fn encode(user_id: i32) -> String {
|
pub fn encode(user_id: i32, token_version: i32) -> String {
|
||||||
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
|
let key: HmacSha256 = signing_key();
|
||||||
let mut claims = BTreeMap::new();
|
let claims = Claims {
|
||||||
claims.insert("user_id", user_id);
|
user_id,
|
||||||
claims.sign_with_key(&key).unwrap()
|
tv: token_version,
|
||||||
|
exp: (Utc::now() + Duration::hours(TOKEN_LIFETIME_HOURS)).timestamp(),
|
||||||
|
};
|
||||||
|
// Signing claims with a valid HMAC key cannot fail.
|
||||||
|
claims
|
||||||
|
.sign_with_key(&key)
|
||||||
|
.expect("signing claims with a valid HMAC key cannot fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
||||||
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
|
let key: HmacSha256 = signing_key();
|
||||||
let token_str: &str = encoded_token.as_str();
|
let token_str: &str = encoded_token.as_str();
|
||||||
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
let token: Result<Token<Header, Claims, jwt::Verified>, jwt::Error> =
|
||||||
VerifyWithKey::verify_with_key(token_str, &key);
|
VerifyWithKey::verify_with_key(token_str, &key);
|
||||||
|
|
||||||
match token {
|
match token {
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
let _header = token.header();
|
|
||||||
let claims = token.claims();
|
let claims = token.claims();
|
||||||
|
if claims.exp < Utc::now().timestamp() {
|
||||||
|
return Err("token has expired");
|
||||||
|
}
|
||||||
Ok(JwtToken {
|
Ok(JwtToken {
|
||||||
user_id: claims["user_id"],
|
user_id: claims.user_id,
|
||||||
body: encoded_token,
|
token_version: claims.tv,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(_err) => Err("could not decode token"),
|
Err(_err) => Err("could not decode token"),
|
||||||
@@ -46,7 +77,10 @@ impl JwtToken {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
|
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
|
||||||
match request.headers().get("user-token") {
|
match request.headers().get("user-token") {
|
||||||
Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())),
|
Some(token) => match token.to_str() {
|
||||||
|
Ok(token_str) => JwtToken::decode(String::from(token_str)),
|
||||||
|
Err(_) => Err("token header is not valid text"),
|
||||||
|
},
|
||||||
None => Err("There is no token"),
|
None => Err("There is no token"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -55,14 +89,19 @@ impl JwtToken {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod jwt_test {
|
mod jwt_test {
|
||||||
use actix_web::{http::header, test};
|
use actix_web::{http::header, test};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use hmac::Hmac;
|
||||||
|
use jwt::SignWithKey;
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
use super::JwtToken;
|
use super::{Claims, JwtToken};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
async fn encode_decode() {
|
async fn encode_decode() {
|
||||||
let encoded_token: String = JwtToken::encode(32);
|
let encoded_token: String = JwtToken::encode(32, 0);
|
||||||
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
|
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
|
||||||
assert_eq!(32, decoded_token.user_id);
|
assert_eq!(32, decoded_token.user_id);
|
||||||
|
assert_eq!(0, decoded_token.token_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -75,9 +114,25 @@ mod jwt_test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
async fn decode_expired_token() {
|
||||||
|
let key: Hmac<Sha256> = super::signing_key();
|
||||||
|
let claims = Claims {
|
||||||
|
user_id: 32,
|
||||||
|
tv: 0,
|
||||||
|
exp: (Utc::now() - Duration::hours(1)).timestamp(),
|
||||||
|
};
|
||||||
|
let expired_token: String = claims.sign_with_key(&key).unwrap();
|
||||||
|
|
||||||
|
match JwtToken::decode(expired_token) {
|
||||||
|
Err(message) => assert_eq!(message, "token has expired"),
|
||||||
|
_ => panic!("Expired token should not be accepted."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn decode_from_request_with_correct_token() {
|
async fn decode_from_request_with_correct_token() {
|
||||||
let encoded_token: String = JwtToken::encode(32);
|
let encoded_token: String = JwtToken::encode(32, 0);
|
||||||
let request = test::TestRequest::default()
|
let request = test::TestRequest::default()
|
||||||
.insert_header(header::ContentType::json())
|
.insert_header(header::ContentType::json())
|
||||||
.insert_header(("user-token", encoded_token))
|
.insert_header(("user-token", encoded_token))
|
||||||
|
|||||||
+15
-9
@@ -1,12 +1,13 @@
|
|||||||
use actix_web::dev::ServiceRequest;
|
use actix_web::dev::ServiceRequest;
|
||||||
|
pub mod extractor;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod processes;
|
pub mod processes;
|
||||||
use crate::auth::processes::check_password;
|
use crate::auth::processes::check_token;
|
||||||
use crate::auth::processes::extract_header_token;
|
use crate::auth::processes::extract_header_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) {
|
match extract_header_token(request) {
|
||||||
Ok(token) => check_password(token),
|
Ok(token) => check_token(token),
|
||||||
Err(message) => Err(message),
|
Err(message) => Err(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,19 +17,24 @@ mod mod_test {
|
|||||||
|
|
||||||
use actix_web::test::TestRequest;
|
use actix_web::test::TestRequest;
|
||||||
|
|
||||||
use super::{jwt::JwtToken, process_token};
|
use super::process_token;
|
||||||
|
use crate::auth::jwt::JwtToken;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_token_test() {
|
fn process_token_test() {
|
||||||
let token = JwtToken::encode(32);
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let token = JwtToken::encode(user.id, user.token_version);
|
||||||
let request = TestRequest::delete()
|
let request = TestRequest::delete()
|
||||||
.insert_header(("user-token", token))
|
.insert_header(("user-token", token))
|
||||||
.to_srv_request();
|
.to_srv_request();
|
||||||
|
|
||||||
match process_token(&request) {
|
assert_eq!(Ok(user.id), process_token(&request));
|
||||||
Ok(message) => assert_eq!("passed", message),
|
|
||||||
Err(_) => panic!("process token failed"),
|
delete_user(&mut connection, user.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
|
|||||||
+54
-23
@@ -1,22 +1,33 @@
|
|||||||
use super::jwt;
|
use super::jwt;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::models::user::rss_user::User;
|
||||||
|
use crate::schema::users;
|
||||||
use actix_web::dev::ServiceRequest;
|
use actix_web::dev::ServiceRequest;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
pub fn check_password(password: String) -> Result<String, &'static str> {
|
/// Decodes the token and confirms it hasn't been revoked, i.e. its `token_version`
|
||||||
match jwt::JwtToken::decode(password) {
|
/// still matches the one stored on the user (bumped on logout / password change).
|
||||||
Ok(_token) => Ok(String::from("passed")),
|
pub fn check_token(token: String) -> Result<i32, &'static str> {
|
||||||
Err(message) => Err(message),
|
let decoded = jwt::JwtToken::decode(token)?;
|
||||||
|
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user: User = users::table
|
||||||
|
.find(decoded.user_id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.map_err(|_| "could not decode token")?;
|
||||||
|
|
||||||
|
if user.token_version != decoded.token_version {
|
||||||
|
return Err("token has been revoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(decoded.user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
||||||
log::info!("Request: {:?}", request);
|
|
||||||
match request.headers().get("user-token") {
|
match request.headers().get("user-token") {
|
||||||
Some(token) => match token.to_str() {
|
Some(token) => match token.to_str() {
|
||||||
Ok(processed_password) => {
|
Ok(processed_token) => Ok(String::from(processed_token)),
|
||||||
log::info!("Token provided: {}", processed_password);
|
Err(_) => Err("there was an error processing token"),
|
||||||
Ok(String::from(processed_password))
|
|
||||||
}
|
|
||||||
Err(_processed_password) => Err("there was an error processing token"),
|
|
||||||
},
|
},
|
||||||
None => Err("there is no token"),
|
None => Err("there is no token"),
|
||||||
}
|
}
|
||||||
@@ -25,31 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod processes_test {
|
mod processes_test {
|
||||||
use actix_web::test::TestRequest;
|
use actix_web::test::TestRequest;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::auth::jwt::JwtToken;
|
use crate::auth::jwt::JwtToken;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user};
|
||||||
|
|
||||||
use super::check_password;
|
use super::check_token;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_correct_password() {
|
fn check_correct_token() {
|
||||||
let password_string: String = JwtToken::encode(32);
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
let result = check_password(password_string);
|
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||||
|
|
||||||
match result {
|
let result = check_token(token);
|
||||||
Ok(check) => assert_eq!("passed", check),
|
|
||||||
_ => panic!("Check correct password failed."),
|
assert_eq!(Ok(user.id), result);
|
||||||
}
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn incorrect_check_password() {
|
fn revoked_token_is_rejected() {
|
||||||
let password: String = String::from("test");
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
match check_password(password) {
|
// Token signed with the user's current version, then the version is bumped
|
||||||
Err(message) => assert_eq!("could not decode token", message),
|
// (as logout would do), which must invalidate the previously issued token.
|
||||||
_ => panic!("check password should not be able to be decoded"),
|
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||||
|
diesel::update(crate::schema::users::table.find(user.id))
|
||||||
|
.set(crate::schema::users::token_version.eq(user.token_version + 1))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(Err("token has been revoked"), check_token(token));
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incorrect_check_token() {
|
||||||
|
let token: String = String::from("test");
|
||||||
|
|
||||||
|
assert_eq!(Err("could not decode token"), check_token(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+10
-1
@@ -1,8 +1,11 @@
|
|||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use dotenv::dotenv;
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
|
use dotenvy::dotenv;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||||
|
|
||||||
pub fn establish_connection() -> PgConnection {
|
pub fn establish_connection() -> PgConnection {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
@@ -10,3 +13,9 @@ pub fn establish_connection() -> PgConnection {
|
|||||||
PgConnection::establish(&database_url)
|
PgConnection::establish(&database_url)
|
||||||
.unwrap_or_else(|e| panic!("Error connecting to database {}: {}", database_url, e))
|
.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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
use actix_web::{HttpResponse, ResponseError};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// Wraps any error so it can be returned with `?` from request handlers.
|
||||||
|
/// Always surfaces as a 500 to the client; the real error is logged.
|
||||||
|
pub struct AppError(anyhow::Error);
|
||||||
|
|
||||||
|
impl fmt::Debug for AppError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
fmt::Debug::fmt(&self.0, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AppError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
fmt::Display::fmt(&self.0, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResponseError for AppError {
|
||||||
|
fn error_response(&self) -> HttpResponse {
|
||||||
|
log::error!("Unhandled error: {:?}", self.0);
|
||||||
|
HttpResponse::InternalServerError().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E> From<E> for AppError
|
||||||
|
where
|
||||||
|
E: Into<anyhow::Error>,
|
||||||
|
{
|
||||||
|
fn from(err: E) -> Self {
|
||||||
|
AppError(err.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{HttpResponse, Responder};
|
use actix_web::{HttpResponse, Responder};
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::reader::structs::feed::FeedAggregate;
|
use crate::reader::structs::feed::FeedAggregate;
|
||||||
@@ -13,7 +13,12 @@ impl Responder for Articles {
|
|||||||
type Body = String;
|
type Body = String;
|
||||||
|
|
||||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||||
let body = serde_json::to_string(&self).unwrap();
|
match serde_json::to_string(&self) {
|
||||||
HttpResponse::with_body(StatusCode::OK, body)
|
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to serialize response: {}", err);
|
||||||
|
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{HttpResponse, Responder};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FeedInfo {
|
||||||
|
pub id: i32,
|
||||||
|
pub title: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct FeedInfoList {
|
||||||
|
pub feeds: Vec<FeedInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Responder for FeedInfoList {
|
||||||
|
type Body = String;
|
||||||
|
|
||||||
|
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||||
|
match serde_json::to_string(&self) {
|
||||||
|
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to serialize response: {}", err);
|
||||||
|
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
pub mod articles;
|
pub mod articles;
|
||||||
|
pub mod feed_info;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod new_feed;
|
pub mod new_feed;
|
||||||
pub mod new_feed_item;
|
|
||||||
pub mod new_user;
|
pub mod new_user;
|
||||||
pub mod read_feed_item;
|
pub mod read_feed_item;
|
||||||
pub mod readable;
|
pub mod readable;
|
||||||
|
|||||||
@@ -1,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,5 +1,5 @@
|
|||||||
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{HttpResponse, Responder};
|
use actix_web::{HttpResponse, Responder};
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -11,7 +11,12 @@ impl Responder for Readable {
|
|||||||
type Body = String;
|
type Body = String;
|
||||||
|
|
||||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||||
let body = serde_json::to_string(&self).unwrap();
|
match serde_json::to_string(&self) {
|
||||||
HttpResponse::with_body(StatusCode::OK, body)
|
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to serialize response: {}", err);
|
||||||
|
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-20
@@ -1,43 +1,67 @@
|
|||||||
extern crate diesel;
|
extern crate diesel;
|
||||||
extern crate dotenv;
|
|
||||||
|
|
||||||
|
use actix_cors::Cors;
|
||||||
use actix_service::Service;
|
use actix_service::Service;
|
||||||
use actix_web::{App, HttpResponse, HttpServer};
|
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
||||||
|
use dotenvy::dotenv;
|
||||||
use futures::future::{ok, Either};
|
use futures::future::{ok, Either};
|
||||||
|
use std::env;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod database;
|
mod database;
|
||||||
|
mod error;
|
||||||
mod json_serialization;
|
mod json_serialization;
|
||||||
mod models;
|
mod models;
|
||||||
mod reader;
|
mod reader;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test_helpers;
|
||||||
mod views;
|
mod views;
|
||||||
|
|
||||||
#[actix_rt::main]
|
#[actix_rt::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
|
dotenv().ok();
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
HttpServer::new(|| {
|
database::run_migrations(&mut database::establish_connection());
|
||||||
let app = App::new()
|
|
||||||
|
let frontend_origin =
|
||||||
|
env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| String::from("http://localhost:5173"));
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
let cors = Cors::default()
|
||||||
|
.allowed_origin(&frontend_origin)
|
||||||
|
.allow_any_method()
|
||||||
|
.allow_any_header()
|
||||||
|
.supports_credentials();
|
||||||
|
|
||||||
|
App::new()
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
let mut passed: bool;
|
|
||||||
let request_url: String = String::from(req.uri().path());
|
let request_url: String = String::from(req.uri().path());
|
||||||
|
|
||||||
log::info!("Request Url: {}", request_url);
|
log::info!("Request Url: {}", request_url);
|
||||||
if req.path().contains("/article/") {
|
|
||||||
match auth::process_token(&req) {
|
// Only these endpoints are reachable without a valid token. Everything
|
||||||
Ok(_token) => passed = true,
|
// else (in particular all `/article/*` endpoints) requires one.
|
||||||
Err(_message) => passed = false,
|
let is_public = matches!(
|
||||||
}
|
request_url.as_str(),
|
||||||
|
"/api/v1/auth/login" | "/api/v1/user/create"
|
||||||
|
);
|
||||||
|
|
||||||
|
let passed = if is_public {
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
log::warn!("No auth check done.");
|
match auth::process_token(&req) {
|
||||||
passed = true;
|
Ok(user_id) => {
|
||||||
|
log::info!("Authenticated user {} for {}", user_id, request_url);
|
||||||
|
req.extensions_mut().insert(auth::extractor::AuthUser(user_id));
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
Err(message) => {
|
||||||
if req.path().contains("user/create") {
|
log::warn!("Rejected request to {}: {}", request_url, message);
|
||||||
passed = true;
|
false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
log::info!("passed: {:?}", passed);
|
};
|
||||||
|
|
||||||
let end_result = match passed {
|
let end_result = match passed {
|
||||||
true => Either::Left(srv.call(req)),
|
true => Either::Left(srv.call(req)),
|
||||||
@@ -52,10 +76,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.configure(views::views_factory);
|
.wrap(cors)
|
||||||
app
|
.configure(views::views_factory)
|
||||||
})
|
})
|
||||||
.bind("127.0.0.1:8001")?
|
.bind("0.0.0.0:8001")?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
|
|
||||||
|
pub const MIN_PASSWORD_LENGTH: usize = 6;
|
||||||
|
|
||||||
|
/// Rejects empty/trivial passwords. Kept as a standalone function so callers
|
||||||
|
/// (e.g. the user-creation handler) can return a `400` for this case
|
||||||
|
/// specifically, rather than the `500` that `NewUser::new`'s `anyhow::Result`
|
||||||
|
/// would otherwise map to.
|
||||||
|
pub fn validate_password(password: &str) -> Result<(), &'static str> {
|
||||||
|
if password.trim().len() < MIN_PASSWORD_LENGTH {
|
||||||
|
return Err("password must be at least 6 characters long");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Insertable, Clone)]
|
#[derive(Insertable, Clone)]
|
||||||
#[diesel(table_name=users)]
|
#[diesel(table_name=users)]
|
||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
@@ -16,14 +29,15 @@ pub struct NewUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NewUser {
|
impl NewUser {
|
||||||
pub fn new(username: String, email: String, password: String) -> NewUser {
|
pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
|
||||||
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
|
validate_password(&password).map_err(anyhow::Error::msg)?;
|
||||||
|
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
|
||||||
let uuid = Uuid::new_v4();
|
let uuid = Uuid::new_v4();
|
||||||
NewUser {
|
Ok(NewUser {
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password: hashed_password,
|
password: hashed_password,
|
||||||
unique_id: uuid.to_string(),
|
unique_id: uuid.to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ pub struct User {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub unique_id: String,
|
pub unique_id: String,
|
||||||
|
pub token_version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn verify(self, password: String) -> bool {
|
pub fn verify(self, password: String) -> anyhow::Result<bool> {
|
||||||
return verify(password.as_str(), &self.password).unwrap();
|
Ok(verify(password.as_str(), &self.password)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-7
@@ -2,13 +2,17 @@ use actix_web::{web, HttpResponse};
|
|||||||
use diesel::RunQueryDsl;
|
use diesel::RunQueryDsl;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection, json_serialization::new_feed::NewFeedSchema,
|
auth::extractor::AuthUser, database::establish_connection,
|
||||||
models::feed::new_feed::NewFeed, schema::feed,
|
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::feeds;
|
use super::feeds;
|
||||||
|
|
||||||
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
pub async fn add(new_feed: web::Json<NewFeedSchema>, auth_user: AuthUser) -> HttpResponse {
|
||||||
|
if auth_user.0 != new_feed.user_id {
|
||||||
|
return HttpResponse::Forbidden().finish();
|
||||||
|
}
|
||||||
|
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
let title: String = new_feed.title.clone();
|
let title: String = new_feed.title.clone();
|
||||||
let url: String = new_feed.url.clone();
|
let url: String = new_feed.url.clone();
|
||||||
@@ -19,12 +23,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
|||||||
Ok(channel) => {
|
Ok(channel) => {
|
||||||
log::info!("valid channel");
|
log::info!("valid channel");
|
||||||
if channel.items.is_empty() {
|
if channel.items.is_empty() {
|
||||||
return HttpResponse::ServiceUnavailable().await.unwrap();
|
return HttpResponse::ServiceUnavailable().finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("{:?}", e);
|
log::error!("{:?}", e);
|
||||||
return HttpResponse::NotFound().await.unwrap();
|
return HttpResponse::NotFound().finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +39,69 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
|||||||
.execute(&mut connection);
|
.execute(&mut connection);
|
||||||
|
|
||||||
match insert_result {
|
match insert_result {
|
||||||
Ok(_) => HttpResponse::Created().await.unwrap(),
|
Ok(_) => HttpResponse::Created().finish(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("{e}");
|
log::error!("{e}");
|
||||||
HttpResponse::Conflict().await.unwrap()
|
HttpResponse::Conflict().finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
|
||||||
|
use super::add;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::test_helpers::unique_suffix;
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn add_fails_for_unfetchable_feed_url() {
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn add_rejects_feed_for_another_user() {
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/add", web::post().to(add)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/add")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"title": "Someone else's feed",
|
||||||
|
"url": format!("https://example.test/feed/{}", unique_suffix()),
|
||||||
|
"user_id": 2
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::FORBIDDEN, resp.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use actix_web::{web, HttpResponse};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::extractor::AuthUser,
|
||||||
|
database::establish_connection,
|
||||||
|
schema::{feed, feed_item},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn delete_feed(path: web::Path<i32>, auth_user: AuthUser) -> HttpResponse {
|
||||||
|
let feed_id = path.into_inner();
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
|
||||||
|
let owner: Option<i32> = feed::table
|
||||||
|
.find(feed_id)
|
||||||
|
.select(feed::user_id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.optional()
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
// Treat "doesn't exist" and "not yours" the same, so callers can't probe
|
||||||
|
// for other users' feed ids.
|
||||||
|
if owner != Some(auth_user.0) {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::delete(feed_item::table.filter(feed_item::feed_id.eq(feed_id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
diesel::delete(feed::table.filter(feed::id.eq(feed_id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
HttpResponse::NoContent().finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use super::delete_feed;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::schema::{feed, feed_item};
|
||||||
|
use crate::test_helpers::{
|
||||||
|
delete_feed as cleanup_feed, delete_user, insert_feed, insert_feed_item, insert_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_removes_feed_and_items() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let f = insert_feed(&mut connection, user.id);
|
||||||
|
let item = insert_feed_item(&mut connection, f.id, false);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri(&format!("/feed/{}", f.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NO_CONTENT, resp.status());
|
||||||
|
|
||||||
|
let feed_exists: i64 = feed::table
|
||||||
|
.filter(feed::id.eq(f.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(0, feed_exists);
|
||||||
|
|
||||||
|
let item_exists: i64 = feed_item::table
|
||||||
|
.filter(feed_item::id.eq(item.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(0, item_exists);
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_returns_404_for_nonexistent_feed() {
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri("/feed/999999999")
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_does_not_affect_other_feeds() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed_a = insert_feed(&mut connection, user.id);
|
||||||
|
let feed_b = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri(&format!("/feed/{}", feed_a.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NO_CONTENT, resp.status());
|
||||||
|
|
||||||
|
let feed_b_exists: i64 = feed::table
|
||||||
|
.filter(feed::id.eq(feed_b.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(1, feed_b_exists);
|
||||||
|
|
||||||
|
cleanup_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_rejects_other_users_feed() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||||
|
|
||||||
|
let user_a_id = user_a.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri(&format!("/feed/{}", feed_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
|
||||||
|
let feed_b_exists: i64 = feed::table
|
||||||
|
.filter(feed::id.eq(feed_b.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(1, feed_b_exists);
|
||||||
|
|
||||||
|
cleanup_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -1,9 +1,10 @@
|
|||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use rss::Channel;
|
use rss::Channel;
|
||||||
|
|
||||||
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
|
use super::net::safe_fetch;
|
||||||
let content = reqwest::get(feed).await?.bytes().await?;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub async fn get_feed(feed: &str) -> Result<Channel, AppError> {
|
||||||
|
let content = safe_fetch(feed).await?.bytes().await?;
|
||||||
let channel = Channel::read_from(&content[..])?;
|
let channel = Channel::read_from(&content[..])?;
|
||||||
log::debug!("{:?}", channel);
|
log::debug!("{:?}", channel);
|
||||||
Ok(channel)
|
Ok(channel)
|
||||||
|
|||||||
+92
-7
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::user::JsonUser;
|
use crate::json_serialization::user::JsonUser;
|
||||||
use crate::models::feed::rss_feed::Feed;
|
use crate::models::feed::rss_feed::Feed;
|
||||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
@@ -9,22 +11,29 @@ use crate::{
|
|||||||
schema::feed::{self, user_id},
|
schema::feed::{self, user_id},
|
||||||
schema::feed_item,
|
schema::feed_item,
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use super::structs::article::Article;
|
use super::structs::article::Article;
|
||||||
|
|
||||||
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
|
pub async fn get(
|
||||||
|
path: web::Path<JsonUser>,
|
||||||
|
req: HttpRequest,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
let request = req.clone();
|
let request = req.clone();
|
||||||
let req_user_id = path.user_id;
|
let req_user_id = path.user_id;
|
||||||
log::info!("Received user_id: {}", req_user_id);
|
log::info!("Received user_id: {}", req_user_id);
|
||||||
|
|
||||||
|
if auth_user.0 != req_user_id {
|
||||||
|
return Ok(HttpResponse::Forbidden().finish());
|
||||||
|
}
|
||||||
|
|
||||||
let mut connection: diesel::PgConnection = establish_connection();
|
let mut connection: diesel::PgConnection = establish_connection();
|
||||||
let feeds: Vec<Feed> = feed::table
|
let feeds: Vec<Feed> = feed::table
|
||||||
.filter(user_id.eq(req_user_id))
|
.filter(user_id.eq(req_user_id))
|
||||||
.load::<Feed>(&mut connection)
|
.load::<Feed>(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
|
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
|
||||||
for feed in feeds {
|
for feed in feeds {
|
||||||
@@ -32,8 +41,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
|
|||||||
.filter(feed_id.eq(feed.id))
|
.filter(feed_id.eq(feed.id))
|
||||||
.filter(read.eq(false))
|
.filter(read.eq(false))
|
||||||
.order(id.asc())
|
.order(id.asc())
|
||||||
.load(&mut connection)
|
.load(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Load {} feed items for feed: {}",
|
"Load {} feed items for feed: {}",
|
||||||
@@ -70,5 +78,82 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
|
|||||||
feeds: feed_aggregates,
|
feeds: feed_aggregates,
|
||||||
};
|
};
|
||||||
|
|
||||||
articles.respond_to(&request)
|
Ok(articles.respond_to(&request).map_into_boxed_body())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
|
||||||
|
use super::get;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
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 user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn get_rejects_requests_for_another_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a.id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/get/{user_id}", web::get().to(get)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/get/{}", user_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::FORBIDDEN, resp.status());
|
||||||
|
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::extractor::AuthUser,
|
||||||
|
database::establish_connection,
|
||||||
|
error::AppError,
|
||||||
|
json_serialization::feed_info::{FeedInfo, FeedInfoList},
|
||||||
|
json_serialization::user::JsonUser,
|
||||||
|
schema::feed::{self, user_id},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_feeds(
|
||||||
|
path: web::Path<JsonUser>,
|
||||||
|
req: HttpRequest,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let request = req.clone();
|
||||||
|
let req_user_id = path.user_id;
|
||||||
|
|
||||||
|
if auth_user.0 != req_user_id {
|
||||||
|
return Ok(HttpResponse::Forbidden().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let feeds = feed::table
|
||||||
|
.filter(user_id.eq(req_user_id))
|
||||||
|
.select((feed::id, feed::title, feed::url))
|
||||||
|
.load::<(i32, String, String)>(&mut connection)?;
|
||||||
|
|
||||||
|
let feed_list: Vec<FeedInfo> = feeds
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, title, url)| FeedInfo { id, title, url })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(FeedInfoList { feeds: feed_list }
|
||||||
|
.respond_to(&request)
|
||||||
|
.map_into_boxed_body())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
|
||||||
|
use super::list_feeds;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn list_feeds_returns_feeds_for_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/feeds/{}", 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(&feed.title));
|
||||||
|
assert!(body_str.contains(&feed.url));
|
||||||
|
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn list_feeds_returns_empty_list_for_user_with_no_feeds() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/feeds/{}", 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("\"feeds\":[]"));
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn list_feeds_rejects_requests_for_another_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||||
|
|
||||||
|
let user_a_id = user_a.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/feeds/{}", user_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::FORBIDDEN, resp.status());
|
||||||
|
|
||||||
|
delete_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-15
@@ -1,31 +1,149 @@
|
|||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::schema::feed_item::{id, read};
|
use crate::schema::feed_item::{id, read};
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection, json_serialization::read_feed_item::ReadItem,
|
database::establish_connection,
|
||||||
models::feed_item::rss_feed_item::FeedItem, schema::feed_item,
|
json_serialization::read_feed_item::ReadItem,
|
||||||
|
models::feed_item::rss_feed_item::FeedItem,
|
||||||
|
schema::{feed, feed_item},
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
use diesel::RunQueryDsl;
|
use diesel::RunQueryDsl;
|
||||||
use diesel::{ExpressionMethods, QueryDsl};
|
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl};
|
||||||
|
|
||||||
pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Responder {
|
pub async fn mark_read(
|
||||||
|
_req: HttpRequest,
|
||||||
|
path: web::Path<ReadItem>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl Responder, AppError> {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
log::info!("Id: {}", path.id);
|
log::info!("Id: {}", path.id);
|
||||||
let feed_items: Vec<FeedItem> = feed_item::table
|
|
||||||
|
// Join through to `feed` so we can confirm the item belongs to the caller
|
||||||
|
// before mutating it. "Doesn't exist" and "not yours" both return 404.
|
||||||
|
let owned_item: Option<(FeedItem, i32)> = feed_item::table
|
||||||
|
.inner_join(feed::table)
|
||||||
.filter(id.eq(path.id))
|
.filter(id.eq(path.id))
|
||||||
.load::<FeedItem>(&mut connection)
|
.select((feed_item::all_columns, feed::user_id))
|
||||||
.unwrap();
|
.first(&mut connection)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
if feed_items.len() != 1 {
|
let feed_item = match owned_item {
|
||||||
return HttpResponse::NotFound();
|
Some((feed_item, owner_id)) if owner_id == auth_user.0 => feed_item,
|
||||||
}
|
_ => return Ok(HttpResponse::NotFound().finish()),
|
||||||
|
};
|
||||||
|
|
||||||
let feed_item: &FeedItem = feed_items.first().unwrap();
|
let result = diesel::update(&feed_item)
|
||||||
|
|
||||||
let result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
|
|
||||||
.set(read.eq(true))
|
.set(read.eq(true))
|
||||||
.execute(&mut connection);
|
.execute(&mut connection)?;
|
||||||
|
|
||||||
log::info!("Mark as read: {:?}", result);
|
log::info!("Mark as read: {:?}", result);
|
||||||
|
|
||||||
HttpResponse::Ok()
|
Ok(HttpResponse::Ok().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use super::mark_read;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
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 user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.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()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn mark_read_rejects_other_users_item() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||||
|
let item_b = insert_feed_item(&mut connection, feed_b.id, false);
|
||||||
|
|
||||||
|
let user_a_id = user_a.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/read/{id}", web::put().to(mark_read)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::put()
|
||||||
|
.uri(&format!("/read/{}", item_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
|
||||||
|
let updated: FeedItem = feed_item::table
|
||||||
|
.find(item_b.id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!updated.read);
|
||||||
|
|
||||||
|
delete_feed_item(&mut connection, item_b.id);
|
||||||
|
delete_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ use actix_web::web;
|
|||||||
|
|
||||||
use crate::views::path::Path;
|
use crate::views::path::Path;
|
||||||
mod add;
|
mod add;
|
||||||
|
mod delete_feed;
|
||||||
pub mod feeds;
|
pub mod feeds;
|
||||||
mod get;
|
mod get;
|
||||||
|
mod list_feeds;
|
||||||
mod mark_read;
|
mod mark_read;
|
||||||
|
pub mod net;
|
||||||
mod read;
|
mod read;
|
||||||
mod scraper;
|
mod scraper;
|
||||||
pub mod structs;
|
pub mod structs;
|
||||||
@@ -19,6 +22,14 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
|
|||||||
&base_path.define(String::from("/get/{user_id}")),
|
&base_path.define(String::from("/get/{user_id}")),
|
||||||
web::get().to(get::get),
|
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(
|
app.route(
|
||||||
&base_path.define(String::from("/add")),
|
&base_path.define(String::from("/add")),
|
||||||
web::post().to(add::add),
|
web::post().to(add::add),
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use anyhow::bail;
|
||||||
|
use reqwest::{redirect::Policy, Client, Response, Url};
|
||||||
|
use tokio::net::lookup_host;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
// Outbound requests for feed/article URLs are driven by user input (feed URLs,
|
||||||
|
// "read" links). Without these checks a user could point the server at
|
||||||
|
// internal services or cloud metadata endpoints (e.g. http://169.254.169.254/)
|
||||||
|
// and have it fetch them on their behalf (SSRF).
|
||||||
|
pub async fn safe_fetch(url: &str) -> Result<Response, AppError> {
|
||||||
|
safe_fetch_inner(url).await.map_err(AppError::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirects are validated and followed manually (rather than via
|
||||||
|
// `redirect::Policy::default()`) so each hop's resolved address is checked
|
||||||
|
// against `is_globally_routable` before it's fetched — otherwise an allowed
|
||||||
|
// host could redirect to an internal address and bypass the checks below.
|
||||||
|
const MAX_REDIRECTS: u8 = 5;
|
||||||
|
|
||||||
|
async fn safe_fetch_inner(url: &str) -> anyhow::Result<Response> {
|
||||||
|
let client = Client::builder().redirect(Policy::none()).build()?;
|
||||||
|
|
||||||
|
let mut current = Url::parse(url)?;
|
||||||
|
for _ in 0..=MAX_REDIRECTS {
|
||||||
|
check_url_is_safe(¤t).await?;
|
||||||
|
|
||||||
|
let response = client.get(current.clone()).send().await?;
|
||||||
|
|
||||||
|
if response.status().is_redirection() {
|
||||||
|
let location = response
|
||||||
|
.headers()
|
||||||
|
.get(reqwest::header::LOCATION)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("redirect response from {} has no Location header", current)
|
||||||
|
})?
|
||||||
|
.to_str()?;
|
||||||
|
current = current.join(location)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
bail!("refusing to fetch {}: too many redirects", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn check_url_is_safe(url: &Url) -> anyhow::Result<()> {
|
||||||
|
if url.scheme() != "http" && url.scheme() != "https" {
|
||||||
|
bail!("refusing to fetch {}: unsupported URL scheme", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("refusing to fetch {}: URL has no host", url))?;
|
||||||
|
let port = url.port_or_known_default().unwrap_or(80);
|
||||||
|
|
||||||
|
let mut resolved_any = false;
|
||||||
|
for addr in lookup_host((host, port)).await? {
|
||||||
|
resolved_any = true;
|
||||||
|
if !is_globally_routable(addr.ip()) {
|
||||||
|
bail!(
|
||||||
|
"refusing to fetch {}: resolves to non-public address {}",
|
||||||
|
url,
|
||||||
|
addr.ip()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !resolved_any {
|
||||||
|
bail!("refusing to fetch {}: host did not resolve to any address", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_globally_routable(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
!(v4.is_private()
|
||||||
|
|| v4.is_loopback()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_unspecified()
|
||||||
|
|| v4.is_broadcast()
|
||||||
|
|| v4.is_multicast())
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||||
|
return is_globally_routable(IpAddr::V4(v4));
|
||||||
|
}
|
||||||
|
|
||||||
|
let segments = v6.segments();
|
||||||
|
let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; // fc00::/7
|
||||||
|
let is_unicast_link_local = (segments[0] & 0xffc0) == 0xfe80; // fe80::/10
|
||||||
|
|
||||||
|
!(v6.is_loopback()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| v6.is_multicast()
|
||||||
|
|| is_unique_local
|
||||||
|
|| is_unicast_link_local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_loopback_and_private_addresses() {
|
||||||
|
assert!(!is_globally_routable("127.0.0.1".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("10.0.0.5".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("192.168.1.1".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("169.254.169.254".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("::1".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("fc00::1".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("fe80::1".parse().unwrap()));
|
||||||
|
assert!(!is_globally_routable("::ffff:127.0.0.1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_public_addresses() {
|
||||||
|
assert!(is_globally_routable("93.184.216.34".parse().unwrap()));
|
||||||
|
assert!(is_globally_routable("2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn rejects_unsupported_schemes() {
|
||||||
|
let result = safe_fetch("ftp://example.test/file").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn rejects_loopback_urls() {
|
||||||
|
let result = safe_fetch("http://127.0.0.1:8001/").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn rejects_link_local_metadata_url() {
|
||||||
|
let result = safe_fetch("http://169.254.169.254/latest/meta-data/").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use reqwest::Error;
|
use super::super::net::safe_fetch;
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
// Do a request for the given URL, with a minimum time between requests
|
// Do a request for the given URL, with a minimum time between requests
|
||||||
// to avoid overloading the server.
|
// to avoid overloading the server.
|
||||||
pub async fn do_throttled_request(url: &str) -> Result<String, Error> {
|
pub async fn do_throttled_request(url: &str) -> Result<String, AppError> {
|
||||||
let response = reqwest::get(url).await?;
|
let response = safe_fetch(url).await?;
|
||||||
response.text().await
|
Ok(response.text().await?)
|
||||||
}
|
}
|
||||||
|
|||||||
+551
-68
@@ -1,9 +1,10 @@
|
|||||||
use super::feeds;
|
use super::feeds;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::user::JsonUser;
|
use crate::json_serialization::user::JsonUser;
|
||||||
use crate::models::feed::rss_feed::Feed;
|
use crate::models::feed::rss_feed::Feed;
|
||||||
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
||||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
use crate::schema::feed_item::{feed_id, url};
|
||||||
use crate::schema::feed_item::{feed_id, title};
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection,
|
database::establish_connection,
|
||||||
schema::{
|
schema::{
|
||||||
@@ -11,108 +12,182 @@ use crate::{
|
|||||||
feed_item,
|
feed_item,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse};
|
||||||
use chrono::{DateTime, Local, NaiveDateTime};
|
use chrono::{DateTime, Local, NaiveDateTime};
|
||||||
use dateparser::parse;
|
use dateparser::parse;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use rss::Item;
|
use rss::Item;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
||||||
// let format_string = "%a, %d %b %Y %H:%M:%S %z";
|
if let Ok(result) = parse(date_str) {
|
||||||
let format_string = "%Y-%m-%dT%H:%M:%S%Z";
|
|
||||||
|
|
||||||
let result = parse(date_str).unwrap();
|
|
||||||
log::info!("Date: {:?}", result);
|
log::info!("Date: {:?}", result);
|
||||||
|
return Ok(result.with_timezone(&Local).naive_local());
|
||||||
|
}
|
||||||
|
|
||||||
match NaiveDateTime::parse_from_str(&result.to_string(), format_string) {
|
DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local())
|
||||||
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),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some feeds (e.g. Deutsche Welle) embed responsive-image templates such as
|
||||||
|
// `src="https://example.com/img_${formatId}.jpg"` that their own frontend
|
||||||
|
// JavaScript fills in before loading — verbatim, they 404. Skip those and
|
||||||
|
// pick the first <img> with a real, directly loadable URL instead.
|
||||||
|
fn image_src_is_resolvable(element: &scraper::ElementRef) -> bool {
|
||||||
|
match element.value().attr("src") {
|
||||||
|
Some(src) => !src.contains('{') && !src.to_lowercase().contains("%7b"),
|
||||||
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
fn escape_html_attr(value: &str) -> String {
|
||||||
let item_title = item.title.clone().unwrap();
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed-supplied `<img>` markup is rendered as-is (via `v-html`) in the frontend,
|
||||||
|
// so strip everything except a harmless image tag before it's stored — in
|
||||||
|
// particular event handlers like `onerror`/`onload` that a malicious feed
|
||||||
|
// could use for XSS.
|
||||||
|
fn sanitize_img_html(html: &str) -> String {
|
||||||
|
let allowed_attributes = HashSet::from(["src", "alt", "title"]);
|
||||||
|
|
||||||
|
ammonia::Builder::default()
|
||||||
|
.tags(HashSet::from(["img"]))
|
||||||
|
.tag_attributes(HashMap::from([("img", allowed_attributes)]))
|
||||||
|
.clean(html)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) -> anyhow::Result<()> {
|
||||||
|
// Items without a title or link are malformed/unusable — skip them rather
|
||||||
|
// than failing the whole sync over one bad entry from an external feed.
|
||||||
|
let Some(item_title) = item.title.clone() else {
|
||||||
|
log::warn!("Skipping feed item without a title.");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if item.link.is_none() {
|
||||||
|
log::warn!("Skipping feed item without a link: {}", item_title);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
log::info!("Create feed item: {}", item_title);
|
log::info!("Create feed item: {}", item_title);
|
||||||
|
|
||||||
let base_content: &str = match item.content() {
|
// Items without a pub_date are treated as current (inserted unconditionally)
|
||||||
Some(c) => c,
|
// — feeds that don't publish dates are typically small/curated enough that
|
||||||
None => match item.description() {
|
// this is fine.
|
||||||
Some(c) => c,
|
|
||||||
None => "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let scraper::node::Node::Text(text) = node {
|
|
||||||
content.push_str(&text.text);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let existing_item: Vec<FeedItem> = feed_item::table
|
|
||||||
.filter(feed_id.eq(feed.id))
|
|
||||||
.filter(title.eq(&item_title))
|
|
||||||
.load(connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if existing_item.is_empty() {
|
|
||||||
log::info!("{:?}", item.pub_date());
|
|
||||||
let mut time: NaiveDateTime = Local::now().naive_local();
|
let mut time: NaiveDateTime = Local::now().naive_local();
|
||||||
if item.pub_date().is_some() {
|
if let Some(pub_date) = item.pub_date() {
|
||||||
time = match get_date(item.pub_date().unwrap()) {
|
time = match get_date(pub_date) {
|
||||||
Ok(date) => date,
|
Ok(date) => date,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!("could not unwrap pub date: {}", err);
|
log::error!("could not parse pub date: {}", err);
|
||||||
time
|
time
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
|
||||||
|
|
||||||
|
let frag = Html::parse_fragment(base_content);
|
||||||
|
let mut content = "".to_string();
|
||||||
|
|
||||||
|
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
|
||||||
|
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
|
||||||
|
// article content. It's not part of the article and isn't present in the
|
||||||
|
// scraped/readable edition either, so skip its text when flattening below.
|
||||||
|
let selector_social_bar =
|
||||||
|
Selector::parse("#article-social-bar").expect("\"#article-social-bar\" is a valid CSS selector");
|
||||||
|
let excluded_node_ids: std::collections::HashSet<_> = frag
|
||||||
|
.select(&selector_social_bar)
|
||||||
|
.flat_map(|el| el.descendants().map(|node| node.id()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
|
||||||
|
match frag.select(&selector_img).find(image_src_is_resolvable) {
|
||||||
|
Some(image) => {
|
||||||
|
content.push_str(&sanitize_img_html(&image.html()));
|
||||||
|
content.push_str("<br>");
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(image_html) = enclosure_image_html(&item) {
|
||||||
|
content.push_str(&sanitize_img_html(&image_html));
|
||||||
|
content.push_str("<br>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in frag.tree.nodes() {
|
||||||
|
if excluded_node_ids.contains(&node.id()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let scraper::node::Node::Text(text) = node.value() {
|
||||||
|
content.push_str(&text.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let new_feed_item = NewFeedItem::new(
|
let new_feed_item = NewFeedItem::new(
|
||||||
feed.id,
|
feed.id,
|
||||||
content.clone(),
|
content.clone(),
|
||||||
item_title.clone(),
|
item_title.clone(),
|
||||||
item.link.unwrap(),
|
item.link.expect("checked above"),
|
||||||
Some(time),
|
Some(time),
|
||||||
);
|
);
|
||||||
let insert_result = diesel::insert_into(feed_item::table)
|
|
||||||
.values(&new_feed_item)
|
|
||||||
.execute(connection);
|
|
||||||
|
|
||||||
log::info!("Insert Result: {:?}", insert_result);
|
// `on_conflict` on the (feed_id, url) unique constraint makes this
|
||||||
|
// insert idempotent, so two syncs for the same feed running concurrently
|
||||||
|
// (e.g. a sync button press racing a page-reload sync) can't both pass a
|
||||||
|
// check-then-insert race and create duplicate items. Keying on the
|
||||||
|
// article's link rather than its title also handles feeds that edit a
|
||||||
|
// headline after publishing while keeping the same link — the link is
|
||||||
|
// the stable identity.
|
||||||
|
let inserted_rows = diesel::insert_into(feed_item::table)
|
||||||
|
.values(&new_feed_item)
|
||||||
|
.on_conflict((feed_id, url))
|
||||||
|
.do_nothing()
|
||||||
|
.execute(connection)?;
|
||||||
|
|
||||||
|
if inserted_rows > 0 {
|
||||||
|
log::info!("Inserted item: {}", item_title);
|
||||||
} else {
|
} else {
|
||||||
log::info!("Item {} already exists.", item_title);
|
log::info!("Item {} already exists.", item_title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
|
pub async fn sync(
|
||||||
let mut connection: diesel::PgConnection = establish_connection();
|
_req: HttpRequest,
|
||||||
|
data: web::Json<JsonUser>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
let req_user_id: i32 = data.user_id;
|
let req_user_id: i32 = data.user_id;
|
||||||
|
|
||||||
|
if auth_user.0 != req_user_id {
|
||||||
|
return Ok(HttpResponse::Forbidden().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut connection: diesel::PgConnection = establish_connection();
|
||||||
|
|
||||||
let feeds: Vec<Feed> = feed::table
|
let feeds: Vec<Feed> = feed::table
|
||||||
.filter(user_id.eq(req_user_id))
|
.filter(user_id.eq(req_user_id))
|
||||||
.load::<Feed>(&mut connection)
|
.load::<Feed>(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
log::info!("Found {} feeds to sync.", feeds.len());
|
log::info!("Found {} feeds to sync.", feeds.len());
|
||||||
|
|
||||||
@@ -124,12 +199,420 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
|
|||||||
Ok(channel) => {
|
Ok(channel) => {
|
||||||
for item in channel.into_items() {
|
for item in channel.into_items() {
|
||||||
log::info!("{:?}", item);
|
log::info!("{:?}", item);
|
||||||
create_feed_item(item, &feed, &mut connection);
|
if let Err(e) = create_feed_item(item, &feed, &mut connection) {
|
||||||
|
log::error!("Could not create feed item for {}: {:?}", feed.url, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
|
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::Ok()
|
Ok(HttpResponse::Ok().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::models::feed::new_feed::NewFeed;
|
||||||
|
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::users;
|
||||||
|
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_img_html_strips_event_handlers() {
|
||||||
|
let sanitized = sanitize_img_html(r#"<img src="x" onerror="alert(1)">"#);
|
||||||
|
|
||||||
|
assert!(!sanitized.contains("onerror"));
|
||||||
|
assert!(!sanitized.contains("alert"));
|
||||||
|
assert!(sanitized.contains(r#"src="x""#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_img_html_keeps_only_allowed_attributes() {
|
||||||
|
let sanitized = sanitize_img_html(
|
||||||
|
r#"<img src="https://example.test/img.jpg" alt="desc" title="t" style="display:none" class="evil">"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(sanitized.contains(r#"src="https://example.test/img.jpg""#));
|
||||||
|
assert!(sanitized.contains(r#"alt="desc""#));
|
||||||
|
assert!(sanitized.contains(r#"title="t""#));
|
||||||
|
assert!(!sanitized.contains("style"));
|
||||||
|
assert!(!sanitized.contains("class"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_feed_item_inserts_articles_older_than_two_weeks() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let new_user = NewUser::new(
|
||||||
|
format!("age_test_{suffix}"),
|
||||||
|
format!("age_{suffix}@example.test"),
|
||||||
|
"secret".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let user: User = diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_feed = NewFeed::new(
|
||||||
|
format!("Age 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 still be inserted, since
|
||||||
|
// infrequently-updated feeds (or infrequent syncs) must not lose
|
||||||
|
// articles the user hasn't seen yet.
|
||||||
|
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).unwrap();
|
||||||
|
create_feed_item(fresh_item, &feed, &mut connection).unwrap();
|
||||||
|
|
||||||
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.load(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(2, items.len(), "both old and fresh items should be inserted");
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
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).unwrap();
|
||||||
|
create_feed_item(item, &feed, &mut connection).unwrap();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let mut item = Item::default();
|
||||||
|
item.set_title(Some(format!("Race test article {suffix}")));
|
||||||
|
item.set_link(Some(format!("https://example.test/race/{suffix}")));
|
||||||
|
item.set_content(Some("<p>Hello world</p>".to_string()));
|
||||||
|
|
||||||
|
// Simulate two concurrent syncs for the same feed (e.g. a sync
|
||||||
|
// button press racing a page-reload sync) — each on its own
|
||||||
|
// connection/thread, released together so they genuinely overlap —
|
||||||
|
// both inserting the same item.
|
||||||
|
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||||
|
let handles: Vec<_> = (0..2)
|
||||||
|
.map(|_| {
|
||||||
|
let feed = feed.clone();
|
||||||
|
let item = item.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
barrier.wait();
|
||||||
|
create_feed_item(item, &feed, &mut connection)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for handle in handles {
|
||||||
|
handle.join().unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.load(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
1,
|
||||||
|
items.len(),
|
||||||
|
"concurrent syncs must not create duplicate feed items"
|
||||||
|
);
|
||||||
|
|
||||||
|
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_feed_item_strips_onerror_from_feed_image() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let new_user = NewUser::new(
|
||||||
|
format!("xss_test_{suffix}"),
|
||||||
|
format!("xss_{suffix}@example.test"),
|
||||||
|
"secret".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let user: User = diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_feed = NewFeed::new(
|
||||||
|
format!("XSS 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!("XSS article {suffix}")));
|
||||||
|
item.set_link(Some(format!("https://example.test/xss/{suffix}")));
|
||||||
|
item.set_content(Some(
|
||||||
|
r#"<img src="https://example.test/real.jpg" onerror="alert(1)"><p>text</p>"#
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
create_feed_item(item, &feed, &mut connection).unwrap();
|
||||||
|
|
||||||
|
let stored: FeedItem = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.first(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!stored.content.contains("onerror"));
|
||||||
|
assert!(!stored.content.contains("alert"));
|
||||||
|
assert!(stored.content.contains(r#"src="https://example.test/real.jpg""#));
|
||||||
|
|
||||||
|
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_strips_social_sharing_widget() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let new_user = NewUser::new(
|
||||||
|
format!("social_bar_test_{suffix}"),
|
||||||
|
format!("social_bar_{suffix}@example.test"),
|
||||||
|
"secret".to_string(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let user: User = diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_feed = NewFeed::new(
|
||||||
|
format!("Social bar 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!("Social bar article {suffix}")));
|
||||||
|
item.set_link(Some(format!("https://example.test/article/{suffix}")));
|
||||||
|
item.set_content(Some(
|
||||||
|
r#"<p>Article text</p>
|
||||||
|
<div id="article-social-bar" data-noprint="true">
|
||||||
|
<ul>
|
||||||
|
<li><a id="whatsapp" href="whatsapp://send?text=foo"> </a></li>
|
||||||
|
<li><a id="link_copy" onclick="copyToClipboard()"> </a>
|
||||||
|
<p>Link kopiert</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>"#
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
create_feed_item(item, &feed, &mut connection).unwrap();
|
||||||
|
|
||||||
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.load(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(1, items.len());
|
||||||
|
assert!(items[0].content.contains("Article text"));
|
||||||
|
assert!(!items[0].content.contains("Link kopiert"));
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ diesel::table! {
|
|||||||
email -> Varchar,
|
email -> Varchar,
|
||||||
password -> Varchar,
|
password -> Varchar,
|
||||||
unique_id -> Varchar,
|
unique_id -> Varchar,
|
||||||
|
token_version -> Int4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
#![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(),
|
||||||
|
)
|
||||||
|
.expect("failed to hash test user password");
|
||||||
|
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();
|
||||||
|
}
|
||||||
+76
-11
@@ -1,5 +1,6 @@
|
|||||||
use crate::database::establish_connection;
|
use crate::database::establish_connection;
|
||||||
use crate::diesel;
|
use crate::diesel;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::login::Login;
|
use crate::json_serialization::login::Login;
|
||||||
use crate::models::user::rss_user::User;
|
use crate::models::user::rss_user::User;
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
|
|||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
|
pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppError> {
|
||||||
let username_cred: String = credentials.username.clone();
|
let username_cred: String = credentials.username.clone();
|
||||||
let password: String = credentials.password.clone();
|
let password: String = credentials.password.clone();
|
||||||
|
|
||||||
@@ -15,31 +16,95 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
|
|||||||
|
|
||||||
let users: Vec<User> = users::table
|
let users: Vec<User> = users::table
|
||||||
.filter(username.eq(username_cred.as_str()))
|
.filter(username.eq(username_cred.as_str()))
|
||||||
.load::<User>(&mut connection)
|
.load::<User>(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if users.is_empty() {
|
if users.is_empty() {
|
||||||
return HttpResponse::NotFound().await.unwrap();
|
return Ok(HttpResponse::NotFound().finish());
|
||||||
} else if users.len() > 1 {
|
} else if users.len() > 1 {
|
||||||
log::error!(
|
log::error!(
|
||||||
"multiple user have the usernam: {}",
|
"multiple user have the usernam: {}",
|
||||||
credentials.username.clone()
|
credentials.username.clone()
|
||||||
);
|
);
|
||||||
return HttpResponse::Conflict().await.unwrap();
|
return Ok(HttpResponse::Conflict().finish());
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: &User = &users[0];
|
let user: &User = &users[0];
|
||||||
|
|
||||||
match user.clone().verify(password) {
|
match user.clone().verify(password)? {
|
||||||
true => {
|
true => {
|
||||||
log::info!("verified password successfully for user {}", user.id);
|
log::info!("verified password successfully for user {}", user.id);
|
||||||
let token: String = JwtToken::encode(user.clone().id);
|
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||||
HttpResponse::Ok()
|
Ok(HttpResponse::Ok()
|
||||||
.insert_header(("token", token))
|
.insert_header(("token", token))
|
||||||
.insert_header(("user_id", user.id))
|
.insert_header(("user_id", user.id))
|
||||||
.await
|
.finish())
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
false => HttpResponse::Unauthorized().await.unwrap(),
|
false => Ok(HttpResponse::Unauthorized().finish()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,62 @@
|
|||||||
pub async fn logout() -> String {
|
use actix_web::HttpResponse;
|
||||||
"logout view".to_string()
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::schema::users;
|
||||||
|
|
||||||
|
/// Invalidates every token previously issued for this user by bumping
|
||||||
|
/// `token_version` — the auth middleware rejects tokens whose `tv` claim no
|
||||||
|
/// longer matches the stored value.
|
||||||
|
pub async fn logout(auth_user: AuthUser) -> Result<HttpResponse, AppError> {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
|
||||||
|
diesel::update(users::table.find(auth_user.0))
|
||||||
|
.set(users::token_version.eq(users::token_version + 1))
|
||||||
|
.execute(&mut connection)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use super::logout;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::models::user::rss_user::User;
|
||||||
|
use crate::schema::users;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn logout_bumps_token_version() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let user_id = user.id;
|
||||||
|
let initial_version = user.token_version;
|
||||||
|
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.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());
|
||||||
|
|
||||||
|
let updated: User = users::table.find(user_id).first(&mut connection).unwrap();
|
||||||
|
assert_eq!(initial_version + 1, updated.token_version);
|
||||||
|
|
||||||
|
delete_user(&mut connection, user_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -1,3 +1,4 @@
|
|||||||
|
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
use actix_web::web::ServiceConfig;
|
use actix_web::web::ServiceConfig;
|
||||||
|
|
||||||
@@ -12,9 +13,19 @@ pub fn auth_factory(app: &mut ServiceConfig) {
|
|||||||
backend: true,
|
backend: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
app.route(
|
// Login is the only unauthenticated endpoint that checks a password, so
|
||||||
&base_path.define(String::from("/login")),
|
// it's the only one worth rate-limiting against brute-force/credential
|
||||||
web::post().to(login::login),
|
// stuffing. One request every 2s with a burst of 5 per IP.
|
||||||
|
let login_rate_limit = GovernorConfigBuilder::default()
|
||||||
|
.seconds_per_request(2)
|
||||||
|
.burst_size(5)
|
||||||
|
.finish()
|
||||||
|
.expect("valid governor rate-limit config");
|
||||||
|
|
||||||
|
app.service(
|
||||||
|
web::resource(base_path.define(String::from("/login")))
|
||||||
|
.wrap(Governor::new(&login_rate_limit))
|
||||||
|
.route(web::post().to(login::login)),
|
||||||
);
|
);
|
||||||
app.route(
|
app.route(
|
||||||
&base_path.define(String::from("/logout")),
|
&base_path.define(String::from("/logout")),
|
||||||
|
|||||||
@@ -1,25 +1,106 @@
|
|||||||
use crate::database::establish_connection;
|
use crate::database::establish_connection;
|
||||||
use crate::diesel;
|
use crate::diesel;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::new_user::NewUserSchema;
|
use crate::json_serialization::new_user::NewUserSchema;
|
||||||
use crate::models::user::new_user::NewUser;
|
use crate::models::user::new_user::{validate_password, NewUser};
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
|
pub async fn create(new_user: web::Json<NewUserSchema>) -> Result<HttpResponse, AppError> {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
let name: String = new_user.name.clone();
|
let name: String = new_user.name.clone();
|
||||||
let email: String = new_user.email.clone();
|
let email: String = new_user.email.clone();
|
||||||
let new_password: String = new_user.password.clone();
|
let new_password: String = new_user.password.clone();
|
||||||
|
|
||||||
let new_user = NewUser::new(name, email, new_password);
|
if let Err(message) = validate_password(&new_password) {
|
||||||
|
return Ok(HttpResponse::BadRequest().body(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_user = NewUser::new(name, email, new_password)?;
|
||||||
|
|
||||||
let insert_result = diesel::insert_into(users::table)
|
let insert_result = diesel::insert_into(users::table)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
.execute(&mut connection);
|
.execute(&mut connection);
|
||||||
|
|
||||||
match insert_result {
|
Ok(match insert_result {
|
||||||
Ok(_) => HttpResponse::Created().await.unwrap(),
|
Ok(_) => HttpResponse::Created().finish(),
|
||||||
Err(_) => HttpResponse::Conflict().await.unwrap(),
|
Err(_) => HttpResponse::Conflict().finish(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_short_password() {
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
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": format!("short_pw_{suffix}"),
|
||||||
|
"email": format!("short_{suffix}@example.test"),
|
||||||
|
"password": "abc"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::BAD_REQUEST, resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# --- builder ---
|
||||||
|
# Node 24 (active LTS): Node 20 is past EOL (April 2026), and vue-router 5's
|
||||||
|
# Babel 8 toolchain requires ^22.18.0 || >=24.11.0 (EBADENGINE on node 20).
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- runtime ---
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
+8
-1
@@ -3,9 +3,16 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="alternate icon" href="/favicon.ico">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||||
|
<meta name="theme-color" content="#1a8f5e">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RSS-Reader</title>
|
<title>RSS-Reader</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,400;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Raleway:wght@400;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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
+2921
-566
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -6,23 +6,26 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mozilla/readability": "^0.4.4",
|
"@mozilla/readability": "^0.6.0",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.17.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.5.35",
|
||||||
"vue-router": "^4.2.4",
|
"vue-router": "^5.1.0"
|
||||||
"vue-sessionstorage": "^1.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.3.2",
|
"@rushstack/eslint-patch": "^1.16.1",
|
||||||
"@vitejs/plugin-vue": "^4.3.1",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
"@vue/eslint-config-prettier": "^8.0.0",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"eslint": "^8.46.0",
|
"eslint": "^8.46.0",
|
||||||
"eslint-plugin-vue": "^9.16.1",
|
"eslint-plugin-vue": "^9.16.1",
|
||||||
"prettier": "^3.0.0",
|
"jsdom": "^29.1.1",
|
||||||
"vite": "^4.4.9"
|
"prettier": "^3.8.3",
|
||||||
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 228 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "RSS Reader",
|
||||||
|
"short_name": "RSS Reader",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#1a8f5e",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+9
-72
@@ -1,78 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { onMounted } from 'vue'
|
||||||
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import { useSettings } from './composables/useSettings.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const { applySettings } = useSettings()
|
||||||
|
onMounted(applySettings)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- <header> -->
|
<AppNav v-if="route.meta.requiresAuth" />
|
||||||
<!-- <div class="wrapper"> -->
|
|
||||||
<!-- <nav> -->
|
|
||||||
<!-- <p onclick="$refs.sync">Sync</p> -->
|
|
||||||
<!-- <p>Login</p> -->
|
|
||||||
<!-- <RouterLink to="/">Home</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/about">About</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
|
|
||||||
<!-- </nav> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
<!-- </header> -->
|
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
header {
|
|
||||||
line-height: 1.5;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
nav {
|
|
||||||
text-align: left;
|
|
||||||
margin-left: -1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { useFeeds } from '../composables/useFeeds'
|
||||||
|
|
||||||
|
// router/index.js's route definitions lazily import(...) the view components,
|
||||||
|
// which importing the real module would still be safe to do (the imports
|
||||||
|
// themselves aren't eagerly evaluated) — but mocking it keeps this test from
|
||||||
|
// depending on the app's actual routes and lets currentRoute/push be
|
||||||
|
// controlled directly.
|
||||||
|
vi.mock('../router', () => ({
|
||||||
|
default: {
|
||||||
|
currentRoute: { value: { name: 'feeds' } },
|
||||||
|
push: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import router from '../router'
|
||||||
|
import { handleUnauthorizedResponse } from '../httpAuthInterceptor'
|
||||||
|
|
||||||
|
describe('handleUnauthorizedResponse', () => {
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'abc123')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
router.currentRoute.value.name = 'feeds'
|
||||||
|
router.push.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => {
|
||||||
|
const error = { response: { status: 401 } }
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBeNull()
|
||||||
|
expect(localStorage.getItem('user-id')).toBeNull()
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBeNull()
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still clears the session on a 401 but does not push again when already on the login route', async () => {
|
||||||
|
router.currentRoute.value.name = 'login'
|
||||||
|
const error = { response: { status: 401 } }
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBeNull()
|
||||||
|
expect(router.push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => {
|
||||||
|
const error = { response: { status: 500 } }
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBe('abc123')
|
||||||
|
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
|
expect(router.push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => {
|
||||||
|
const error = new Error('network')
|
||||||
|
|
||||||
|
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
|
||||||
|
|
||||||
|
expect(localStorage.getItem('user-token')).toBe('abc123')
|
||||||
|
expect(router.push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
+28
-1
@@ -23,6 +23,13 @@
|
|||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
:root {
|
:root {
|
||||||
|
--headline-font-family: Glook, 'Courier New';
|
||||||
|
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
|
||||||
|
--headline-font-size-scale: 1;
|
||||||
|
--content-font-size-scale: 1;
|
||||||
|
--content-text-align: left;
|
||||||
|
--content-padding: 1rem;
|
||||||
|
|
||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
@@ -33,6 +40,13 @@
|
|||||||
--color-heading: var(--vt-c-text-light-1);
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
--color-text: var(--vt-c-text-light-1);
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--color-accent: hsla(160, 100%, 37%, 1);
|
||||||
|
--color-accent-hover: hsla(160, 100%, 37%, 0.2);
|
||||||
|
--color-accent-2: hsla(200, 90%, 45%, 1);
|
||||||
|
--color-accent-2-hover: hsla(200, 90%, 35%, 1);
|
||||||
|
--color-info: #3498db;
|
||||||
|
--color-info-text: white;
|
||||||
|
|
||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +61,9 @@
|
|||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
|
||||||
|
--color-accent-2: hsla(200, 90%, 65%, 1);
|
||||||
|
--color-accent-2-hover: hsla(200, 90%, 75%, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,13 +77,23 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Full-bleed article images use a `100vw`-based breakout, which can be
|
||||||
|
wider than the visible content area (scrollbar) and would otherwise
|
||||||
|
introduce a horizontal scrollbar. */
|
||||||
|
overflow-x: hidden;
|
||||||
|
/* Without this, a horizontal touch drag on content with nothing left to
|
||||||
|
scroll (true here, given overflow-x: hidden above) is claimed by
|
||||||
|
Chrome-on-Android's native swipe-to-go-back/forward gesture before our
|
||||||
|
own touchend listener (article-view swipe navigation) ever sees it —
|
||||||
|
the gesture is silently swallowed with no error, no touchend fires. */
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
transition: color 0.5s, background-color 0.5s;
|
transition: color 0.5s, background-color 0.5s;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
font-size: 15px;
|
font-size: clamp(14px, 2.5vw, 16px);
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|||||||
+73
-15
@@ -3,7 +3,8 @@
|
|||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 0.5rem;
|
||||||
|
padding-top: var(--app-nav-height, 4.5rem);
|
||||||
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
@@ -11,50 +12,107 @@
|
|||||||
a,
|
a,
|
||||||
.green {
|
.green {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
color: var(--color-accent);
|
||||||
transition: 0.4s;
|
transition: 0.4s;
|
||||||
}
|
}
|
||||||
|
.feed-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-actions p {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-actions p:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
background-color: #3498db;
|
background-color: var(--color-info);
|
||||||
color: white;
|
color: var(--color-info-text);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
max-width: min(90vw, 28rem);
|
||||||
|
overflow-wrap: break-word;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
@media (hover: hover) {
|
@media (hover: hover) {
|
||||||
a:hover {
|
a:hover {
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
background-color: var(--color-accent-hover);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-source {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1em 1em 0;
|
||||||
|
font-size: clamp(0.75rem, 2vw, 0.85rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Courier New';
|
font-family: var(--headline-font-family);
|
||||||
font-size: 22px;
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-accent-2);
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
padding: 1em;
|
padding: 0.25em 1em 1em;
|
||||||
|
min-height: 44px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title:hover {
|
||||||
|
color: var(--color-accent-2-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-content {
|
.feed-content {
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
font-family: var(--content-font-family);
|
||||||
font-size: 20px;
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
padding: 1em;
|
text-align: var(--content-text-align);
|
||||||
|
padding: 0 var(--content-padding) 1em;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-content p {
|
.feed-content p {
|
||||||
padding: 1em;
|
padding: 0.5em 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-content h3 {
|
.feed-content h3 {
|
||||||
padding: 1em;
|
padding: 0.5em 0;
|
||||||
font-size: 21px;
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 14px;
|
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
#app {
|
||||||
|
padding: 0.75rem;
|
||||||
|
padding-top: var(--app-nav-height, 4.5rem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
|
|
||||||
input {
|
input {
|
||||||
margin: 15px;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-mask {
|
.modal-mask {
|
||||||
@@ -16,10 +21,13 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-container {
|
.modal-container {
|
||||||
width: 300px;
|
width: clamp(280px, 90vw, 420px);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 20px 30px;
|
padding: 20px 30px;
|
||||||
background-color: #fff;
|
background-color: var(--color-background-soft);
|
||||||
|
color: var(--color-text);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
@@ -27,15 +35,26 @@ input {
|
|||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
color: #42b983;
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-default-button {
|
.modal-footer {
|
||||||
float: right;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer button {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import Modal from './modal/AddUrl.vue'
|
||||||
|
|
||||||
|
const showModal = ref(false)
|
||||||
|
const feeds = ref([])
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'user-token': localStorage.getItem('user-token'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeeds() {
|
||||||
|
const userId = localStorage.getItem('user-id')
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/v1/article/feeds/${userId}`, authHeaders())
|
||||||
|
feeds.value = response.data.feeds
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Failed to load feeds.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFeed(feedId) {
|
||||||
|
if (!window.confirm('Delete this feed and all its articles?')) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/v1/article/feed/${feedId}`, authHeaders())
|
||||||
|
feeds.value = feeds.value.filter(f => f.id !== feedId)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Failed to delete feed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadFeeds)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin">
|
||||||
|
<h1 class="admin__heading">Admin</h1>
|
||||||
|
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
|
||||||
|
<p v-if="error" class="admin__error">{{ error }}</p>
|
||||||
|
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||||
|
<ul v-else class="admin__list">
|
||||||
|
<li v-for="feed in feeds" :key="feed.id" class="admin__item">
|
||||||
|
<div class="admin__item-info">
|
||||||
|
<span class="admin__item-title">{{ feed.title }}</span>
|
||||||
|
<a :href="feed.url" class="admin__item-url" target="_blank" rel="noopener">{{ feed.url }}</a>
|
||||||
|
</div>
|
||||||
|
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
|
||||||
|
<template #header>
|
||||||
|
<h3>Add RSS Feed</h3>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__add:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__error,
|
||||||
|
.admin__empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item-url {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__delete:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useSettings } from '../composables/useSettings.js'
|
||||||
|
|
||||||
|
const {
|
||||||
|
headlineSizeScale,
|
||||||
|
contentSizeScale,
|
||||||
|
headlineFontKey,
|
||||||
|
contentFontKey,
|
||||||
|
SIZE_STEPS,
|
||||||
|
SIZE_LABELS,
|
||||||
|
HEADLINE_FONT_OPTIONS,
|
||||||
|
CONTENT_FONT_OPTIONS,
|
||||||
|
setHeadlineSize,
|
||||||
|
setContentSize,
|
||||||
|
setHeadlineFont,
|
||||||
|
setContentFont,
|
||||||
|
textAlignKey,
|
||||||
|
contentPadding,
|
||||||
|
TEXT_ALIGN_OPTIONS,
|
||||||
|
PADDING_STEPS,
|
||||||
|
PADDING_LABELS,
|
||||||
|
setTextAlign,
|
||||||
|
setContentPadding,
|
||||||
|
} = useSettings()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings">
|
||||||
|
<h1 class="settings__heading">Typography</h1>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Headline Size</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="(step, i) in SIZE_STEPS"
|
||||||
|
:key="step"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': headlineSizeScale === step }"
|
||||||
|
type="button"
|
||||||
|
@click="setHeadlineSize(step)"
|
||||||
|
>{{ SIZE_LABELS[i] }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Article Text Size</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="(step, i) in SIZE_STEPS"
|
||||||
|
:key="step"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': contentSizeScale === step }"
|
||||||
|
type="button"
|
||||||
|
@click="setContentSize(step)"
|
||||||
|
>{{ SIZE_LABELS[i] }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Headline Font</h2>
|
||||||
|
<select
|
||||||
|
class="settings__select"
|
||||||
|
:value="headlineFontKey"
|
||||||
|
@change="setHeadlineFont($event.target.value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="opt in HEADLINE_FONT_OPTIONS"
|
||||||
|
:key="opt.key"
|
||||||
|
:value="opt.key"
|
||||||
|
>{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Article Text Font</h2>
|
||||||
|
<select
|
||||||
|
class="settings__select"
|
||||||
|
:value="contentFontKey"
|
||||||
|
@change="setContentFont($event.target.value)"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="opt in CONTENT_FONT_OPTIONS"
|
||||||
|
:key="opt.key"
|
||||||
|
:value="opt.key"
|
||||||
|
>{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Text Alignment</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="opt in TEXT_ALIGN_OPTIONS"
|
||||||
|
:key="opt.key"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': textAlignKey === opt.key }"
|
||||||
|
type="button"
|
||||||
|
@click="setTextAlign(opt.key)"
|
||||||
|
>{{ opt.label }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="settings__section">
|
||||||
|
<h2 class="settings__section-title">Content Padding</h2>
|
||||||
|
<div class="settings__strip">
|
||||||
|
<button
|
||||||
|
v-for="(step, i) in PADDING_STEPS"
|
||||||
|
:key="step"
|
||||||
|
class="settings__btn"
|
||||||
|
:class="{ 'settings__btn--active': contentPadding === step }"
|
||||||
|
type="button"
|
||||||
|
@click="setContentPadding(step)"
|
||||||
|
>{{ PADDING_LABELS[i] }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings {
|
||||||
|
padding: 1.5rem 1rem 0.5rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__section {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__strip {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__btn {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__btn:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__btn--active {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-background);
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings__select:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||||
|
import FeedFilter from './modal/FeedFilter.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||||
|
|
||||||
|
const headerRef = ref(null)
|
||||||
|
const filterBtnRef = ref(null)
|
||||||
|
const filterModalOpen = ref(false)
|
||||||
|
|
||||||
|
function openFilterModal() {
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
filterModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
|
||||||
|
// so there's nothing to check here before applying it.
|
||||||
|
async function handleFilterClose(selection) {
|
||||||
|
filterModalOpen.value = false
|
||||||
|
await setFeedFilter(selection)
|
||||||
|
filterBtnRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||||
|
// back in only on scroll-up — never merely by proximity to the top. This is
|
||||||
|
// show/hide via `transform` (not the old resize behaviour) — the header is
|
||||||
|
// position:fixed, so translating it never reflows content. The app's
|
||||||
|
// programmatic scrolls resolve to sensible states without any top-of-page
|
||||||
|
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
|
||||||
|
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
|
||||||
|
// reveals the header that way; the list-view read-correction scrollBy moves
|
||||||
|
// only a few px → stays under the threshold and leaves a hidden header hidden.
|
||||||
|
const hidden = ref(false)
|
||||||
|
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
|
||||||
|
// When the feed list mutates itself (read-correction scrollBy + array-splice
|
||||||
|
// scroll anchoring) the page jumps *upward* without user intent. For this long
|
||||||
|
// after such a jump we gate only the reveal direction, so the jump can't pop
|
||||||
|
// the header back into view mid-read. Hiding stays allowed the whole time
|
||||||
|
// (the jump never scrolls down), so scrolling down still hides normally even
|
||||||
|
// while articles are being marked read. See lastProgrammaticScroll in useFeeds.
|
||||||
|
const PROGRAMMATIC_SUPPRESS_MS = 300
|
||||||
|
let lastY = 0
|
||||||
|
let accumulated = 0
|
||||||
|
|
||||||
|
function onScroll() {
|
||||||
|
const y = Math.max(0, window.scrollY)
|
||||||
|
|
||||||
|
// Keep the header visible while the menu is open (the dropdown is anchored
|
||||||
|
// to the header, so hiding it would slide the open menu off-screen).
|
||||||
|
if (menuOpen.value) {
|
||||||
|
hidden.value = false
|
||||||
|
accumulated = 0
|
||||||
|
lastY = y
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = y - lastY
|
||||||
|
lastY = y
|
||||||
|
// Reset the accumulator whenever direction flips, so the threshold is
|
||||||
|
// measured from the last turning point (not from page load).
|
||||||
|
if ((delta > 0) !== (accumulated > 0)) accumulated = 0
|
||||||
|
accumulated += delta
|
||||||
|
|
||||||
|
// Hiding (scroll-down) is always allowed. Revealing (scroll-up) is gated for
|
||||||
|
// a short window after a programmatic list update, whose induced jump is
|
||||||
|
// upward and would otherwise pop the header back into view mid-read. A
|
||||||
|
// genuine scroll-up reveals once the window has elapsed.
|
||||||
|
const afterProgrammatic = performance.now() - lastProgrammaticScroll.value < PROGRAMMATIC_SUPPRESS_MS
|
||||||
|
if (accumulated > REVEAL_THRESHOLD) hidden.value = true // scrolling down
|
||||||
|
else if (accumulated < -REVEAL_THRESHOLD && !afterProgrammatic) hidden.value = false // scrolling up
|
||||||
|
}
|
||||||
|
|
||||||
|
let ticking = false
|
||||||
|
function onScrollRaf() {
|
||||||
|
if (ticking) return
|
||||||
|
ticking = true
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
onScroll()
|
||||||
|
ticking = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
|
||||||
|
// the fixed header isn't hidden behind it at scroll position 0. The header is
|
||||||
|
// a fixed size, so this is measured once on mount and never changes.
|
||||||
|
const h = headerRef.value?.getBoundingClientRect().height ?? 0
|
||||||
|
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
||||||
|
|
||||||
|
lastY = Math.max(0, window.scrollY)
|
||||||
|
window.addEventListener('scroll', onScrollRaf, { passive: true })
|
||||||
|
document.addEventListener('click', onDocumentClick)
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onScrollRaf)
|
||||||
|
document.removeEventListener('click', onDocumentClick)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||||
|
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
menuOpen.value = !menuOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() {
|
||||||
|
menuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The open menu is a thin absolutely-positioned strip under the header, so its
|
||||||
|
// `@click.self` only catches clicks on that strip — a click anywhere else on
|
||||||
|
// the page never reaches it. This document-level listener closes the menu on
|
||||||
|
// any outside click. The hamburger is excluded (it has its own toggle, so an
|
||||||
|
// opening click mustn't immediately re-close), and clicks inside the menu are
|
||||||
|
// left to the menu items' own handlers.
|
||||||
|
function onDocumentClick(event) {
|
||||||
|
if (!menuOpen.value) return
|
||||||
|
const target = event.target
|
||||||
|
if (target.closest?.('.app-nav__hamburger') || target.closest?.('.app-nav__menu')) return
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await logoutSession()
|
||||||
|
closeMenu()
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSync() {
|
||||||
|
sync()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMarkAllRead() {
|
||||||
|
markAllRead()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleViewMode() {
|
||||||
|
toggleViewMode()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleLayout() {
|
||||||
|
toggleLayout()
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
|
<div class="app-nav__wrapper">
|
||||||
|
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||||
|
<span
|
||||||
|
v-if="isSyncing"
|
||||||
|
class="app-nav__sync-spinner"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
v-if="onFeedsPage && feedTitles.length"
|
||||||
|
ref="filterBtnRef"
|
||||||
|
class="app-nav__filter-btn"
|
||||||
|
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
:aria-expanded="filterModalOpen"
|
||||||
|
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
|
||||||
|
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
|
||||||
|
@click="openFilterModal"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<Teleport to="body">
|
||||||
|
<FeedFilter
|
||||||
|
:show="filterModalOpen"
|
||||||
|
:titles="feedTitles"
|
||||||
|
:counts="displayedFeedUnreadCounts"
|
||||||
|
:selected="feedFilter"
|
||||||
|
@close="handleFilterClose"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
<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>
|
||||||
|
<template v-if="onFeedsPage">
|
||||||
|
<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="handleMarkAllRead">Mark all as read</button>
|
||||||
|
</template>
|
||||||
|
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</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: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--color-background);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav--hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__unread {
|
||||||
|
font-weight: normal;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__sync-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
animation: app-nav-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-nav-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-nav__sync-spinner {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__filter-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__filter-btn:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__filter-btn--active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__hamburger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
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: 0.5rem 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 axios from 'axios'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
const loginData = {
|
|
||||||
"username": username.value,
|
|
||||||
"password": password.value,
|
|
||||||
}
|
|
||||||
const jsonData = JSON.stringify(loginData)
|
|
||||||
console.log('test')
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('login/rss', jsonData, {
|
const response = await axios.post('/api/v1/auth/login', {
|
||||||
|
username: username.value,
|
||||||
|
password: password.value,
|
||||||
|
}, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json', // Set the content type to JSON
|
'Content-Type': 'application/json',
|
||||||
'crossDomain': true,
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Credentials': true,
|
|
||||||
'strict-origin-when-cross-origin': false
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle the response data here
|
|
||||||
console.log('Response:', response.data);
|
|
||||||
|
|
||||||
// You can also access the HTTP status code
|
|
||||||
console.log('HTTP Status Code:', response.status);
|
|
||||||
|
|
||||||
if (response.status == 200) {
|
if (response.status == 200) {
|
||||||
let token = response.headers.token
|
localStorage.setItem("user-token", response.headers.token)
|
||||||
let user_id = response.headers.user_id
|
localStorage.setItem("user-id", response.headers.user_id)
|
||||||
localStorage.setItem("user-token", token)
|
router.push({ name: 'feeds' })
|
||||||
localStorage.setItem("user-id", user_id)
|
|
||||||
sessionStorage.setItem("user-id", user_id)
|
|
||||||
sessionStorage.setItem("user-token", token)
|
|
||||||
router.push({ name: 'about' })
|
|
||||||
}
|
}
|
||||||
// Handle success
|
} catch (err) {
|
||||||
} catch (error) {
|
console.error('Login failed:', err)
|
||||||
// Handle any errors here
|
error.value = 'Login failed. Please check your username and password.'
|
||||||
console.error('Error:', error);
|
|
||||||
}
|
}
|
||||||
// Implement your login logic here (e.g., send a request to your backend)
|
|
||||||
// If login is successful, you can redirect the user to the dashboard:
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="login-page">
|
||||||
<h1>Login Page</h1>
|
<h1>Login</h1>
|
||||||
<form @submit.prevent="login">
|
<form @submit.prevent="login">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username/Email:</label>
|
<label for="username">Username/Email:</label>
|
||||||
@@ -62,7 +45,39 @@ async function login() {
|
|||||||
<label for="password">Password:</label>
|
<label for="password">Password:</label>
|
||||||
<input v-model="password" type="password" id="password" name="password" required />
|
<input v-model="password" type="password" id="password" name="password" required />
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="error" class="login-error">{{ error }}</p>
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
max-width: 420px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
+510
-153
@@ -1,190 +1,547 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, unref, onMounted, nextTick } from 'vue';
|
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
import { Readability } from '@mozilla/readability';
|
|
||||||
import Modal from './modal/AddUrl.vue';
|
|
||||||
|
|
||||||
const showMessage = ref(false)
|
const {
|
||||||
const feeds = ref([]);
|
feeds,
|
||||||
const message = ref('')
|
showMessage,
|
||||||
const showModal = ref(false)
|
message,
|
||||||
|
viewMode,
|
||||||
|
currentIndex,
|
||||||
|
layout,
|
||||||
|
nextArticle,
|
||||||
|
prevArticle,
|
||||||
|
fetchData,
|
||||||
|
sync,
|
||||||
|
getReadable,
|
||||||
|
disconnectObserver,
|
||||||
|
setInitialLoad,
|
||||||
|
showMessageForXSeconds,
|
||||||
|
markCurrentArticleRead,
|
||||||
|
} = useFeeds()
|
||||||
|
|
||||||
async function getReadable(feed, index) {
|
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||||
try {
|
|
||||||
const response = await axios.post("feeds/read", {
|
const shareLabel = navigator.share ? 'Share' : 'Copy link'
|
||||||
url: feed.url
|
|
||||||
},
|
function scrollToNextArticle() {
|
||||||
{
|
const articles = document.querySelectorAll('#article .observe')
|
||||||
headers: {
|
const threshold = window.scrollY + 1
|
||||||
'Content-Type': 'application/json',
|
for (const el of articles) {
|
||||||
'user-token': localStorage.getItem("user-token")
|
if (el.offsetTop > threshold) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
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 markRead(id) {
|
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
|
||||||
try {
|
// app state, so it's kept as plain component-local variables rather than in
|
||||||
const response = await axios.put("feeds/read/" + id,
|
// useFeeds() — nothing outside this component needs it.
|
||||||
null,
|
let touchStartX = null
|
||||||
{
|
let touchStartY = null
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
||||||
'user-token': localStorage.getItem("user-token")
|
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
||||||
|
|
||||||
|
function onArticleTouchStart(event) {
|
||||||
|
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
|
||||||
|
// horizontally-scrollable code block (.article-feature__content :deep(pre))
|
||||||
|
// — those should scroll the block, not page the article.
|
||||||
|
if (event.touches.length !== 1 || event.target.closest('pre')) {
|
||||||
|
touchStartX = null
|
||||||
|
touchStartY = null
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
touchStartX = event.touches[0].clientX
|
||||||
|
touchStartY = event.touches[0].clientY
|
||||||
}
|
}
|
||||||
)
|
|
||||||
console.log(response.status)
|
function onArticleTouchEnd(event) {
|
||||||
} catch (error) {
|
if (touchStartX === null) return
|
||||||
console.log(error)
|
const touch = event.changedTouches[0]
|
||||||
|
const deltaX = touch.clientX - touchStartX
|
||||||
|
const deltaY = touch.clientY - touchStartY
|
||||||
|
touchStartX = null
|
||||||
|
touchStartY = null
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
|
||||||
|
|
||||||
|
if (deltaX < 0) {
|
||||||
|
nextArticle()
|
||||||
|
} else {
|
||||||
|
prevArticle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessageForXSeconds(text, seconds) {
|
// Small images (icons, logos, ...) look bad stretched to the full-bleed
|
||||||
message.value = text;
|
// width used for readable article images — leave them at their natural size
|
||||||
showMessage.value = true;
|
// instead. Intrinsic size is only known once the image has loaded, so check
|
||||||
|
// on load (or immediately if it's already cached/complete).
|
||||||
|
const SMALL_IMAGE_THRESHOLD = 200
|
||||||
|
|
||||||
// Set a timeout to hide the message after x seconds
|
function markSmallImages() {
|
||||||
setTimeout(() => {
|
document.querySelectorAll('.article-feature__content--readable img, .feed-content--readable img').forEach(img => {
|
||||||
showMessage.value = false;
|
const checkSize = () => {
|
||||||
message.value = '';
|
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
|
||||||
}, seconds * 1000); // Convert seconds to milliseconds
|
img.classList.add('article-feature__image--small')
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
});
|
|
||||||
response.data.feeds.forEach(feed => {
|
|
||||||
feeds.value.push(...feed.items);
|
|
||||||
});
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (img.complete) {
|
||||||
let observer; // Declare observer outside the setup function
|
checkSize()
|
||||||
|
} else {
|
||||||
function setupIntersectionObserver() {
|
img.addEventListener('load', checkSize, { once: true })
|
||||||
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) {
|
watch(() => feeds.value[currentIndex.value]?.content, async () => {
|
||||||
const array = unref(feeds);
|
await nextTick()
|
||||||
array.splice(index, 1);
|
markSmallImages()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadReadable(feed, index) {
|
||||||
|
await getReadable(feed, index)
|
||||||
|
await nextTick()
|
||||||
|
markSmallImages()
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialLoad = false
|
async function shareUrl(url) {
|
||||||
onMounted(() => {
|
if (navigator.share) {
|
||||||
initialLoad = false
|
await navigator.share({ url })
|
||||||
fetchData().await
|
} else {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
showMessageForXSeconds('Link copied.', 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectObserver()
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
setInitialLoad(false)
|
||||||
|
await fetchData()
|
||||||
|
// viewMode persists across reloads (see useFeeds.js) — if we're booting
|
||||||
|
// straight into article view, the article now on screen needs the same
|
||||||
|
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
|
||||||
|
// give it when reached by in-session navigation.
|
||||||
|
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||||
|
sync(true)
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
initialLoad = true
|
setInitialLoad(true)
|
||||||
console.log('set to true')
|
console.log('set to true')
|
||||||
}, 2000);
|
}, 2000);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
|
||||||
<div class="wrapper">
|
|
||||||
<nav>
|
|
||||||
<p @click="sync">Sync</p>
|
|
||||||
<!-- <p @click="updateShow(true)">Add RSS</p> -->
|
|
||||||
<p @click="showModal = true">Add RSS</p>
|
|
||||||
<!-- <RouterLink to="/">Home</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/about">About</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- use the modal component, pass in the prop -->
|
|
||||||
<modal :show="showModal" @close="showModal = false">
|
|
||||||
<template #header>
|
|
||||||
<h3>Add RSS Feed</h3>
|
|
||||||
</template>
|
|
||||||
</modal>
|
|
||||||
</Teleport>
|
|
||||||
<div>
|
<div>
|
||||||
<h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
|
|
||||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
<div v-if="showMessage" class="message">{{ message }}</div>
|
||||||
<div id='article' class='article'>
|
|
||||||
<p v-if="feeds.length == 0">No unread articles.</p>
|
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
|
||||||
<template v-for="( feed, index ) in feeds ">
|
<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 " :key="feed.id">
|
||||||
<div v-bind:id="index" class="observe">
|
<div v-bind:id="index" class="observe">
|
||||||
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
<p class="feed-source">{{ feed.feedTitle }}</p>
|
||||||
|
<h2 @click="loadReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
||||||
<h3>{{ feed.timestamp }}</h3>
|
<h3>{{ feed.timestamp }}</h3>
|
||||||
<p class="feed-content" v-html='feed.content'></p>
|
<p 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" :class="{ 'feed-content--readable': feed.readable }" v-html='feed.content'></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<button
|
||||||
|
v-if="feeds.length"
|
||||||
|
type="button"
|
||||||
|
class="article-nav__btn list-skip-btn"
|
||||||
|
aria-label="Skip to next article"
|
||||||
|
@click="scrollToNextArticle"
|
||||||
|
>↓</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
|
||||||
|
<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>
|
||||||
|
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
|
||||||
|
component forces a synchronous whole-document reflow
|
||||||
|
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
|
||||||
|
every enter/leave, regardless of the app's own CSS — confirmed (via
|
||||||
|
a DevTools performance trace) to be what was corrupting the header's
|
||||||
|
native <select>/<button> rendering during article paging. -->
|
||||||
|
<article v-else class="article-feature" :key="currentIndex">
|
||||||
|
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
|
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||||
|
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||||
|
<p 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="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-skip-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observe {
|
||||||
|
scroll-margin-top: var(--app-nav-height, 4.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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-content--readable :deep(img),
|
||||||
|
.feed-content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
margin-left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content--readable :deep(img.article-feature__image--small) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.feed-content--readable :deep(img),
|
||||||
|
.feed-content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1em;
|
||||||
|
padding-bottom: 5rem;
|
||||||
|
/* Fill the viewport below the fixed header even when the article content
|
||||||
|
is short, so swipe-to-navigate works anywhere on screen instead of only
|
||||||
|
over the (possibly small) content area. dvh is the accurate unit on
|
||||||
|
mobile, where this matters most; vh is the fallback for browsers
|
||||||
|
without dvh support. */
|
||||||
|
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
|
||||||
|
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__source {
|
||||||
|
margin: 0 0 0.5em;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: clamp(0.75rem, 2vw, 0.85rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__title {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-family: var(--headline-font-family);
|
||||||
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-accent-2);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__title:hover {
|
||||||
|
color: var(--color-accent-2-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__meta {
|
||||||
|
margin: 0.75em 0 1.5em;
|
||||||
|
padding: 0 1rem 1.5em;
|
||||||
|
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||||
|
font-weight: normal;
|
||||||
|
opacity: 0.55;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature .feed-original-link {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content {
|
||||||
|
padding: 0 var(--content-padding);
|
||||||
|
text-align: var(--content-text-align);
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
|
line-height: 1.75;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(p) {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(h3) {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(img),
|
||||||
|
.article-feature__content :deep(video) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content--readable :deep(img),
|
||||||
|
.article-feature__content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
margin-left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small images (icons, logos, ...) keep their natural size instead of being
|
||||||
|
stretched to the full-bleed width. */
|
||||||
|
.article-feature__content--readable :deep(img.article-feature__image--small) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On desktop the viewport is much wider than the article column, so the
|
||||||
|
full-bleed 100vw treatment above would blow images up far beyond their
|
||||||
|
natural resolution. Keep them at natural size, centered in the text. */
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.article-feature__content--readable :deep(img),
|
||||||
|
.article-feature__content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(a) {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(blockquote) {
|
||||||
|
margin: 1.5em 0;
|
||||||
|
padding: 1em 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(pre) {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1em;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(code) {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(figcaption) {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,112 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AdminFeeds from '../AdminFeeds.vue'
|
||||||
|
import AddUrl from '../modal/AddUrl.vue'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
describe('AdminFeeds', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'test-token')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each mount renders the AddUrl modal via <Teleport to="body">, which
|
||||||
|
// survives outside the wrapper's own element — unmount every mounted
|
||||||
|
// instance so a leftover doesn't bleed into a later test's document.body.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountAdmin() {
|
||||||
|
const wrapper = mount(AdminFeeds)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetches and renders the feed list on mount', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
|
||||||
|
expect(wrapper.text()).toContain('Feed A')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the empty state when there are no feeds', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error message when the feed list fails to load', async () => {
|
||||||
|
axios.get.mockRejectedValueOnce(new Error('Network Error'))
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Failed to load feeds.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a feed after confirmation', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
|
||||||
|
})
|
||||||
|
axios.delete.mockResolvedValueOnce({ status: 204 })
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.admin__delete').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the add-feed modal from the Add RSS button', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.admin__add').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reloads the feed list when the modal reports a feed was added', async () => {
|
||||||
|
axios.get
|
||||||
|
.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
|
||||||
|
|
||||||
|
const wrapper = mountAdmin()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('No feeds added yet.')
|
||||||
|
|
||||||
|
await wrapper.findComponent(AddUrl).vm.$emit('added')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wrapper.text()).toContain('Feed B')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AppNav from '../AppNav.vue'
|
||||||
|
import FeedFilter from '../modal/FeedFilter.vue'
|
||||||
|
import { useFeeds } from '../../composables/useFeeds'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
// jsdom does not implement IntersectionObserver, but AppNav sets one up on mount
|
||||||
|
// to track whether the list view's title is scrolled into view.
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
|
describe('AppNav', () => {
|
||||||
|
let router
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.setItem('user-token', 'abc123')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
|
||||||
|
feeds.value = []
|
||||||
|
allItems.value = []
|
||||||
|
feedFilter.value = null
|
||||||
|
// Module-singleton state: a prior test may have stamped this via a mocked
|
||||||
|
// performance.now(); reset it so the scroll-driven reveal gate starts clean.
|
||||||
|
lastProgrammaticScroll.value = 0
|
||||||
|
showMessage.value = false
|
||||||
|
message.value = ''
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unmount every AppNav mounted via mountNav() after each test so mounted
|
||||||
|
// instances (and their router/menu listeners) don't pile up across the file.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountNav(options = { global: { plugins: [router] } }) {
|
||||||
|
const wrapper = mount(AppNav, options)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
|
async function mountWithMenuOpen() {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
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 = mountNav()
|
||||||
|
|
||||||
|
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('closes the menu on a click outside of it', async () => {
|
||||||
|
const wrapper = await mountWithMenuOpen()
|
||||||
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
|
||||||
|
|
||||||
|
// A click anywhere outside the menu strip (e.g. on page content) closes it.
|
||||||
|
document.body.dispatchEvent(new Event('click', { bubbles: true }))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
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('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 () => {
|
||||||
|
// The badge is the global unread total, sourced from the master list
|
||||||
|
// (allItems) so it stays correct regardless of any active feed filter.
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.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 = mountNav()
|
||||||
|
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 { allItems } = useFeeds()
|
||||||
|
allItems.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 = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides the unread count when there are no articles', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens a feed filter modal listing "All feeds" plus one entry per distinct feed', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
|
||||||
|
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-01' },
|
||||||
|
{ id: 3, feedTitle: 'Feed A', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
|
||||||
|
const modal = wrapper.findComponent(FeedFilter)
|
||||||
|
expect(modal.props('show')).toBe(true)
|
||||||
|
// Distinct titles, sorted alphabetically, with per-feed unread counts.
|
||||||
|
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
|
||||||
|
expect(modal.props('counts')).toEqual({ 'Feed A': 2, 'Feed B': 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render the feed filter when there are no feeds', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
|
||||||
|
const { allItems, feeds, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
const modal = wrapper.findComponent(FeedFilter)
|
||||||
|
modal.vm.$emit('close', ['Feed A'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A'])
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1])
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
modal.vm.$emit('close', null)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
expect(feeds.value.map(f => f.id)).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the selected feed\'s unread count in the title when a filter is active', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
{ id: 3, feedTitle: 'Feed B', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// No filter → global total (3).
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// Filtered → unread in Feed A only (2).
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums the unread count across every selected feed in the title badge', async () => {
|
||||||
|
const { allItems, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
{ id: 3, feedTitle: 'Feed C', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
|
||||||
|
]
|
||||||
|
feedFilter.value = ['Feed A', 'Feed B']
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the filter button active and swaps its label/title when a filter is set', async () => {
|
||||||
|
const { allItems, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const button = wrapper.find('.app-nav__filter-btn')
|
||||||
|
|
||||||
|
expect(button.classes()).not.toContain('app-nav__filter-btn--active')
|
||||||
|
expect(button.attributes('aria-label')).toBe('Filter feeds')
|
||||||
|
expect(button.attributes('title')).toBe('Filter feeds')
|
||||||
|
|
||||||
|
feedFilter.value = ['Feed A']
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(button.classes()).toContain('app-nav__filter-btn--active')
|
||||||
|
expect(button.attributes('aria-label')).toBe('Filter feeds (active)')
|
||||||
|
expect(button.attributes('title')).toBe('Filter feeds (active)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tracks the modal\'s open state via aria-expanded on the filter button', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const button = wrapper.find('.app-nav__filter-btn')
|
||||||
|
|
||||||
|
expect(button.attributes('aria-expanded')).toBe('false')
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
expect(button.attributes('aria-expanded')).toBe('true')
|
||||||
|
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(button.attributes('aria-expanded')).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-apply the filter when the emitted selection is order-insensitively unchanged', async () => {
|
||||||
|
const { allItems, feedFilter } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
|
]
|
||||||
|
feedFilter.value = ['Feed A', 'Feed B']
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed B', 'Feed A'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
// setFeedFilter() always scrollTo(0, 0)s — its absence proves the
|
||||||
|
// no-op path (sameFeedSelection) was taken instead.
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not re-apply the filter when null is emitted while already unfiltered', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
const scrollToSpy = vi.spyOn(window, 'scrollTo')
|
||||||
|
|
||||||
|
await wrapper.find('.app-nav__filter-btn').trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(scrollToSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns focus to the filter button once the modal closes', async () => {
|
||||||
|
const { allItems } = useFeeds()
|
||||||
|
allItems.value = [
|
||||||
|
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const wrapper = mountNav({ attachTo: document.body, global: { plugins: [router] } })
|
||||||
|
await flushPromises()
|
||||||
|
const button = wrapper.find('.app-nav__filter-btn')
|
||||||
|
|
||||||
|
await button.trigger('click')
|
||||||
|
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(button.element)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sync spinner', () => {
|
||||||
|
it('does not render the spinner, and reserves no space for it, when idle', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders while a sync triggered through the composable is in flight, and disappears once it resolves', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const { sync } = useFeeds()
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders while syncing even on a page where the feed filter itself does not render', async () => {
|
||||||
|
await router.push('/login')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const { sync } = useFeeds()
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('scroll-driven show/hide', () => {
|
||||||
|
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
||||||
|
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
||||||
|
// Vitest gotcha, avoid bare fake timers here — they'd clobber this stub.
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset scroll position so each mount's lastY baseline starts at 0.
|
||||||
|
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
|
||||||
|
// getBoundingClientRect() is all-zero in jsdom; give the header a real
|
||||||
|
// height so onMounted()'s one-time measurement (used for the
|
||||||
|
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
|
||||||
|
// This is a prototype-wide spy (scoped to this describe block only, restored in
|
||||||
|
// afterEach), so it returns a full DOMRect-shaped object rather than a
|
||||||
|
// partial one — no test here happens to call getBoundingClientRect() on
|
||||||
|
// anything but the header, but a bare `{ height: 50 }` would silently
|
||||||
|
// hand back `undefined` for top/left/width/etc. to any code that did.
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollTo(y) {
|
||||||
|
Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true })
|
||||||
|
window.dispatchEvent(new Event('scroll'))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('hides the header when scrolling down past the threshold', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveals the header again when scrolling back up past the threshold', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
scrollTo(150)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reveals the header on a large upward jump that lands near the top', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
|
||||||
|
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
|
||||||
|
scrollTo(10)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not reveal the header merely for landing near the top', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Hide it with a jump just past the header's own height (50px, mocked
|
||||||
|
// above) plus the reveal threshold.
|
||||||
|
scrollTo(62)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Move back to right at the header's own height — within the "near the
|
||||||
|
// top" zone the old code always revealed for — but the upward delta
|
||||||
|
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
|
||||||
|
// hidden: proximity to the top is no longer sufficient on its own.
|
||||||
|
scrollTo(50)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
||||||
|
const { markProgrammaticScroll } = useFeeds()
|
||||||
|
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||||
|
const wrapper = mountNav()
|
||||||
|
|
||||||
|
// Hide it first via a normal scroll-down (no programmatic flag active).
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// A read-correction flags a programmatic scroll, then the page jumps
|
||||||
|
// upward. Within the window that upward jump must NOT reveal the header.
|
||||||
|
markProgrammaticScroll() // records lastProgrammaticScroll = 1000
|
||||||
|
nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Still allows hiding on scroll-down even while the flag is active.
|
||||||
|
scrollTo(500)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Once the window elapses, a genuine scroll-up reveals it again.
|
||||||
|
nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window
|
||||||
|
scrollTo(450)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not toggle on sub-threshold jitter', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Start well below the top so the "near the top" guard doesn't apply.
|
||||||
|
scrollTo(300)
|
||||||
|
await nextTick()
|
||||||
|
// Reveal first so we're testing that small moves don't hide it.
|
||||||
|
scrollTo(260)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
scrollTo(268) // +8px, under the 12px threshold
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
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,536 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
// Shared by the swipe-navigation tests below, which all start from the same
|
||||||
|
// two-article article view.
|
||||||
|
function twoArticleFeed() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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, viewMode, currentIndex, layout } = useFeeds()
|
||||||
|
feeds.value = []
|
||||||
|
showMessage.value = false
|
||||||
|
message.value = ''
|
||||||
|
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 is also hit by the sync triggered on mount, so branch on the
|
||||||
|
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
||||||
|
axios.post.mockImplementation((url) => {
|
||||||
|
if (url === '/api/v1/article/sync') {
|
||||||
|
return Promise.resolve({ status: 200 })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ 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('keeps a link to the original article visible after the readable version is loaded', async () => {
|
||||||
|
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 is also hit by the sync triggered on mount, so branch on the
|
||||||
|
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
||||||
|
axios.post.mockImplementation((url) => {
|
||||||
|
if (url === '/api/v1/article/sync') {
|
||||||
|
return Promise.resolve({ status: 200 })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ 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()
|
||||||
|
|
||||||
|
const linkAfter = wrapper.find('.feed-original-link a')
|
||||||
|
expect(linkAfter.exists()).toBe(true)
|
||||||
|
expect(linkAfter.attributes('href')).toBe('https://example.test/1')
|
||||||
|
})
|
||||||
|
|
||||||
|
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 .article-feature__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.
|
||||||
|
// (axios.post is also hit by the sync triggered on mount.)
|
||||||
|
expect(axios.post).not.toHaveBeenCalledWith('/api/v1/article/read', expect.anything(), expect.anything())
|
||||||
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.find('.article-single .article-feature__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(true)
|
||||||
|
|
||||||
|
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 .article-feature__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 .article-feature__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'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swipes left to navigate to the next article', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swipes right to navigate to the previous article', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not navigate on a mostly-vertical drag', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', {
|
||||||
|
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
|
||||||
|
})
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not navigate when a swipe starts inside a code block', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'My Feed',
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', 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 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not throw when swiping with an empty feed list', async () => {
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
useFeeds().toggleViewMode()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const article = wrapper.find('.article-single')
|
||||||
|
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
|
||||||
|
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('All caught up')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
import '@/assets/modal.css';
|
import '@/assets/modal.css';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
show: Boolean
|
show: Boolean
|
||||||
})
|
})
|
||||||
|
const emit = defineEmits(['close', 'added'])
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
@@ -15,7 +16,7 @@ async function save() {
|
|||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
console.log('saved ' + url.value)
|
console.log('saved ' + url.value)
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("feeds/add", {
|
const response = await axios.post("/api/v1/article/add", {
|
||||||
url: url.value,
|
url: url.value,
|
||||||
title: title.value,
|
title: title.value,
|
||||||
user_id: parseInt(localStorage.getItem("user-id"))
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
@@ -30,6 +31,7 @@ async function save() {
|
|||||||
|
|
||||||
console.log(response)
|
console.log(response)
|
||||||
output.value = 'saved successfully'
|
output.value = 'saved successfully'
|
||||||
|
emit('added')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error.message)
|
console.error(error.message)
|
||||||
output.value = error.message
|
output.value = error.message
|
||||||
@@ -44,7 +46,7 @@ async function save() {
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<slot name="header">Add RSS Feed</slot>
|
<slot name="header">Add RSS Feed</slot>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="save">
|
||||||
<label for="name">URL:</label>
|
<label for="name">URL:</label>
|
||||||
<input v-model="url" id="url" type="text" required />
|
<input v-model="url" id="url" type="text" required />
|
||||||
<label for="name">Title:</label>
|
<label for="name">Title:</label>
|
||||||
@@ -56,8 +58,8 @@ async function save() {
|
|||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<button type="submit" @click="save">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button class="modal-default-button" @click="$emit('close')">Close</button>
|
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup>
|
||||||
|
import '@/assets/modal.css';
|
||||||
|
import { ref, watch, onUnmounted, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: Boolean,
|
||||||
|
titles: { type: Array, default: () => [] },
|
||||||
|
counts: { type: Object, default: () => ({}) },
|
||||||
|
selected: { type: Array, default: null },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
|
// Component-local draft: seeded from `selected` each time the modal opens
|
||||||
|
// (including on initial mount if it's already open — see the `immediate`
|
||||||
|
// watcher below) and only reported back (via `close`) once it's dismissed —
|
||||||
|
// see AppNav.vue for how the emitted selection is applied.
|
||||||
|
const draft = ref(null)
|
||||||
|
const allFeedsCheckboxRef = ref(null)
|
||||||
|
|
||||||
|
function isChecked(title) {
|
||||||
|
return draft.value !== null && draft.value.includes(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleAll(event) {
|
||||||
|
if (event.target.checked) {
|
||||||
|
draft.value = null
|
||||||
|
} else {
|
||||||
|
// The selection is never allowed to be empty — "All feeds" is the
|
||||||
|
// fallback state, so it can't be unchecked without checking a feed first.
|
||||||
|
event.target.checked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onToggleFeed(title, checked) {
|
||||||
|
if (checked) {
|
||||||
|
draft.value = draft.value === null ? [title] : [...draft.value, title]
|
||||||
|
} else {
|
||||||
|
const remaining = (draft.value ?? []).filter(t => t !== title)
|
||||||
|
// Unchecking the last selected feed falls back to "All feeds" — see
|
||||||
|
// onToggleAll, the selection is never allowed to be empty.
|
||||||
|
draft.value = remaining.length > 0 ? remaining : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
emit('close', draft.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event) {
|
||||||
|
if (event.key === 'Escape') handleClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the modal is open, scrolling the page behind it would run through
|
||||||
|
// list view's IntersectionObserver and mark articles read out from under the
|
||||||
|
// user — lock it, and restore whatever it was before. `''` is a legitimate
|
||||||
|
// saved overflow value, so `isLocked` (not a null-check on the saved value)
|
||||||
|
// tracks whether there's actually anything to restore — otherwise unlocking
|
||||||
|
// on unmount without ever having locked would stomp a value this component
|
||||||
|
// never touched.
|
||||||
|
let previousBodyOverflow = ''
|
||||||
|
let isLocked = false
|
||||||
|
function lockBodyScroll() {
|
||||||
|
previousBodyOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
isLocked = true
|
||||||
|
}
|
||||||
|
function unlockBodyScroll() {
|
||||||
|
if (!isLocked) return
|
||||||
|
document.body.style.overflow = previousBodyOverflow
|
||||||
|
isLocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.show, async (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
draft.value = props.selected ? [...props.selected] : null
|
||||||
|
lockBodyScroll()
|
||||||
|
window.addEventListener('keydown', onKeydown)
|
||||||
|
await nextTick()
|
||||||
|
allFeedsCheckboxRef.value?.focus()
|
||||||
|
} else {
|
||||||
|
unlockBodyScroll()
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
unlockBodyScroll()
|
||||||
|
window.removeEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="modal">
|
||||||
|
<div v-if="show" class="modal-mask" @click.self="handleClose">
|
||||||
|
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="feed-filter-heading">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="feed-filter-heading">Filter feeds</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<label class="feed-filter__option">
|
||||||
|
<input
|
||||||
|
ref="allFeedsCheckboxRef"
|
||||||
|
type="checkbox"
|
||||||
|
:checked="draft === null"
|
||||||
|
@change="onToggleAll"
|
||||||
|
/>
|
||||||
|
All feeds
|
||||||
|
</label>
|
||||||
|
<label v-for="title in titles" :key="title" class="feed-filter__option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isChecked(title)"
|
||||||
|
@change="onToggleFeed(title, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
{{ title }} ({{ counts[title] ?? 0 }})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" @click="handleClose">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feed-filter__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-height: 44px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* modal.css's global `input` rule (display: block, width: 100%, min-height:
|
||||||
|
44px, ...) is meant for the text inputs in AddUrl.vue's form — reset it
|
||||||
|
here so checkboxes don't render full-width and oversized. */
|
||||||
|
.feed-filter__option input[type='checkbox'] {
|
||||||
|
display: inline-block;
|
||||||
|
width: auto;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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')
|
||||||
|
expect(wrapper.emitted('added')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
expect(wrapper.emitted('added')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
|
||||||
|
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('.modal-default-button').trigger('click')
|
||||||
|
|
||||||
|
expect(axios.post).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
import FeedFilter from '../FeedFilter.vue'
|
||||||
|
|
||||||
|
describe('FeedFilter', () => {
|
||||||
|
const previousBodyOverflow = document.body.style.overflow
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.style.overflow = previousBodyOverflow
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when show is false', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: ['Feed A'], counts: { 'Feed A': 2 }, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('.modal-mask').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders "All feeds" plus one checkbox per title, defaulting a missing count to 0', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: { 'Feed A': 2 }, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const options = wrapper.findAll('.feed-filter__option')
|
||||||
|
expect(options).toHaveLength(3)
|
||||||
|
expect(options[0].text()).toBe('All feeds')
|
||||||
|
expect(options[1].text()).toBe('Feed A (2)')
|
||||||
|
expect(options[2].text()).toBe('Feed B (0)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks "All feeds" and leaves every feed unchecked (but enabled) when nothing is selected', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[0].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.disabled).toBe(false)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checking a feed unchecks "All feeds"', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
|
||||||
|
await checkboxes[1].setValue(true)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checking a second feed keeps both checked, with "All feeds" still unchecked', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
|
||||||
|
await checkboxes[2].setValue(true)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[2].element.checked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unchecking the last selected feed falls back to "All feeds"', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
|
||||||
|
await checkboxes[1].setValue(false)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checking "All feeds" clears every selected feed', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[0].element.checked).toBe(false)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true)
|
||||||
|
|
||||||
|
await checkboxes[0].setValue(true)
|
||||||
|
|
||||||
|
expect(checkboxes[0].element.checked).toBe(true)
|
||||||
|
expect(checkboxes[1].element.checked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unchecking "All feeds" while it is active leaves it checked — the selection is never empty', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
const allCheckbox = wrapper.findAll('input[type="checkbox"]')[0]
|
||||||
|
|
||||||
|
await allCheckbox.setValue(false)
|
||||||
|
|
||||||
|
expect(allCheckbox.element.checked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close with the current draft when Done is clicked', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
await wrapper.findAll('input[type="checkbox"]')[1].setValue(true)
|
||||||
|
|
||||||
|
await wrapper.find('.modal-footer button').trigger('click')
|
||||||
|
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close with the current draft on Escape, but not again once closed', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[null]])
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits close on a backdrop click, but not on a click inside the modal container', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.find('.modal-header').trigger('click')
|
||||||
|
expect(wrapper.emitted('close')).toBeUndefined()
|
||||||
|
|
||||||
|
await wrapper.find('.modal-mask').trigger('click')
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[null]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reseeds the draft from `selected` on reopen, discarding an abandoned edit', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
// Edit the draft without confirming via Done/close.
|
||||||
|
await wrapper.findAll('input[type="checkbox"]')[2].setValue(true)
|
||||||
|
expect(wrapper.findAll('input[type="checkbox"]')[2].element.checked).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true) // Feed A, from `selected`
|
||||||
|
expect(checkboxes[2].element.checked).toBe(false) // Feed B, abandoned edit dropped
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locks body scroll while open and restores the prior value on close', async () => {
|
||||||
|
document.body.style.overflow = 'scroll'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: [], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: false })
|
||||||
|
expect(document.body.style.overflow).toBe('scroll')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores the prior body scroll value on unmount while still open', async () => {
|
||||||
|
document.body.style.overflow = 'auto'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: [], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
expect(document.body.style.overflow).toBe('auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the "All feeds" checkbox after opening', async () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
attachTo: document.body,
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.setProps({ show: true })
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(wrapper.findAll('input[type="checkbox"]')[0].element)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds the draft, locks body scroll, and wires Escape when mounted already open', async () => {
|
||||||
|
document.body.style.overflow = 'auto'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A', 'Feed B'], counts: {}, selected: ['Feed A'] },
|
||||||
|
})
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(document.body.style.overflow).toBe('hidden')
|
||||||
|
const checkboxes = wrapper.findAll('input[type="checkbox"]')
|
||||||
|
expect(checkboxes[1].element.checked).toBe(true) // Feed A, seeded from `selected`
|
||||||
|
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.emitted('close')).toEqual([[['Feed A']]])
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a pre-set body overflow untouched when mounted closed and then unmounted', () => {
|
||||||
|
document.body.style.overflow = 'scroll'
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: false, titles: [], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
|
||||||
|
expect(document.body.style.overflow).toBe('scroll')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders dialog accessibility attributes tied to the heading', () => {
|
||||||
|
const wrapper = mount(FeedFilter, {
|
||||||
|
props: { show: true, titles: ['Feed A'], counts: {}, selected: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialog = wrapper.find('.modal-container')
|
||||||
|
expect(dialog.attributes('role')).toBe('dialog')
|
||||||
|
expect(dialog.attributes('aria-modal')).toBe('true')
|
||||||
|
const labelledby = dialog.attributes('aria-labelledby')
|
||||||
|
expect(wrapper.find(`#${labelledby}`).text()).toBe('Filter feeds')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// loadPersistedFeedFilter() (see useFeeds.js) reads localStorage at module
|
||||||
|
// import time, not lazily — every case here has to vi.resetModules() and
|
||||||
|
// dynamically re-import so the module-scope ref actually re-initializes
|
||||||
|
// against the localStorage value set up beforehand. Mirrors the pattern in
|
||||||
|
// viewModeBoot.spec.js.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
|
describe('feedFilter boot persistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots with the persisted array when it is valid', async () => {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(['Feed A', 'Feed B']))
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into "All feeds" when the key is absent', async () => {
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into "All feeds" when the persisted array is empty', async () => {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify([]))
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['a plain object', JSON.stringify({ title: 'Feed A' })],
|
||||||
|
['a bare string', JSON.stringify('Feed A')],
|
||||||
|
])('boots into "All feeds" when the persisted value is not an array (%s)', async (_label, raw) => {
|
||||||
|
localStorage.setItem('feedFilter', raw)
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into "All feeds" when the persisted value is not valid JSON', async () => {
|
||||||
|
localStorage.setItem('feedFilter', 'not json')
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['numbers', [1]],
|
||||||
|
['null', [null]],
|
||||||
|
['a mix of strings and non-strings', ['a', 2]],
|
||||||
|
])('boots into "All feeds" when the array contains non-string elements (%s)', async (_label, value) => {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(value))
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { feedFilter } = useFeeds()
|
||||||
|
|
||||||
|
expect(feedFilter.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
|||||||
|
// Reproduces the "reload the tab while in article view" boot sequence:
|
||||||
|
// module-scope init must pick up the persisted viewMode, and RssFeeds'
|
||||||
|
// mount flow (fetchData + sync) must not reset it back to 'list'.
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
|
describe('viewMode boot persistence', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
localStorage.clear()
|
||||||
|
localStorage.setItem('user-token', 'test-token')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into article view when localStorage says so, surviving the mount fetch/sync', async () => {
|
||||||
|
localStorage.setItem('viewMode', 'article')
|
||||||
|
|
||||||
|
const axios = (await import('axios')).default
|
||||||
|
axios.get.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
title: 'My Feed',
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Article one', content: '<p>hi</p>', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
axios.post.mockResolvedValue({ status: 200 })
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
// Import AFTER setting storage — module-scope refs initialize at import.
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { viewMode } = useFeeds()
|
||||||
|
expect(viewMode.value).toBe('article')
|
||||||
|
|
||||||
|
const RssFeeds = (await import('../../components/RssFeeds.vue')).default
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(viewMode.value).toBe('article')
|
||||||
|
expect(wrapper.find('.article-single').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('#article').exists()).toBe(false)
|
||||||
|
|
||||||
|
// The article shown right after reload must be marked read immediately —
|
||||||
|
// not only once the user pages forward/back at least once.
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('boots into list view when the key is absent', async () => {
|
||||||
|
const axios = (await import('axios')).default
|
||||||
|
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
||||||
|
axios.post.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
const { useFeeds } = await import('../useFeeds')
|
||||||
|
const { viewMode } = useFeeds()
|
||||||
|
expect(viewMode.value).toBe('list')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,797 @@
|
|||||||
|
import { ref, computed, 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)
|
||||||
|
// `allItems` is the full flattened+sorted stream from the last fetch (the
|
||||||
|
// master); `feeds` is the currently displayed projection of it (all items, or a
|
||||||
|
// single feed when a filter is active). Every view/observer consumer operates on
|
||||||
|
// `feeds` — the filter layer only ever swaps what `feeds` points at, so none of
|
||||||
|
// the index-based observer / currentIndex logic has to know a filter exists.
|
||||||
|
const allItems = ref([]);
|
||||||
|
const feeds = ref([]);
|
||||||
|
// Selected feed titles, or null = all feeds. Persisted in localStorage (see
|
||||||
|
// setFeedFilter) so the selection survives a reload.
|
||||||
|
function loadPersistedFeedFilter() {
|
||||||
|
const raw = localStorage.getItem('feedFilter')
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return Array.isArray(parsed) && parsed.length > 0 && parsed.every(title => typeof title === 'string') ? parsed : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const feedFilter = ref(loadPersistedFeedFilter())
|
||||||
|
// Feed titles are specific to the account that was logged in — call this
|
||||||
|
// wherever a session ends (explicit logout, or a 401 dropping a stale/expired
|
||||||
|
// one in main.js) so they can't leak (in storage or in the still-live module
|
||||||
|
// singleton) into whichever account is active next on this device.
|
||||||
|
export function clearFeedFilter() {
|
||||||
|
localStorage.removeItem('feedFilter')
|
||||||
|
feedFilter.value = null
|
||||||
|
}
|
||||||
|
// Distinct feed titles present in the loaded (unread) items — the filter
|
||||||
|
// checkbox options. Every currently selected title is always kept in the
|
||||||
|
// list even once its feed runs out of unread items, so a checked feed never
|
||||||
|
// disappears out from under the user before they get a chance to uncheck it.
|
||||||
|
const feedTitles = computed(() => {
|
||||||
|
const titles = new Set(allItems.value.map(i => i.feedTitle))
|
||||||
|
for (const title of feedFilter.value ?? []) titles.add(title)
|
||||||
|
return [...titles].sort((a, b) => a.localeCompare(b))
|
||||||
|
})
|
||||||
|
// Unread count per feed title, shown next to each checkbox in the filter
|
||||||
|
// modal. Derived from the master (allItems) so article view's
|
||||||
|
// read-but-still-shown items are excluded. A title kept in feedTitles with no
|
||||||
|
// unread items left has no key here — render it as 0.
|
||||||
|
const feedUnreadCounts = computed(() => {
|
||||||
|
const counts = {}
|
||||||
|
for (const item of allItems.value) {
|
||||||
|
if (!item.read) counts[item.feedTitle] = (counts[item.feedTitle] ?? 0) + 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
// The header badge count: unread items in the selected feed when a filter is
|
||||||
|
// active, otherwise the global unread total. Derived from the master (allItems)
|
||||||
|
// rather than the displayed `feeds` so article view's read-but-still-shown
|
||||||
|
// items are excluded and the count matches the filter regardless of view.
|
||||||
|
const unreadCount = computed(() => {
|
||||||
|
if (!feedFilter.value) return allItems.value.filter(i => !i.read).length
|
||||||
|
const titles = new Set(feedFilter.value)
|
||||||
|
return allItems.value.filter(i => !i.read && titles.has(i.feedTitle)).length
|
||||||
|
})
|
||||||
|
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
|
||||||
|
// for the filter modal's per-feed checkbox counts, instead of binding
|
||||||
|
// feedUnreadCounts directly — by design, this stays frozen while paging
|
||||||
|
// through article view (markCurrentArticleRead marks one article read per
|
||||||
|
// page turn, which would otherwise recompute it live on every single swipe).
|
||||||
|
// The header's own unread badge stays bound to the live unreadCount (it
|
||||||
|
// should update in real time regardless of view). Everywhere else that marks
|
||||||
|
// articles read — list-view scrolling (handleIntersection), opening/changing
|
||||||
|
// the filter, syncing, mark-all-read, leaving article view — refreshes this
|
||||||
|
// snapshot immediately, so it only ever looks stale during article-view
|
||||||
|
// paging itself.
|
||||||
|
const displayedFeedUnreadCounts = ref({})
|
||||||
|
function refreshUnreadDisplay() {
|
||||||
|
displayedFeedUnreadCounts.value = feedUnreadCounts.value
|
||||||
|
}
|
||||||
|
const message = ref('')
|
||||||
|
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
|
||||||
|
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
|
||||||
|
// An IntersectionObserver always delivers an initial snapshot for every element
|
||||||
|
// it starts observing. If the observer (re)connects while the page is scrolled
|
||||||
|
// down — e.g. leaving a scrolled article view, an HMR module reload, or the
|
||||||
|
// post-splice re-setup in handleIntersection — that snapshot reports every
|
||||||
|
// article above the viewport as "not intersecting / scrolled past" and would
|
||||||
|
// mark them all read at once. So each setupIntersectionObserver() sets this and
|
||||||
|
// handleIntersection() drops exactly the first callback after a connect; only
|
||||||
|
// genuine scroll-driven exits mark read after that.
|
||||||
|
let skipNextObservation = false
|
||||||
|
|
||||||
|
// Count of fetchData()/sync() calls currently in flight — a refcount rather
|
||||||
|
// than a plain boolean for the same reason as pendingReadCounts below: sync()
|
||||||
|
// awaits its own trailing fetchData(), so one call nests inside the other, and
|
||||||
|
// fetchData() can also be invoked independently (RssFeeds.vue's boot reload)
|
||||||
|
// while a sync() elsewhere is still mid network round-trip. A bare boolean
|
||||||
|
// would have whichever call finishes first clear it out from under the other
|
||||||
|
// still-running one. Both rebuild allItems/feeds from the server, so the
|
||||||
|
// passive/paging mark-as-read paths — handleIntersection's scroll-marking and
|
||||||
|
// markCurrentArticleRead's display-marking — must not fire while any is in
|
||||||
|
// flight. markAllRead is a deliberate, already-confirmed action and is left
|
||||||
|
// ungated.
|
||||||
|
const activeSyncCount = ref(0)
|
||||||
|
function beginSync() {
|
||||||
|
activeSyncCount.value += 1
|
||||||
|
}
|
||||||
|
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
|
||||||
|
// isSyncing (see below) — flushed once every in-flight fetchData()/sync()
|
||||||
|
// has finished, rather than left unread forever. Flushing by id (not by
|
||||||
|
// re-deriving an index) sidesteps fetchData() having replaced
|
||||||
|
// feeds.value/allItems.value wholesale in the meantime — the id is still
|
||||||
|
// valid, wherever the item now sits.
|
||||||
|
const pendingSuppressedReadIds = new Set()
|
||||||
|
|
||||||
|
function endSync() {
|
||||||
|
activeSyncCount.value -= 1
|
||||||
|
if (activeSyncCount.value === 0) {
|
||||||
|
flushPendingSuppressedReads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const isSyncing = computed(() => activeSyncCount.value > 0)
|
||||||
|
|
||||||
|
function flushPendingSuppressedReads() {
|
||||||
|
if (pendingSuppressedReadIds.size === 0) return
|
||||||
|
const ids = [...pendingSuppressedReadIds]
|
||||||
|
pendingSuppressedReadIds.clear()
|
||||||
|
for (const id of ids) {
|
||||||
|
// feeds.value is always a filter()/slice() projection of allItems.value
|
||||||
|
// (see applyFilter), so every item in it is the same object reference as
|
||||||
|
// one already in allItems.value — one lookup is enough.
|
||||||
|
const feed = allItems.value.find(f => f.id === id)
|
||||||
|
if (feed) feed.read = true
|
||||||
|
markRead(id)
|
||||||
|
}
|
||||||
|
// A still-open article view marks in place and relies on dropReadArticles()
|
||||||
|
// running at the next leaveArticleView()/setFeedFilter() to clean up — same
|
||||||
|
// invariant markCurrentArticleRead() already keeps while paging, and
|
||||||
|
// removing one here could shift currentIndex out from under whatever's on
|
||||||
|
// screen. List view has no such upcoming transition to rely on (the user
|
||||||
|
// may just keep scrolling), so sweep these out now: this is exactly what a
|
||||||
|
// scroll-triggered mark would already have done immediately, had a sync not
|
||||||
|
// been in flight.
|
||||||
|
if (viewMode.value !== 'article') {
|
||||||
|
dropReadArticles()
|
||||||
|
}
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
||||||
|
// mutation that moves the page without user intent — currently the list-view
|
||||||
|
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
||||||
|
// (instead of treating the induced jump as a user scroll) for a short window
|
||||||
|
// after this, so removing read articles can't pop the header in/out mid-read.
|
||||||
|
const lastProgrammaticScroll = ref(0)
|
||||||
|
function markProgrammaticScroll() {
|
||||||
|
lastProgrammaticScroll.value = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders() {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'user-token': localStorage.getItem("user-token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tells the server to revoke the current token (bumps token_version, so any
|
||||||
|
// other outstanding tokens for this account are invalidated too) before
|
||||||
|
// clearing the local session. Best-effort: if the request fails (e.g. the
|
||||||
|
// token already expired) the local session is cleared regardless.
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/auth/logout', null, authHeaders())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging out', error)
|
||||||
|
}
|
||||||
|
localStorage.removeItem('user-token')
|
||||||
|
localStorage.removeItem('user-id')
|
||||||
|
clearFeedFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"),
|
||||||
|
// but their image CDN only accepts numeric format ids in the URL — the
|
||||||
|
// template's `${formatId}` literally means a number. Substituting the
|
||||||
|
// symbolic name verbatim produces a 400 (image fails to load). DW generates
|
||||||
|
// the same fixed set of numeric variants for every image, so map the
|
||||||
|
// symbolic names we've seen to their numeric equivalent.
|
||||||
|
const DW_FORMAT_IDS = {
|
||||||
|
MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTemplatedImage(img) {
|
||||||
|
const rawFormat = img.getAttribute('data-format')
|
||||||
|
const format = rawFormat && (DW_FORMAT_IDS[rawFormat] ?? (/^\d+$/.test(rawFormat) ? rawFormat : null))
|
||||||
|
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());
|
||||||
|
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
|
||||||
|
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
|
||||||
|
// article body. It's not part of the article, so strip it before Readability
|
||||||
|
// pulls it into the parsed content.
|
||||||
|
doc.querySelectorAll('#article-social-bar').forEach(el => el.remove())
|
||||||
|
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
|
||||||
|
// for an embedded video/audio player whose actual <video>/<audio>/<iframe>
|
||||||
|
// we already stripped — without it, the heading is just a giant orphaned
|
||||||
|
// icon that takes up space and links nowhere.
|
||||||
|
doc.querySelectorAll('[aria-label]').forEach(el => {
|
||||||
|
if (/^(Eingebettete[rs]?|Embedded) (Video|Audio)/i.test(el.getAttribute('aria-label'))) {
|
||||||
|
el.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Alpine.js widget overlays: x-cloak marks elements that should be hidden
|
||||||
|
// until Alpine.js initialises (prevents FOUC). These are always widget
|
||||||
|
// containers (e.g. taz's "taz schneller googeln" promo), never article
|
||||||
|
// content, so they're safe to remove unconditionally.
|
||||||
|
doc.querySelectorAll('[x-cloak]').forEach(el => el.remove())
|
||||||
|
// taz subscription promo blocks: a standalone <section> whose link(s) point
|
||||||
|
// to an /abo/ subscription page. Only climb to <section>, not <article>,
|
||||||
|
// to avoid accidentally removing the main article body.
|
||||||
|
doc.querySelectorAll('a[href*="/abo/"]').forEach(el => {
|
||||||
|
const container = el.closest('section')
|
||||||
|
if (container) container.remove()
|
||||||
|
})
|
||||||
|
// taz "Mehr zum Thema" related-articles teaser section.
|
||||||
|
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
|
||||||
|
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
|
||||||
|
// <article> itself or its direct <a> child carries an aria-label containing "Abo".
|
||||||
|
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
|
||||||
|
const container = el.closest('section') ?? el
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
doc.querySelectorAll('article > a[aria-label*="Abo"]').forEach(el => {
|
||||||
|
const container = el.closest('section') ?? el.closest('article')
|
||||||
|
if (container) container.remove()
|
||||||
|
})
|
||||||
|
// Al Jazeera embeds a "Recommended Stories" section (a heading followed by
|
||||||
|
// a list of unrelated article teasers), marked up as <section class="more-on">.
|
||||||
|
// It's not part of the article, so strip it before Readability pulls it
|
||||||
|
// into the parsed content.
|
||||||
|
doc.querySelectorAll('section.more-on').forEach(el => el.remove())
|
||||||
|
const article = new Readability(doc).parse();
|
||||||
|
if (!article) {
|
||||||
|
showMessageForXSeconds('Could not extract readable content.', 5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
feeds.value[index].content = article.content;
|
||||||
|
feeds.value[index].readable = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error)
|
||||||
|
showMessageForXSeconds(error, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
|
||||||
|
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
|
||||||
|
// prevArticle() can both re-mark the same article (page forward, back,
|
||||||
|
// forward again), so two overlapping markRead() calls for one id must both
|
||||||
|
// finish before fetchData() is allowed to trust the server's word on it —
|
||||||
|
// otherwise the first call to settle would clear the id out from under the
|
||||||
|
// second, still-in-flight one.
|
||||||
|
const pendingReadCounts = new Map()
|
||||||
|
|
||||||
|
function addPendingRead(id) {
|
||||||
|
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingRead(id) {
|
||||||
|
const count = pendingReadCounts.get(id) ?? 0
|
||||||
|
if (count <= 1) {
|
||||||
|
pendingReadCounts.delete(id)
|
||||||
|
} else {
|
||||||
|
pendingReadCounts.set(id, count - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short backoff between retries of a failed mark-read PUT — each entry is the
|
||||||
|
// wait before that retry attempt.
|
||||||
|
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
|
||||||
|
// Bounds how long a hung request (dead connection, captive portal — axios has
|
||||||
|
// no default timeout) can keep an id "pending": without this, a request that
|
||||||
|
// never settles would never leave pendingReadCounts, silently withholding
|
||||||
|
// that article from every fetchData() for the rest of the session.
|
||||||
|
const MARK_READ_TIMEOUT_MS = 10000
|
||||||
|
|
||||||
|
// 4xx responses (expired token, item not found/not owned — see
|
||||||
|
// src/reader/mark_read.rs) mean the request itself is wrong and won't
|
||||||
|
// succeed on retry; only a missing response (network error, timeout) or a
|
||||||
|
// server-side/rate-limit status is worth retrying.
|
||||||
|
function isRetryableMarkReadError(error) {
|
||||||
|
const status = error.response?.status
|
||||||
|
return status === undefined || status >= 500 || status === 429
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves to true once the article is confirmed read server-side, or false
|
||||||
|
// once retries (if any) are exhausted / the error isn't retryable.
|
||||||
|
async function markRead(id) {
|
||||||
|
addPendingRead(id)
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
try {
|
||||||
|
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking article read:', error)
|
||||||
|
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
|
||||||
|
// Out of retries, or a retry can't help — this mark never committed
|
||||||
|
// server-side, so the item genuinely is still unread there. Surface
|
||||||
|
// it rather than letting it silently vanish from the local list now
|
||||||
|
// only to mysteriously reappear as unread later.
|
||||||
|
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
removePendingRead(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects the master list onto `feeds` through the active filter. A filtered
|
||||||
|
// feed with no remaining unread items projects to an empty list ("All caught
|
||||||
|
// up") while staying selected — see feedTitles.
|
||||||
|
function applyFilter() {
|
||||||
|
if (!feedFilter.value) {
|
||||||
|
feeds.value = allItems.value.slice()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const titles = new Set(feedFilter.value)
|
||||||
|
feeds.value = allItems.value.filter(i => titles.has(i.feedTitle))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Article view marks an article read in place (feed.read = true) without
|
||||||
|
// removing it — see markCurrentArticleRead — so currentIndex stays valid while
|
||||||
|
// paging within that same list. Anything switches the list out from under
|
||||||
|
// that index (leaving article view, or re-filtering while still in it) has to
|
||||||
|
// drop those read-in-place articles from the master here, or they resurface:
|
||||||
|
// re-selecting their feed would otherwise re-project them from allItems as if
|
||||||
|
// still unread.
|
||||||
|
function dropReadArticles() {
|
||||||
|
// The feeds.value filter here is redundant for the setFeedFilter() caller
|
||||||
|
// (applyFilter() overwrites feeds.value again right after) but is what
|
||||||
|
// leaveArticleView() actually needs — kept shared rather than split into two
|
||||||
|
// near-identical branches.
|
||||||
|
feeds.value = feeds.value.filter(feed => !feed.read)
|
||||||
|
allItems.value = allItems.value.filter(feed => !feed.read)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order-insensitive: callers building the next selection from checkbox
|
||||||
|
// toggles (see FeedFilter.vue) can produce an array in a different order than
|
||||||
|
// what's already active — that's not a real change.
|
||||||
|
function sameFeedSelection(a, b) {
|
||||||
|
if (a === null || b === null) return a === b
|
||||||
|
if (a.length !== b.length) return false
|
||||||
|
const setA = new Set(a)
|
||||||
|
return b.every(title => setA.has(title))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared by setFeedFilter() and pruneFeedFilter() — both assign feedFilter.value
|
||||||
|
// then need the same localStorage write/removal to keep it in sync.
|
||||||
|
function persistFeedFilter() {
|
||||||
|
if (feedFilter.value) {
|
||||||
|
localStorage.setItem('feedFilter', JSON.stringify(feedFilter.value))
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('feedFilter')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
|
||||||
|
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
|
||||||
|
// the observer at the new .observe nodes after Vue has re-rendered. Works in
|
||||||
|
// article view too — there are no .observe nodes there so the setup is a no-op,
|
||||||
|
// and resetting currentIndex keeps paging valid against the new list.
|
||||||
|
// A no-op (order-insensitively unchanged) selection returns early, before any
|
||||||
|
// of the scrollTo/dropReadArticles/mark-read side effects below — every
|
||||||
|
// caller gets this protection for free rather than having to check first.
|
||||||
|
async function setFeedFilter(titles) {
|
||||||
|
const next = titles && titles.length > 0 ? titles : null // null for "All feeds"
|
||||||
|
if (sameFeedSelection(feedFilter.value, next)) return
|
||||||
|
disconnectObserver()
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
dropReadArticles()
|
||||||
|
feedFilter.value = next
|
||||||
|
persistFeedFilter()
|
||||||
|
currentIndex.value = 0
|
||||||
|
applyFilter()
|
||||||
|
// In article view the item at currentIndex is fully displayed, not just
|
||||||
|
// listed — unlike a plain currentIndex reset from paging, this one really is
|
||||||
|
// "shown" to the user, so mark it the same way toggleViewMode()/nextArticle()/
|
||||||
|
// prevArticle() do. List view only shows titles here; its own read-marking
|
||||||
|
// happens via scroll (handleIntersection), so this must not fire there.
|
||||||
|
if (viewMode.value === 'article') markCurrentArticleRead()
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
await nextTick()
|
||||||
|
setupIntersectionObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A persisted/selected title can outlive its feed (deleted or renamed from
|
||||||
|
// Admin) — the modal then has no way to un-list a title the user never got a
|
||||||
|
// chance to see and uncheck. `allItems` can't tell "the feed still exists"
|
||||||
|
// from "the feed has 0 unread items right now" (feedTitles deliberately keeps
|
||||||
|
// the latter listed), so it's not a usable source of truth here; the feed
|
||||||
|
// list itself is, and AdminFeeds.vue already fetches exactly that from this
|
||||||
|
// same endpoint. Only requested when a filter is actually active, since
|
||||||
|
// that's the only case with anything to prune.
|
||||||
|
async function pruneFeedFilter() {
|
||||||
|
if (!feedFilter.value) return
|
||||||
|
try {
|
||||||
|
const user_id = localStorage.getItem("user-id")
|
||||||
|
const response = await axios.get("/api/v1/article/feeds/" + user_id, authHeaders())
|
||||||
|
// The filter may have been cleared (logout / 401) while the request was in flight.
|
||||||
|
if (!feedFilter.value) return
|
||||||
|
const existingTitles = new Set(response.data.feeds.map(feed => feed.title))
|
||||||
|
const pruned = feedFilter.value.filter(title => existingTitles.has(title))
|
||||||
|
if (pruned.length === feedFilter.value.length) return
|
||||||
|
feedFilter.value = pruned.length > 0 ? pruned : null
|
||||||
|
persistFeedFilter()
|
||||||
|
applyFilter()
|
||||||
|
} catch (error) {
|
||||||
|
// Best-effort: a stale title stuck in the modal until the next successful
|
||||||
|
// prune is harmless, so don't fail the reload over it.
|
||||||
|
console.error('Error pruning feed filter', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
beginSync()
|
||||||
|
const user_id = localStorage.getItem("user-id")
|
||||||
|
// Snapshot ids pending *before* the GET goes out, not just when its
|
||||||
|
// response comes back. A markRead() PUT that commits while this GET is
|
||||||
|
// in flight is invisible to the check below (its id has already been
|
||||||
|
// cleared by the time the response arrives) even though the server read
|
||||||
|
// the DB before that commit and so still reported the item unread — this
|
||||||
|
// snapshot catches that case too.
|
||||||
|
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
|
||||||
|
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 }));
|
||||||
|
});
|
||||||
|
// An item pending at either end of this request may not have committed
|
||||||
|
// server-side yet, so the server can still report it unread here. Drop it
|
||||||
|
// from this snapshot too — otherwise a concurrent background sync's
|
||||||
|
// refetch (see sync()) would resurrect it as unread out from under the
|
||||||
|
// user while markRead() is still confirming or retrying it.
|
||||||
|
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
|
||||||
|
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
|
||||||
|
// lexicographic comparison sorts them chronologically.
|
||||||
|
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
||||||
|
allItems.value = freshItems;
|
||||||
|
applyFilter();
|
||||||
|
await pruneFeedFilter();
|
||||||
|
refreshUnreadDisplay();
|
||||||
|
await nextTick();
|
||||||
|
setupIntersectionObserver();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching data:', error)
|
||||||
|
showMessageForXSeconds(error, 5)
|
||||||
|
} finally {
|
||||||
|
endSync()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sync(silent = false) {
|
||||||
|
beginSync()
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/v1/article/sync', {
|
||||||
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
|
}, authHeaders())
|
||||||
|
|
||||||
|
if (response.status == 200 && !silent) {
|
||||||
|
showMessageForXSeconds('Sync successful.', 5)
|
||||||
|
}
|
||||||
|
await fetchData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sync', error)
|
||||||
|
if (!silent) {
|
||||||
|
showMessageForXSeconds(error, 5)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
endSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupIntersectionObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sticky topbar overlays the top of the viewport, so an article fully
|
||||||
|
// hidden behind it should already count as "scrolled past" — shrink the
|
||||||
|
// observer's root by that height so it stops intersecting at that point.
|
||||||
|
const topbarHeight = document.querySelector('.app-nav')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
|
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
|
||||||
|
root: null, // Use the viewport as the root
|
||||||
|
rootMargin: `-${topbarHeight}px 0px 0px 0px`,
|
||||||
|
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
|
||||||
|
});
|
||||||
|
|
||||||
|
const observedDivs = document.querySelectorAll(".observe");
|
||||||
|
// Arm the skip only when we actually observe nodes: an observe() batch fires
|
||||||
|
// an initial snapshot to drop (a connect while scrolled down would otherwise
|
||||||
|
// mass-mark everything above the viewport), but with nothing observed there's
|
||||||
|
// no snapshot — and assigning unconditionally clears any stale flag from a
|
||||||
|
// prior no-op setup (e.g. one run in article view, which has no .observe nodes).
|
||||||
|
skipNextObservation = observedDivs.length > 0
|
||||||
|
observedDivs.forEach(observedDiv => {
|
||||||
|
observer.observe(observedDiv);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIntersection(entries, topbarHeight = 0) {
|
||||||
|
// Drop the initial snapshot fired on (re)connect — see skipNextObservation.
|
||||||
|
// The old observer is disconnected before this new one observes, so only the
|
||||||
|
// latest observer's initial callback reaches here; one skip is enough.
|
||||||
|
if (skipNextObservation) {
|
||||||
|
skipNextObservation = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Resolve all affected feeds before touching feeds.value — the target.id
|
||||||
|
// indices are render-time positions that shift once we splice the array.
|
||||||
|
const readFeeds = entries
|
||||||
|
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
|
||||||
|
.map(entry => feeds.value[entry.target.id])
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (readFeeds.length === 0) return
|
||||||
|
|
||||||
|
if (isSyncing.value) {
|
||||||
|
// Don't mutate a list that's mid-reload — queue these ids instead of
|
||||||
|
// dropping them; flushPendingSuppressedReads() marks (and removes) them
|
||||||
|
// once every in-flight fetchData()/sync() has finished. Nothing here
|
||||||
|
// touched the DOM, so there's no observer disconnect/reconnect to do.
|
||||||
|
for (const feed of readFeeds) {
|
||||||
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect before the DOM mutation. In card layout the cards are short
|
||||||
|
// enough that the shift caused by removing one can push the next card above
|
||||||
|
// the header, which the observer would immediately treat as another read —
|
||||||
|
// cascading until many articles disappear at once.
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both the array splice (via scroll anchoring) and the scrollBy correction
|
||||||
|
// below move the page — flag it so AppNav's header auto-hide ignores the jump.
|
||||||
|
markProgrammaticScroll()
|
||||||
|
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||||
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
// Mirror into the master so cleared filters don't resurrect read articles.
|
||||||
|
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
|
||||||
|
for (const feed of readFeeds) {
|
||||||
|
markRead(feed.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick().then(() => {
|
||||||
|
// If scroll anchoring didn't compensate for the removed content (common
|
||||||
|
// with position:fixed headers and overflow-x:hidden on body), the first
|
||||||
|
// remaining article will have drifted above the header. Correct the scroll
|
||||||
|
// position so it sits exactly at the header bottom before reconnecting —
|
||||||
|
// otherwise the initial observation would immediately mark everything above
|
||||||
|
// the topbar as read and cascade until the list is empty.
|
||||||
|
const first = document.querySelector('.observe')
|
||||||
|
if (first) {
|
||||||
|
const top = first.getBoundingClientRect().top
|
||||||
|
if (top < topbarHeight) {
|
||||||
|
markProgrammaticScroll()
|
||||||
|
window.scrollBy(0, top - topbarHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setupIntersectionObserver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
const readIds = new Set(ids)
|
||||||
|
feeds.value = []
|
||||||
|
// markAllRead operates on the visible subset (only the filtered feed, if one
|
||||||
|
// is active) — drop exactly those from the master too.
|
||||||
|
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
currentIndex.value = 0
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
// markRead() resolves to true/false rather than rejecting, so Promise.all
|
||||||
|
// can't reject here — but a false means that article is still unread
|
||||||
|
// server-side, which the message below must not contradict.
|
||||||
|
const results = await Promise.all(ids.map(id => markRead(id)))
|
||||||
|
const failedCount = results.filter(ok => !ok).length
|
||||||
|
if (failedCount === 0) {
|
||||||
|
showMessageForXSeconds('All articles marked as read.', 5)
|
||||||
|
} else {
|
||||||
|
// markRead() already surfaced each individual failure — this summarizes
|
||||||
|
// the batch outcome instead of claiming full success over it.
|
||||||
|
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markCurrentArticleRead() {
|
||||||
|
const feed = feeds.value[currentIndex.value]
|
||||||
|
if (!feed) return
|
||||||
|
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
|
||||||
|
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
|
||||||
|
// marks it once every in-flight fetchData()/sync() has finished.
|
||||||
|
if (isSyncing.value) {
|
||||||
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
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.
|
||||||
|
dropReadArticles()
|
||||||
|
currentIndex.value = 0
|
||||||
|
viewMode.value = 'list'
|
||||||
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
|
refreshUnreadDisplay()
|
||||||
|
// 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 {
|
||||||
|
// Disconnect first: the v-if switch is about to unmount all .observe
|
||||||
|
// elements, which would otherwise fire intersection callbacks reporting
|
||||||
|
// them as no-longer-intersecting and mark every visible article read.
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
viewMode.value = 'article'
|
||||||
|
localStorage.setItem('viewMode', viewMode.value)
|
||||||
|
currentIndex.value = 0
|
||||||
|
markCurrentArticleRead()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleLayout() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
||||||
|
localStorage.setItem('layout', layout.value)
|
||||||
|
await nextTick()
|
||||||
|
setupIntersectionObserver()
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextArticle() {
|
||||||
|
if (currentIndex.value < feeds.value.length - 1) {
|
||||||
|
currentIndex.value += 1
|
||||||
|
markCurrentArticleRead()
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevArticle() {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
currentIndex.value -= 1
|
||||||
|
markCurrentArticleRead()
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeeds() {
|
||||||
|
return {
|
||||||
|
feeds,
|
||||||
|
allItems,
|
||||||
|
feedFilter,
|
||||||
|
feedTitles,
|
||||||
|
feedUnreadCounts,
|
||||||
|
unreadCount,
|
||||||
|
displayedFeedUnreadCounts,
|
||||||
|
refreshUnreadDisplay,
|
||||||
|
setFeedFilter,
|
||||||
|
showMessage,
|
||||||
|
message,
|
||||||
|
viewMode,
|
||||||
|
currentIndex,
|
||||||
|
toggleViewMode,
|
||||||
|
leaveArticleView,
|
||||||
|
markCurrentArticleRead,
|
||||||
|
layout,
|
||||||
|
toggleLayout,
|
||||||
|
nextArticle,
|
||||||
|
prevArticle,
|
||||||
|
fetchData,
|
||||||
|
sync,
|
||||||
|
getReadable,
|
||||||
|
markRead,
|
||||||
|
markAllRead,
|
||||||
|
showMessageForXSeconds,
|
||||||
|
setupIntersectionObserver,
|
||||||
|
disconnectObserver,
|
||||||
|
setInitialLoad,
|
||||||
|
handleIntersection,
|
||||||
|
lastProgrammaticScroll,
|
||||||
|
markProgrammaticScroll,
|
||||||
|
isSyncing,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const HEADLINE_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Glook)', value: "Glook, 'Courier New'" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'raleway', label: 'Raleway', value: "Raleway, -apple-system, sans-serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTENT_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Merriweather)', value: "Merriweather, Georgia, 'Times New Roman', Times, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'source-serif', label: 'Source Serif 4', value: "'Source Serif 4', Georgia, serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
|
||||||
|
const SIZE_LABELS = ['S', 'M', 'L', 'XL']
|
||||||
|
|
||||||
|
const TEXT_ALIGN_OPTIONS = [
|
||||||
|
{ key: 'left', label: 'Left' },
|
||||||
|
{ key: 'justify', label: 'Justified' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PADDING_STEPS = [1, 0.5, 0.15]
|
||||||
|
const PADDING_LABELS = ['Default', 'Compact', 'Minimal']
|
||||||
|
|
||||||
|
const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1'))
|
||||||
|
const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
|
||||||
|
const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default')
|
||||||
|
const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default')
|
||||||
|
const textAlignKey = ref(localStorage.getItem('s-text-align') ?? 'left')
|
||||||
|
const contentPadding = ref(parseFloat(localStorage.getItem('s-content-padding') ?? '1'))
|
||||||
|
|
||||||
|
function fontValue(options, key) {
|
||||||
|
return (options.find(o => o.key === key) ?? options[0]).value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings() {
|
||||||
|
const s = document.documentElement.style
|
||||||
|
s.setProperty('--headline-font-size-scale', headlineSizeScale.value)
|
||||||
|
s.setProperty('--content-font-size-scale', contentSizeScale.value)
|
||||||
|
s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value))
|
||||||
|
s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value))
|
||||||
|
s.setProperty('--content-text-align', textAlignKey.value)
|
||||||
|
s.setProperty('--content-padding', contentPadding.value + 'rem')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineSize(scale) {
|
||||||
|
headlineSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-headline-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentSize(scale) {
|
||||||
|
contentSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-content-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineFont(key) {
|
||||||
|
headlineFontKey.value = key
|
||||||
|
localStorage.setItem('s-headline-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentFont(key) {
|
||||||
|
contentFontKey.value = key
|
||||||
|
localStorage.setItem('s-content-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextAlign(key) {
|
||||||
|
textAlignKey.value = key
|
||||||
|
localStorage.setItem('s-text-align', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentPadding(step) {
|
||||||
|
contentPadding.value = step
|
||||||
|
localStorage.setItem('s-content-padding', step)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return {
|
||||||
|
headlineSizeScale,
|
||||||
|
contentSizeScale,
|
||||||
|
headlineFontKey,
|
||||||
|
contentFontKey,
|
||||||
|
SIZE_STEPS,
|
||||||
|
SIZE_LABELS,
|
||||||
|
HEADLINE_FONT_OPTIONS,
|
||||||
|
CONTENT_FONT_OPTIONS,
|
||||||
|
TEXT_ALIGN_OPTIONS,
|
||||||
|
PADDING_STEPS,
|
||||||
|
PADDING_LABELS,
|
||||||
|
applySettings,
|
||||||
|
setHeadlineSize,
|
||||||
|
setContentSize,
|
||||||
|
setHeadlineFont,
|
||||||
|
setContentFont,
|
||||||
|
setTextAlign,
|
||||||
|
setContentPadding,
|
||||||
|
textAlignKey,
|
||||||
|
contentPadding,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import router from './router'
|
||||||
|
import { clearFeedFilter } from './composables/useFeeds'
|
||||||
|
|
||||||
|
// Extracted out of main.js so it's unit-testable in isolation: importing
|
||||||
|
// main.js itself boots the whole app (createApp(...).mount('#app')) and
|
||||||
|
// eagerly touches the router's lazy-loaded route components, neither of
|
||||||
|
// which a test can do safely.
|
||||||
|
//
|
||||||
|
// A 401 means the server has rejected the token (missing, expired, or
|
||||||
|
// revoked via logout/token_version bump elsewhere). Drop the stale session
|
||||||
|
// and send the user back to login rather than leaving them on a page where
|
||||||
|
// every request silently fails.
|
||||||
|
export function handleUnauthorizedResponse(error) {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('user-token')
|
||||||
|
localStorage.removeItem('user-id')
|
||||||
|
clearFeedFilter()
|
||||||
|
if (router.currentRoute.value.name !== 'login') {
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import { handleUnauthorizedResponse } from './httpAuthInterceptor'
|
||||||
|
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
handleUnauthorizedResponse
|
||||||
|
)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
+15
-32
@@ -1,32 +1,27 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
redirect: '/feeds',
|
||||||
component: HomeView,
|
|
||||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/feeds',
|
path: '/feeds',
|
||||||
name: 'feeds',
|
name: 'feeds',
|
||||||
// route level code-splitting
|
// route level code-splitting
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
// this generates a separate chunk (Feed.[hash].js) for this route
|
||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/FeedView.vue'),
|
component: () => import('../views/FeedView.vue'),
|
||||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/about',
|
path: '/admin',
|
||||||
name: 'about',
|
name: 'admin',
|
||||||
// route level code-splitting
|
component: () => import('../views/AdminView.vue'),
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
meta: { requiresAuth: true },
|
||||||
// which is lazy-loaded when the route is visited.
|
|
||||||
component: () => import('../views/AboutView.vue'),
|
|
||||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -36,25 +31,13 @@ const router = createRouter({
|
|||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to) => {
|
||||||
if (to.meta.requiresAuth) {
|
const isAuthenticated = localStorage.getItem("user-token") != null;
|
||||||
// TODO Check if the user is authenticated (e.g., check for a valid token)
|
// Redirect unauthenticated users hitting a protected route to login;
|
||||||
let isAuthenticated = false;
|
// returning a value (instead of the deprecated next() callback) is the
|
||||||
if (localStorage.getItem("user-token") != null){
|
// modern vue-router guard API. Returning nothing lets navigation proceed.
|
||||||
isAuthenticated = true;
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
}
|
return '/login';
|
||||||
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
// Redirect to the login page
|
|
||||||
next('/login');
|
|
||||||
} else {
|
|
||||||
// Proceed to the protected route
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For routes that don't require authentication, proceed without checking
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
import AdminFeeds from '../components/AdminFeeds.vue'
|
||||||
|
import AdminSettings from '../components/AdminSettings.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<AdminSettings />
|
||||||
|
<AdminFeeds />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import TheWelcome from '../components/TheWelcome.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main>
|
|
||||||
<TheWelcome />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
+5
-27
@@ -16,39 +16,17 @@ export default defineConfig({
|
|||||||
|
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/login/rss': {
|
'/api': {
|
||||||
target: 'http://localhost:8001/api/v1/auth/login',
|
target: 'http://localhost:8001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path.replace(/^\/login\/rss/, ''),
|
|
||||||
},
|
},
|
||||||
'/feeds/get': {
|
|
||||||
target: 'http://localhost:8001/api/v1/article/get',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
rewrite: (path) => path.replace(/^\/feeds\/get/, ''),
|
|
||||||
},
|
|
||||||
'/feeds/sync': {
|
|
||||||
target: 'http://localhost:8001/api/v1/article/sync',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
rewrite: (path) => path.replace(/^\/feeds\/sync/, ''),
|
|
||||||
},
|
|
||||||
'/feeds/read': {
|
|
||||||
target: 'http://localhost:8001/api/v1/article/read',
|
|
||||||
changeOrigin: true,
|
|
||||||
secure: false,
|
|
||||||
rewrite: (path) => path.replace(/^\/feeds\/read/, ''),
|
|
||||||
},
|
|
||||||
'/feeds/add': {
|
|
||||||
target: 'http://localhost:8001/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