claude rework
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
target
|
||||||
|
vue/node_modules
|
||||||
|
vue/dist
|
||||||
|
.git
|
||||||
|
.env
|
||||||
@@ -1 +1,5 @@
|
|||||||
DATABASE_URL=postgres://admin:secret+123@localhost/rss
|
DATABASE_URL=postgres://admin:f055b6523e481a399281e2515dd3cefa@localhost/rss
|
||||||
|
JWT_SECRET=59b567a3782ff10bffb67d41640a3e16f91c8426b6efb1c7441bad8f7a9c1ba1
|
||||||
|
FRONTEND_ORIGIN=http://localhost:5173
|
||||||
|
RUST_LOG=info
|
||||||
|
POSTGRES_PASSWORD=f055b6523e481a399281e2515dd3cefa
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL=postgres://admin:changeme@localhost/rss
|
||||||
|
JWT_SECRET=change-this-to-a-long-random-string
|
||||||
|
FRONTEND_ORIGIN=http://localhost:5173
|
||||||
|
RUST_LOG=info
|
||||||
|
POSTGRES_PASSWORD=changeme
|
||||||
@@ -1 +1,3 @@
|
|||||||
/target
|
/target
|
||||||
|
.env
|
||||||
|
.claude
|
||||||
|
|||||||
Generated
+2051
-950
File diff suppressed because it is too large
Load Diff
+21
-20
@@ -6,31 +6,32 @@ edition = "2021"
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reqwest = { version = "0.11", features = ["json", "blocking"] }
|
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rss = { version = "2.0.1" }
|
rss = { version = "2.0.13" }
|
||||||
actix-web = "4.1.0"
|
actix-web = "4.13"
|
||||||
actix-rt = "2.7.0"
|
actix-rt = "2.10"
|
||||||
futures = "0.3.24"
|
futures = "0.3.31"
|
||||||
serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
|
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
|
||||||
serde_derive = "1.0.145"
|
serde_derive = "1.0.228"
|
||||||
actix-service = "2.0.2"
|
actix-service = "2.0.3"
|
||||||
diesel = { version = "2.0.2", features = ["postgres", "chrono"] }
|
diesel = { version = "2.3", features = ["postgres", "chrono"] }
|
||||||
|
diesel_migrations = "2.3"
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
bcrypt = "0.13.0"
|
bcrypt = "0.19"
|
||||||
uuid = {version = "1.2.1", features=["serde", "v4"]}
|
uuid = {version = "1.23", features=["serde", "v4"]}
|
||||||
jwt = "0.16.0"
|
jwt = "0.16.0"
|
||||||
hmac = "0.12.1"
|
hmac = "0.12"
|
||||||
sha2 = "0.10.6"
|
sha2 = "0.10"
|
||||||
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"
|
||||||
|
|
||||||
[dependencies.serde_json]
|
[dependencies.serde_json]
|
||||||
version = "1.0.86"
|
version = "1.0.150"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["alloc"]
|
features = ["alloc"]
|
||||||
|
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
# --- 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
|
||||||
|
|
||||||
|
# --- runtime ---
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/rss-reader /usr/local/bin/rss-reader
|
||||||
|
|
||||||
|
EXPOSE 8001
|
||||||
|
CMD ["rss-reader"]
|
||||||
@@ -1,24 +1,152 @@
|
|||||||
## 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 15
|
||||||
|
|
||||||
generate table
|
---
|
||||||
|
|
||||||
`diesel migration generate create_to_do_items`
|
## Development setup
|
||||||
|
|
||||||
fill up and down
|
### Prerequisites
|
||||||
|
|
||||||
`diesel migration run`
|
- [Rust](https://rustup.rs/) (stable toolchain)
|
||||||
|
- [Node.js](https://nodejs.org/) 20+ and npm
|
||||||
|
- PostgreSQL (locally, or via Docker — see below)
|
||||||
|
- The Diesel CLI: `cargo install diesel_cli --no-default-features --features postgres`
|
||||||
|
(requires `libpq`; on Debian/Ubuntu: `sudo apt install libpq-dev`)
|
||||||
|
|
||||||
# docker
|
### 1. Configure environment variables
|
||||||
|
|
||||||
`docker exec -it 59ff8bad10c0 psql -d rss -U admin`
|
Copy the example file and fill in your own values:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
# Create user
|
`.env` is read by both the backend (via `dotenv`) and the `diesel` CLI, and is gitignored — never commit it.
|
||||||
curl -X POST -H "Content-Type: application/json" -d '{"name": "mace", "email": "safemind@posteo.de", "password": "secret"}' http://localhost:8001/api/v1/user/create
|
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DATABASE_URL` | Postgres connection string, e.g. `postgres://admin:changeme@localhost/rss` |
|
||||||
|
| `JWT_SECRET` | Secret used to sign/verify auth tokens — use a long random string |
|
||||||
|
| `FRONTEND_ORIGIN` | Origin allowed by CORS, e.g. `http://localhost:5173` for the Vite dev server |
|
||||||
|
| `RUST_LOG` | Log level for `env_logger`, e.g. `info` |
|
||||||
|
| `POSTGRES_PASSWORD` | Password for the `admin` Postgres user (used by `docker-compose.yml`) |
|
||||||
|
|
||||||
|
### 2. Start PostgreSQL
|
||||||
|
|
||||||
|
The simplest way during development is to run just the database container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up postgres -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts Postgres on `localhost:5432` with the credentials from `.env`. Alternatively, point `DATABASE_URL` at any Postgres instance you already have running.
|
||||||
|
|
||||||
|
### 3. Run database migrations
|
||||||
|
|
||||||
|
Migrations are embedded in the backend binary and also run automatically on startup, but you can apply them manually with the Diesel CLI (useful when generating new migrations during development):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
diesel setup # creates the database and runs existing migrations
|
||||||
|
diesel migration run # applies any pending migrations
|
||||||
|
diesel migration generate <name> # scaffolds a new up.sql/down.sql pair
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the backend
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The API listens on `http://0.0.0.0:8001`. Backend logs respect `RUST_LOG`.
|
||||||
|
|
||||||
|
### 5. Run the frontend
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd vue
|
||||||
|
npm install
|
||||||
|
npm run dev -- --host
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server runs on `http://localhost:5173` (the `--host` flag also exposes it on your LAN so you can test from a phone) and proxies `/api` requests to `http://localhost:8001` (configured in `vue/vite.config.js`).
|
||||||
|
|
||||||
|
### 6. Try it out
|
||||||
|
|
||||||
|
Create a user, then log in through the UI at `http://localhost:5173`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST -H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "mace", "email": "you@example.com", "password": "secret"}' \
|
||||||
|
http://localhost:8001/api/v1/user/create
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful commands during development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Backend
|
||||||
|
cargo build # compile
|
||||||
|
cargo test # run the test suite (needs a reachable Postgres + DATABASE_URL)
|
||||||
|
cargo clippy # lint
|
||||||
|
cargo fmt # format
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Frontend (run from vue/)
|
||||||
|
npm run test # run Vitest component tests
|
||||||
|
npm run lint # eslint
|
||||||
|
npm run format # prettier
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Inspect the dockerized database directly
|
||||||
|
docker exec -it rss-postgres psql -d rss -U admin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production setup (Docker)
|
||||||
|
|
||||||
|
The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container.
|
||||||
|
|
||||||
|
### 1. Configure environment variables
|
||||||
|
|
||||||
|
Same as in development — create a root-level `.env` from `.env.example` and fill in **strong, unique** values for `POSTGRES_PASSWORD` and `JWT_SECRET`. Set `FRONTEND_ORIGIN` to the URL the frontend will actually be served from (e.g. `http://<host-ip>:8080` or your domain), since the backend's CORS policy only allows that origin.
|
||||||
|
|
||||||
|
### 2. Build and start everything
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up --build -d
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds three images and starts them on a shared network:
|
||||||
|
|
||||||
|
- **`postgres`** — PostgreSQL 15, 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
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|||||||
+26
-2
@@ -1,4 +1,3 @@
|
|||||||
version: "3.7"
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
container_name: "rss-postgres"
|
container_name: "rss-postgres"
|
||||||
@@ -8,9 +7,34 @@ services:
|
|||||||
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/data
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: "rss-backend"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
- "DATABASE_URL=postgres://admin:${POSTGRES_PASSWORD}@postgres/rss"
|
||||||
|
- "JWT_SECRET=${JWT_SECRET}"
|
||||||
|
- "FRONTEND_ORIGIN=${FRONTEND_ORIGIN}"
|
||||||
|
- "RUST_LOG=${RUST_LOG}"
|
||||||
|
ports:
|
||||||
|
- "0.0.0.0:8001:8001"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
container_name: "rss-frontend"
|
||||||
|
build:
|
||||||
|
context: ./vue
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
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();
|
|
||||||
}
|
|
||||||
+10
-4
@@ -3,29 +3,36 @@ extern crate jwt;
|
|||||||
extern crate sha2;
|
extern crate sha2;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use std::env;
|
||||||
|
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
|
use dotenv::dotenv;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
|
||||||
pub struct JwtToken {
|
pub struct JwtToken {
|
||||||
pub user_id: i32,
|
pub user_id: i32,
|
||||||
pub body: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
|
fn signing_key() -> HmacSha256 {
|
||||||
|
dotenv().ok();
|
||||||
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
impl JwtToken {
|
impl JwtToken {
|
||||||
pub fn encode(user_id: i32) -> String {
|
pub fn encode(user_id: i32) -> String {
|
||||||
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
|
let key: HmacSha256 = signing_key();
|
||||||
let mut claims = BTreeMap::new();
|
let mut claims = BTreeMap::new();
|
||||||
claims.insert("user_id", user_id);
|
claims.insert("user_id", user_id);
|
||||||
claims.sign_with_key(&key).unwrap()
|
claims.sign_with_key(&key).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
||||||
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
|
let key: HmacSha256 = signing_key();
|
||||||
let token_str: &str = encoded_token.as_str();
|
let token_str: &str = encoded_token.as_str();
|
||||||
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
||||||
VerifyWithKey::verify_with_key(token_str, &key);
|
VerifyWithKey::verify_with_key(token_str, &key);
|
||||||
@@ -36,7 +43,6 @@ impl JwtToken {
|
|||||||
let claims = token.claims();
|
let claims = token.claims();
|
||||||
Ok(JwtToken {
|
Ok(JwtToken {
|
||||||
user_id: claims["user_id"],
|
user_id: claims["user_id"],
|
||||||
body: encoded_token,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(_err) => Err("could not decode token"),
|
Err(_err) => Err("could not decode token"),
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ pub mod processes;
|
|||||||
use crate::auth::processes::check_password;
|
use crate::auth::processes::check_password;
|
||||||
use crate::auth::processes::extract_header_token;
|
use crate::auth::processes::extract_header_token;
|
||||||
|
|
||||||
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_password(token),
|
||||||
Err(message) => Err(message),
|
Err(message) => Err(message),
|
||||||
@@ -26,7 +26,7 @@ mod mod_test {
|
|||||||
.to_srv_request();
|
.to_srv_request();
|
||||||
|
|
||||||
match process_token(&request) {
|
match process_token(&request) {
|
||||||
Ok(message) => assert_eq!("passed", message),
|
Ok(user_id) => assert_eq!(32, user_id),
|
||||||
Err(_) => panic!("process token failed"),
|
Err(_) => panic!("process token failed"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use super::jwt;
|
use super::jwt;
|
||||||
use actix_web::dev::ServiceRequest;
|
use actix_web::dev::ServiceRequest;
|
||||||
|
|
||||||
pub fn check_password(password: String) -> Result<String, &'static str> {
|
pub fn check_password(password: String) -> Result<i32, &'static str> {
|
||||||
match jwt::JwtToken::decode(password) {
|
match jwt::JwtToken::decode(password) {
|
||||||
Ok(_token) => Ok(String::from("passed")),
|
Ok(token) => Ok(token.user_id),
|
||||||
Err(message) => Err(message),
|
Err(message) => Err(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ mod processes_test {
|
|||||||
let result = check_password(password_string);
|
let result = check_password(password_string);
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(check) => assert_eq!("passed", check),
|
Ok(user_id) => assert_eq!(32, user_id),
|
||||||
_ => panic!("Check correct password failed."),
|
_ => panic!("Check correct password failed."),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use diesel::pg::PgConnection;
|
use diesel::pg::PgConnection;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use dotenv::dotenv;
|
use dotenv::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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{HttpResponse, Responder};
|
use actix_web::{HttpResponse, Responder};
|
||||||
use reqwest::StatusCode;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::reader::structs::feed::FeedAggregate;
|
use crate::reader::structs::feed::FeedAggregate;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
pub mod articles;
|
pub mod articles;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod new_feed;
|
pub mod new_feed;
|
||||||
pub mod new_feed_item;
|
|
||||||
pub mod new_user;
|
pub mod new_user;
|
||||||
pub mod read_feed_item;
|
pub mod read_feed_item;
|
||||||
pub mod readable;
|
pub mod readable;
|
||||||
|
|||||||
@@ -1,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)]
|
||||||
|
|||||||
+24
-3
@@ -1,22 +1,39 @@
|
|||||||
extern crate diesel;
|
extern crate diesel;
|
||||||
extern crate dotenv;
|
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, HttpResponse, HttpServer};
|
||||||
|
use dotenv::dotenv;
|
||||||
use futures::future::{ok, Either};
|
use futures::future::{ok, Either};
|
||||||
|
use std::env;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod database;
|
mod database;
|
||||||
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 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();
|
||||||
|
|
||||||
let app = App::new()
|
let app = App::new()
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
let mut passed: bool;
|
let mut passed: bool;
|
||||||
@@ -25,7 +42,10 @@ async fn main() -> std::io::Result<()> {
|
|||||||
log::info!("Request Url: {}", request_url);
|
log::info!("Request Url: {}", request_url);
|
||||||
if req.path().contains("/article/") {
|
if req.path().contains("/article/") {
|
||||||
match auth::process_token(&req) {
|
match auth::process_token(&req) {
|
||||||
Ok(_token) => passed = true,
|
Ok(user_id) => {
|
||||||
|
log::info!("Authenticated user {} for {}", user_id, request_url);
|
||||||
|
passed = true;
|
||||||
|
}
|
||||||
Err(_message) => passed = false,
|
Err(_message) => passed = false,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -52,10 +72,11 @@ async fn main() -> std::io::Result<()> {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.wrap(cors)
|
||||||
.configure(views::views_factory);
|
.configure(views::views_factory);
|
||||||
app
|
app
|
||||||
})
|
})
|
||||||
.bind("127.0.0.1:8001")?
|
.bind("0.0.0.0:8001")?
|
||||||
.run()
|
.run()
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ pub struct User {
|
|||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn verify(self, password: String) -> bool {
|
pub fn verify(self, password: String) -> bool {
|
||||||
return verify(password.as_str(), &self.password).unwrap();
|
verify(password.as_str(), &self.password).unwrap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,3 +42,28 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App};
|
||||||
|
|
||||||
|
use super::add;
|
||||||
|
use crate::test_helpers::unique_suffix;
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn add_fails_for_unfetchable_feed_url() {
|
||||||
|
let app = test::init_service(App::new().route("/add", web::post().to(add))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/add")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"title": "Bad feed",
|
||||||
|
"url": format!("not-a-valid-url-{}", unique_suffix()),
|
||||||
|
"user_id": 1
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,3 +72,44 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
|
|||||||
|
|
||||||
articles.respond_to(&request)
|
articles.respond_to(&request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App};
|
||||||
|
|
||||||
|
use super::get;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{
|
||||||
|
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn get_returns_only_unread_items() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
let unread = insert_feed_item(&mut connection, feed.id, false);
|
||||||
|
let read = insert_feed_item(&mut connection, feed.id, true);
|
||||||
|
|
||||||
|
let app =
|
||||||
|
test::init_service(App::new().route("/get/{user_id}", web::get().to(get))).await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/get/{}", user.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
|
||||||
|
let body = test::read_body(resp).await;
|
||||||
|
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
|
||||||
|
assert!(body_str.contains(&unread.title));
|
||||||
|
assert!(!body_str.contains(&read.title));
|
||||||
|
|
||||||
|
delete_feed_item(&mut connection, unread.id);
|
||||||
|
delete_feed_item(&mut connection, read.id);
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,3 +29,55 @@ pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Res
|
|||||||
|
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use super::mark_read;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
|
use crate::schema::feed_item;
|
||||||
|
use crate::test_helpers::{
|
||||||
|
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn mark_read_flips_the_read_flag() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
let item = insert_feed_item(&mut connection, feed.id, false);
|
||||||
|
|
||||||
|
let app =
|
||||||
|
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
|
||||||
|
let req = test::TestRequest::put()
|
||||||
|
.uri(&format!("/read/{}", item.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
|
||||||
|
let updated: FeedItem = feed_item::table
|
||||||
|
.find(item.id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert!(updated.read);
|
||||||
|
|
||||||
|
delete_feed_item(&mut connection, item.id);
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn mark_read_returns_not_found_for_unknown_id() {
|
||||||
|
let app =
|
||||||
|
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
|
||||||
|
let req = test::TestRequest::put().uri("/read/999999999").to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+82
-25
@@ -19,38 +19,19 @@ use rss::Item;
|
|||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
|
||||||
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
||||||
// let format_string = "%a, %d %b %Y %H:%M:%S %z";
|
if let Ok(result) = parse(date_str) {
|
||||||
let format_string = "%Y-%m-%dT%H:%M:%S%Z";
|
log::info!("Date: {:?}", result);
|
||||||
|
return Ok(result.with_timezone(&Local).naive_local());
|
||||||
let result = parse(date_str).unwrap();
|
|
||||||
log::info!("Date: {:?}", result);
|
|
||||||
|
|
||||||
match NaiveDateTime::parse_from_str(&result.to_string(), format_string) {
|
|
||||||
Ok(r) => Ok(r),
|
|
||||||
Err(_) => {
|
|
||||||
let datetime = DateTime::parse_from_rfc2822(date_str);
|
|
||||||
match datetime {
|
|
||||||
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
|
|
||||||
Err(_) => match DateTime::parse_from_rfc2822(date_str) {
|
|
||||||
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
||||||
let item_title = item.title.clone().unwrap();
|
let item_title = item.title.clone().unwrap();
|
||||||
log::info!("Create feed item: {}", item_title);
|
log::info!("Create feed item: {}", item_title);
|
||||||
|
|
||||||
let base_content: &str = match item.content() {
|
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
|
||||||
Some(c) => c,
|
|
||||||
None => match item.description() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
let frag = Html::parse_fragment(base_content);
|
let frag = Html::parse_fragment(base_content);
|
||||||
let mut content = "".to_string();
|
let mut content = "".to_string();
|
||||||
@@ -133,3 +114,79 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
|
|||||||
|
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::models::feed::new_feed::NewFeed;
|
||||||
|
use crate::models::user::new_user::NewUser;
|
||||||
|
use crate::models::user::rss_user::User;
|
||||||
|
use crate::schema::users;
|
||||||
|
use crate::test_helpers::unique_suffix;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_date_parses_iso8601_dates() {
|
||||||
|
assert!(get_date("2024-01-01T12:00:00Z").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_date_parses_rfc2822_dates() {
|
||||||
|
assert!(get_date("Tue, 03 Jun 2025 10:00:00 GMT").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_date_returns_err_for_unparseable_dates() {
|
||||||
|
assert!(get_date("not-a-date").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_feed_item_does_not_duplicate_existing_items() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let new_user = NewUser::new(
|
||||||
|
format!("sync_test_{suffix}"),
|
||||||
|
format!("sync_{suffix}@example.test"),
|
||||||
|
"secret".to_string(),
|
||||||
|
);
|
||||||
|
let user: User = diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let new_feed = NewFeed::new(
|
||||||
|
format!("Sync test feed {suffix}"),
|
||||||
|
format!("https://example.test/feed/{suffix}"),
|
||||||
|
user.id,
|
||||||
|
);
|
||||||
|
let feed: Feed = diesel::insert_into(feed::table)
|
||||||
|
.values(&new_feed)
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut item = Item::default();
|
||||||
|
item.set_title(Some(format!("Sync test article {suffix}")));
|
||||||
|
item.set_link(Some(format!("https://example.test/article/{suffix}")));
|
||||||
|
item.set_content(Some("<p>Hello world</p>".to_string()));
|
||||||
|
|
||||||
|
create_feed_item(item.clone(), &feed, &mut connection);
|
||||||
|
create_feed_item(item, &feed, &mut connection);
|
||||||
|
|
||||||
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
|
.filter(feed_id.eq(feed.id))
|
||||||
|
.load(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(1, items.len(), "duplicate feed items should not be created");
|
||||||
|
|
||||||
|
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
diesel::delete(users::table.filter(users::id.eq(user.id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#![cfg(test)]
|
||||||
|
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::models::feed::new_feed::NewFeed;
|
||||||
|
use crate::models::feed::rss_feed::Feed;
|
||||||
|
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
||||||
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
|
use crate::models::user::new_user::NewUser;
|
||||||
|
use crate::models::user::rss_user::User;
|
||||||
|
use crate::schema::{feed, feed_item, users};
|
||||||
|
|
||||||
|
// Test fixtures are written through the same models/schema as the app and
|
||||||
|
// cleaned up explicitly afterwards (rather than wrapped in a rolled-back
|
||||||
|
// transaction), because every handler opens its own DB connection via
|
||||||
|
// `establish_connection`, so a transaction held by the test would not be
|
||||||
|
// visible to the handler under test. Random suffixes keep parallel test runs
|
||||||
|
// from colliding on the unique username/email/url constraints.
|
||||||
|
|
||||||
|
pub fn unique_suffix() -> String {
|
||||||
|
Uuid::new_v4().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
let new_user = NewUser::new(
|
||||||
|
format!("test_user_{suffix}"),
|
||||||
|
format!("test_{suffix}@example.test"),
|
||||||
|
password.to_string(),
|
||||||
|
);
|
||||||
|
diesel::insert_into(users::table)
|
||||||
|
.values(&new_user)
|
||||||
|
.get_result(connection)
|
||||||
|
.expect("failed to insert test user")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_user(connection: &mut PgConnection, user_id: i32) {
|
||||||
|
diesel::delete(users::table.filter(users::id.eq(user_id)))
|
||||||
|
.execute(connection)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_feed(connection: &mut PgConnection, owner_id: i32) -> Feed {
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
let new_feed = NewFeed::new(
|
||||||
|
format!("Test feed {suffix}"),
|
||||||
|
format!("https://example.test/feed/{suffix}"),
|
||||||
|
owner_id,
|
||||||
|
);
|
||||||
|
diesel::insert_into(feed::table)
|
||||||
|
.values(&new_feed)
|
||||||
|
.get_result(connection)
|
||||||
|
.expect("failed to insert test feed")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_feed(connection: &mut PgConnection, id: i32) {
|
||||||
|
diesel::delete(feed::table.filter(feed::id.eq(id)))
|
||||||
|
.execute(connection)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn insert_feed_item(connection: &mut PgConnection, owning_feed_id: i32, read: bool) -> FeedItem {
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
let new_item = NewFeedItem::new(
|
||||||
|
owning_feed_id,
|
||||||
|
format!("Content {suffix}"),
|
||||||
|
format!("Title {suffix}"),
|
||||||
|
format!("https://example.test/article/{suffix}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let item: FeedItem = diesel::insert_into(feed_item::table)
|
||||||
|
.values(&new_item)
|
||||||
|
.get_result(connection)
|
||||||
|
.expect("failed to insert test feed item");
|
||||||
|
|
||||||
|
if read {
|
||||||
|
diesel::update(&item)
|
||||||
|
.set(feed_item::read.eq(true))
|
||||||
|
.execute(connection)
|
||||||
|
.expect("failed to mark test feed item as read");
|
||||||
|
}
|
||||||
|
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_feed_item(connection: &mut PgConnection, id: i32) {
|
||||||
|
diesel::delete(feed_item::table.filter(feed_item::id.eq(id)))
|
||||||
|
.execute(connection)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
@@ -43,3 +43,69 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
|
|||||||
false => HttpResponse::Unauthorized().await.unwrap(),
|
false => HttpResponse::Unauthorized().await.unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App};
|
||||||
|
|
||||||
|
use super::login;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user, unique_suffix};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn login_succeeds_with_correct_credentials() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "correct-password");
|
||||||
|
|
||||||
|
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/login")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"username": user.username,
|
||||||
|
"password": "correct-password"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
assert!(resp.headers().contains_key("token"));
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn login_fails_with_wrong_password() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "correct-password");
|
||||||
|
|
||||||
|
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/login")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"username": user.username,
|
||||||
|
"password": "wrong-password"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::UNAUTHORIZED, resp.status());
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn login_fails_for_unknown_user() {
|
||||||
|
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/login")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"username": format!("does-not-exist-{}", unique_suffix()),
|
||||||
|
"password": "whatever"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
pub async fn logout() -> String {
|
use actix_web::HttpResponse;
|
||||||
"logout view".to_string()
|
|
||||||
|
// JWT auth is stateless and there is no token blacklist, so logging out is
|
||||||
|
// purely a client-side action (discarding the stored token). This endpoint
|
||||||
|
// exists so the frontend has something to call and gets a clean response.
|
||||||
|
pub async fn logout() -> HttpResponse {
|
||||||
|
HttpResponse::Ok().finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App};
|
||||||
|
|
||||||
|
use super::logout;
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn logout_returns_ok() {
|
||||||
|
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
|
||||||
|
let req = test::TestRequest::post().uri("/logout").to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,3 +23,61 @@ pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
|
|||||||
Err(_) => HttpResponse::Conflict().await.unwrap(),
|
Err(_) => HttpResponse::Conflict().await.unwrap(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use super::create;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::schema::users;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user, unique_suffix};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_succeeds_for_new_user() {
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
let username = format!("new_user_{suffix}");
|
||||||
|
let email = format!("new_{suffix}@example.test");
|
||||||
|
|
||||||
|
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/create")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"name": username,
|
||||||
|
"email": email,
|
||||||
|
"password": "secret"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::CREATED, resp.status());
|
||||||
|
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
diesel::delete(users::table.filter(users::username.eq(&username)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_fails_for_duplicate_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/create")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"name": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"password": "secret"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::CONFLICT, resp.status());
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,16 @@
|
|||||||
|
# --- builder ---
|
||||||
|
FROM node:20-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
|
||||||
@@ -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
+2628
-38
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -6,6 +6,7 @@
|
|||||||
"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/"
|
||||||
},
|
},
|
||||||
@@ -13,16 +14,18 @@
|
|||||||
"@mozilla/readability": "^0.4.4",
|
"@mozilla/readability": "^0.4.4",
|
||||||
"axios": "^1.5.0",
|
"axios": "^1.5.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^4.2.4",
|
"vue-router": "^4.2.4"
|
||||||
"vue-sessionstorage": "^1.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.3.2",
|
"@rushstack/eslint-patch": "^1.3.2",
|
||||||
"@vitejs/plugin-vue": "^4.3.1",
|
"@vitejs/plugin-vue": "^4.3.1",
|
||||||
"@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",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"vite": "^4.4.9"
|
"vite": "^4.4.9",
|
||||||
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-72
@@ -1,78 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
|
import AppNav from './components/AppNav.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- <header> -->
|
<AppNav v-if="route.meta.requiresAuth" />
|
||||||
<!-- <div class="wrapper"> -->
|
|
||||||
<!-- <nav> -->
|
|
||||||
<!-- <p onclick="$refs.sync">Sync</p> -->
|
|
||||||
<!-- <p>Login</p> -->
|
|
||||||
<!-- <RouterLink to="/">Home</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/about">About</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
|
|
||||||
<!-- </nav> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
<!-- </header> -->
|
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
header {
|
|
||||||
line-height: 1.5;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav p:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
nav {
|
|
||||||
text-align: left;
|
|
||||||
margin-left: -1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
font-size: 15px;
|
font-size: clamp(14px, 2.5vw, 16px);
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|||||||
+71
-6
@@ -3,7 +3,7 @@
|
|||||||
#app {
|
#app {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: 1rem;
|
||||||
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,29 @@ a,
|
|||||||
color: hsla(160, 100%, 37%, 1);
|
color: hsla(160, 100%, 37%, 1);
|
||||||
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: #3498db;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -23,6 +46,8 @@ a,
|
|||||||
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) {
|
||||||
@@ -31,18 +56,52 @@ a,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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: hsla(160, 100%, 37%, 1);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Courier New';
|
font-family: 'Courier New';
|
||||||
font-size: 22px;
|
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
||||||
|
font-weight: bold;
|
||||||
|
color: hsla(200, 90%, 45%, 1);
|
||||||
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: hsla(200, 90%, 35%, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.feed-title {
|
||||||
|
color: hsla(200, 90%, 65%, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-title:hover {
|
||||||
|
color: hsla(200, 90%, 75%, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-content {
|
.feed-content {
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||||
font-size: 20px;
|
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-content p {
|
.feed-content p {
|
||||||
@@ -51,10 +110,16 @@ a,
|
|||||||
|
|
||||||
.feed-content h3 {
|
.feed-content h3 {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
font-size: 21px;
|
font-size: clamp(1rem, 3vw, 1.3rem);
|
||||||
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: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +21,9 @@ 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: #fff;
|
||||||
@@ -34,8 +41,19 @@ input {
|
|||||||
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,74 @@
|
|||||||
|
<script setup>
|
||||||
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('user-token')
|
||||||
|
localStorage.removeItem('user-id')
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header class="app-nav">
|
||||||
|
<div class="app-nav__wrapper">
|
||||||
|
<span class="app-nav__title">RSS Reader</span>
|
||||||
|
<nav class="app-nav__links">
|
||||||
|
<RouterLink to="/feeds">Feeds</RouterLink>
|
||||||
|
<button class="app-nav__logout" @click="logout">Logout</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-nav__wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__links a {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__links a.router-link-exact-active {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__logout {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.app-nav__wrapper {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
msg: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="greetings">
|
|
||||||
<h1 class="green">{{ msg }}</h1>
|
|
||||||
<h3>
|
|
||||||
You’ve successfully created a project with
|
|
||||||
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
|
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
h1 {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.6rem;
|
|
||||||
position: relative;
|
|
||||||
top: -10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.greetings h1,
|
|
||||||
.greetings h3 {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,57 +2,40 @@
|
|||||||
import axios from 'axios'
|
import 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>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const showModal = ref(false)
|
|||||||
|
|
||||||
async function getReadable(feed, index) {
|
async function getReadable(feed, index) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("feeds/read", {
|
const response = await axios.post("/api/v1/article/read", {
|
||||||
url: feed.url
|
url: feed.url
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -22,6 +22,12 @@ async function getReadable(feed, index) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
|
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);
|
||||||
const article = new Readability(doc).parse();
|
const article = new Readability(doc).parse();
|
||||||
feeds.value[index].content = article.content;
|
feeds.value[index].content = article.content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -32,7 +38,7 @@ async function getReadable(feed, index) {
|
|||||||
|
|
||||||
async function markRead(id) {
|
async function markRead(id) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.put("feeds/read/" + id,
|
const response = await axios.put("/api/v1/article/read/" + id,
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -61,14 +67,14 @@ function showMessageForXSeconds(text, seconds) {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const user_id = localStorage.getItem("user-id")
|
const user_id = localStorage.getItem("user-id")
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("feeds/get/" + user_id, {
|
const response = await axios.get("/api/v1/article/get/" + user_id, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'user-token': localStorage.getItem("user-token")
|
'user-token': localStorage.getItem("user-token")
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
response.data.feeds.forEach(feed => {
|
response.data.feeds.forEach(feed => {
|
||||||
feeds.value.push(...feed.items);
|
feed.items.forEach(item => feeds.value.push({ ...item, feedTitle: feed.title }));
|
||||||
});
|
});
|
||||||
await nextTick();
|
await nextTick();
|
||||||
setupIntersectionObserver();
|
setupIntersectionObserver();
|
||||||
@@ -80,7 +86,7 @@ const fetchData = async () => {
|
|||||||
|
|
||||||
async function sync() {
|
async function sync() {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('feeds/sync', {
|
const response = await axios.post('/api/v1/article/sync', {
|
||||||
user_id: parseInt(localStorage.getItem("user-id"))
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,21 +125,15 @@ function setupIntersectionObserver() {
|
|||||||
|
|
||||||
async function handleIntersection(entries) {
|
async function handleIntersection(entries) {
|
||||||
// The callback function for when the target element enters or exits the viewport
|
// The callback function for when the target element enters or exits the viewport
|
||||||
entries.forEach(entry => {
|
for (const entry of entries) {
|
||||||
if (entry.isIntersecting) {
|
// An article that has scrolled above the viewport (not intersecting,
|
||||||
console.log('Element is in sight');
|
// bounding box above the top edge) has been read — mark it and remove it.
|
||||||
} else if (initialLoad === true) {
|
if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) {
|
||||||
console.log(entry.isIntersecting)
|
await markRead(feeds.value[entry.target.id].id)
|
||||||
// Element is out of sight
|
removeFeed(entry.target.id)
|
||||||
if (entry.isVisible === false && entry.boundingClientRect.y < 0) {
|
document.getElementById(0)?.scrollIntoView()
|
||||||
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) {
|
function removeFeed(index) {
|
||||||
@@ -142,9 +142,9 @@ function removeFeed(index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let initialLoad = false
|
let initialLoad = false
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
initialLoad = false
|
initialLoad = false
|
||||||
fetchData().await
|
await fetchData()
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
initialLoad = true
|
initialLoad = true
|
||||||
console.log('set to true')
|
console.log('set to true')
|
||||||
@@ -153,20 +153,11 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<div class="feed-actions">
|
||||||
<div class="wrapper">
|
<p @click="sync">Sync</p>
|
||||||
<nav>
|
<p @click="showModal = true">Add RSS</p>
|
||||||
<p @click="sync">Sync</p>
|
</div>
|
||||||
<!-- <p @click="updateShow(true)">Add RSS</p> -->
|
|
||||||
<p @click="showModal = true">Add RSS</p>
|
|
||||||
<!-- <RouterLink to="/">Home</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/about">About</RouterLink> -->
|
|
||||||
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<!-- use the modal component, pass in the prop -->
|
|
||||||
<modal :show="showModal" @close="showModal = false">
|
<modal :show="showModal" @close="showModal = false">
|
||||||
<template #header>
|
<template #header>
|
||||||
<h3>Add RSS Feed</h3>
|
<h3>Add RSS Feed</h3>
|
||||||
@@ -174,12 +165,13 @@ onMounted(() => {
|
|||||||
</modal>
|
</modal>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
<div>
|
<div>
|
||||||
<h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
|
<h1>Feeds</h1>
|
||||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
<div v-if="showMessage" class="message">{{ message }}</div>
|
||||||
<div id='article' class='article'>
|
<div id='article' class='article'>
|
||||||
<p v-if="feeds.length == 0">No unread articles.</p>
|
<p v-if="feeds.length == 0">No unread articles.</p>
|
||||||
<template v-for="( feed, index ) in feeds ">
|
<template v-for="( feed, index ) in feeds ">
|
||||||
<div v-bind:id="index" class="observe">
|
<div v-bind:id="index" class="observe">
|
||||||
|
<p class="feed-source">{{ feed.feedTitle }}</p>
|
||||||
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
||||||
<h3>{{ feed.timestamp }}</h3>
|
<h3>{{ feed.timestamp }}</h3>
|
||||||
<p class="feed-content" v-html='feed.content'></p>
|
<p class="feed-content" v-html='feed.content'></p>
|
||||||
|
|||||||
@@ -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,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import AppNav from '../AppNav.vue'
|
||||||
|
|
||||||
|
describe('AppNav', () => {
|
||||||
|
let router
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
localStorage.setItem('user-token', 'abc123')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears stored credentials and redirects to login on logout', async () => {
|
||||||
|
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,78 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import axios from 'axios'
|
||||||
|
import RssFeeds from '../RssFeeds.vue'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
// jsdom does not implement IntersectionObserver, but the component sets one up
|
||||||
|
// once the feed list has rendered.
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
|
describe('RssFeeds', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'test-token')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
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('No unread articles.')
|
||||||
|
})
|
||||||
|
|
||||||
|
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('No unread articles.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs feeds for the current user', async () => {
|
||||||
|
axios.get.mockResolvedValue({ data: { feeds: [] } })
|
||||||
|
axios.post.mockResolvedValueOnce({ status: 200 })
|
||||||
|
|
||||||
|
const wrapper = mount(RssFeeds)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
await wrapper.find('.feed-actions p').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/article/sync',
|
||||||
|
{ user_id: 7 },
|
||||||
|
expect.anything(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
|
||||||
<template>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
aria-hidden="true"
|
|
||||||
role="img"
|
|
||||||
class="iconify iconify--mdi"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
|
||||||
fill="currentColor"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
@@ -15,7 +15,7 @@ async function save() {
|
|||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
console.log('saved ' + url.value)
|
console.log('saved ' + url.value)
|
||||||
try {
|
try {
|
||||||
const response = await axios.post("feeds/add", {
|
const response = await axios.post("/api/v1/article/add", {
|
||||||
url: url.value,
|
url: url.value,
|
||||||
title: title.value,
|
title: title.value,
|
||||||
user_id: parseInt(localStorage.getItem("user-id"))
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
@@ -44,7 +44,7 @@ async function save() {
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<slot name="header">Add RSS Feed</slot>
|
<slot name="header">Add RSS Feed</slot>
|
||||||
</div>
|
</div>
|
||||||
<form @submit.prevent="submitForm">
|
<form @submit.prevent="save">
|
||||||
<label for="name">URL:</label>
|
<label for="name">URL:</label>
|
||||||
<input v-model="url" id="url" type="text" required />
|
<input v-model="url" id="url" type="text" required />
|
||||||
<label for="name">Title:</label>
|
<label for="name">Title:</label>
|
||||||
@@ -56,7 +56,7 @@ async function save() {
|
|||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<button type="submit" @click="save">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button class="modal-default-button" @click="$emit('close')">Close</button>
|
<button class="modal-default-button" @click="$emit('close')">Close</button>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import axios from 'axios'
|
||||||
|
import AddUrl from '../AddUrl.vue'
|
||||||
|
|
||||||
|
vi.mock('axios')
|
||||||
|
|
||||||
|
describe('AddUrl', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('user-token', 'test-token')
|
||||||
|
localStorage.setItem('user-id', '7')
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts the entered url and title and shows a success message', async () => {
|
||||||
|
axios.post.mockResolvedValueOnce({ status: 201 })
|
||||||
|
|
||||||
|
const wrapper = mount(AddUrl, { props: { show: true } })
|
||||||
|
await wrapper.find('#url').setValue('https://example.test/feed.xml')
|
||||||
|
await wrapper.find('#title').setValue('Example feed')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(axios.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/article/add',
|
||||||
|
{ url: 'https://example.test/feed.xml', title: 'Example feed', user_id: 7 },
|
||||||
|
expect.anything(),
|
||||||
|
)
|
||||||
|
expect(wrapper.text()).toContain('saved successfully')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces the error message when the request fails', async () => {
|
||||||
|
axios.post.mockRejectedValueOnce({ message: 'Network Error' })
|
||||||
|
|
||||||
|
const wrapper = mount(AddUrl, { props: { show: true } })
|
||||||
|
await wrapper.find('#url').setValue('https://example.test/feed.xml')
|
||||||
|
await wrapper.find('#title').setValue('Example feed')
|
||||||
|
await wrapper.find('form').trigger('submit')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Network Error')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import router from '../index'
|
||||||
|
|
||||||
|
describe('router auth guard', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects unauthenticated users away from protected routes', async () => {
|
||||||
|
await router.push('/feeds')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('login')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lets authenticated users reach protected routes', async () => {
|
||||||
|
localStorage.setItem('user-token', 'abc123')
|
||||||
|
|
||||||
|
await router.push('/feeds')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('feeds')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('redirects the root path to the feeds route', async () => {
|
||||||
|
localStorage.setItem('user-token', 'abc123')
|
||||||
|
|
||||||
|
await router.push('/')
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('feeds')
|
||||||
|
})
|
||||||
|
})
|
||||||
+3
-17
@@ -1,32 +1,20 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import HomeView from '../views/HomeView.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
redirect: '/feeds',
|
||||||
component: HomeView,
|
|
||||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/feeds',
|
path: '/feeds',
|
||||||
name: 'feeds',
|
name: 'feeds',
|
||||||
// route level code-splitting
|
// route level code-splitting
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
// this generates a separate chunk (Feed.[hash].js) for this route
|
||||||
// which is lazy-loaded when the route is visited.
|
// which is lazy-loaded when the route is visited.
|
||||||
component: () => import('../views/FeedView.vue'),
|
component: () => import('../views/FeedView.vue'),
|
||||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
meta: { requiresAuth: true },
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/about',
|
|
||||||
name: 'about',
|
|
||||||
// route level code-splitting
|
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
|
||||||
// which is lazy-loaded when the route is visited.
|
|
||||||
component: () => import('../views/AboutView.vue'),
|
|
||||||
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -38,13 +26,11 @@ const router = createRouter({
|
|||||||
})
|
})
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.meta.requiresAuth) {
|
if (to.meta.requiresAuth) {
|
||||||
// TODO Check if the user is authenticated (e.g., check for a valid token)
|
|
||||||
let isAuthenticated = false;
|
let isAuthenticated = false;
|
||||||
if (localStorage.getItem("user-token") != null){
|
if (localStorage.getItem("user-token") != null){
|
||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// Redirect to the login page
|
// Redirect to the login page
|
||||||
next('/login');
|
next('/login');
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="about">
|
|
||||||
<h1>This is an about page</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
.about {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import TheWelcome from '../components/TheWelcome.vue'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main>
|
|
||||||
<TheWelcome />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
+6
-28
@@ -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