claude rework

This commit is contained in:
2026-06-07 15:43:43 +02:00
parent a2e2ff141e
commit b4874ad318
63 changed files with 5945 additions and 1752 deletions
+5
View File
@@ -0,0 +1,5 @@
target
vue/node_modules
vue/dist
.git
.env
+5 -1
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgres://admin:changeme@localhost/rss
JWT_SECRET=change-this-to-a-long-random-string
FRONTEND_ORIGIN=http://localhost:5173
RUST_LOG=info
POSTGRES_PASSWORD=changeme
+2
View File
@@ -1 +1,3 @@
/target /target
.env
.claude
Generated
+2051 -950
View File
File diff suppressed because it is too large Load Diff
+21 -20
View File
@@ -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
View File
@@ -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"]
+140 -12
View File
@@ -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
View File
@@ -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:
-36
View File
@@ -1,36 +0,0 @@
const loginButton = document.getElementById('loginButton');
const username = document.getElementById(
'defaultLoginFormUsername');
const password = document.getElementById(
'defaultLoginFormPassword');
const message = document.getElementById("loginMessage");
loginButton.addEventListener("click", () => {
let xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/auth/login", true);
xhr.setRequestHeader("Content-Type",
"application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
let token = xhr.getResponseHeader("token");
localStorage.setItem("user-token", token);
console.log("status 200: " + document.location.origin);
window.location.replace(
document.location.origin);
} else {
message.innerText =
"login failed please try again";
}
}
};
let data = JSON.stringify({
"username": username.value,
"password": password.value
});
xhr.send(data);
message.innerText = "logging in";
})
-59
View File
@@ -1,59 +0,0 @@
if (localStorage.getItem("user-token") == null) {
window.location.replace(document.location.origin + "/login");
} else {
getArticles();
}
function apiCall(url, method) {
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.addEventListener("readystatechange", function () {
if (this.readyState === this.DONE) {
if (this.status === 401) {
window.location.replace(document.location.origin + "/login/");
} else {
runRenderProcess(JSON.parse(this.responseText));
localStorage.setItem("item-cache-date", new Date());
localStorage.setItem("item-cache-data", this.responseText);
}
}
});
xhr.open(method, "/api/v1" + url);
xhr.setRequestHeader("content-type", "application/json");
xhr.setRequestHeader("user-token", localStorage.getItem("user-token"));
return xhr;
}
function runRenderProcess(data) {
params = renderArticle(data["feeds"]);
// document.getElementById("mainContainer").innerHtml = params;
}
function renderArticle(feeds) {
let placeholder = "<div>";
for (i = 0; i < feeds.length; i++) {
let title = feeds[i]["title"];
placeholder += '<div class="itemContainer">' + "<p>" + title + "</p>";
let items = feeds[i]["items"];
for (t = 0; t < items.length; t++) {
placeholder +=
'<div class="article">' +
"<h2>" +
items[t].title +
"</h2>" +
"<p>" +
items[t].content +
"</p>";
}
placeholder += "</div>" + "</div>";
}
placeholder += "</div>";
document.getElementById("mainContainer").innerHTML = placeholder;
}
function getArticles() {
let call = apiCall("/article/get", "GET");
call.send();
}
+10 -4
View File
@@ -3,29 +3,36 @@ extern crate jwt;
extern crate sha2; extern crate sha2;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::env;
use actix_web::HttpRequest; use actix_web::HttpRequest;
use dotenv::dotenv;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey}; use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use sha2::Sha256; use sha2::Sha256;
pub struct JwtToken { pub struct JwtToken {
pub user_id: i32, pub user_id: i32,
pub body: String,
} }
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 {
dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
HmacSha256::new_from_slice(secret.as_bytes()).unwrap()
}
impl JwtToken { impl JwtToken {
pub fn encode(user_id: i32) -> String { pub fn encode(user_id: i32) -> String {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap(); let key: HmacSha256 = signing_key();
let mut claims = BTreeMap::new(); let mut claims = BTreeMap::new();
claims.insert("user_id", user_id); claims.insert("user_id", user_id);
claims.sign_with_key(&key).unwrap() claims.sign_with_key(&key).unwrap()
} }
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> { pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap(); let key: HmacSha256 = signing_key();
let token_str: &str = encoded_token.as_str(); let token_str: &str = encoded_token.as_str();
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> = let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
VerifyWithKey::verify_with_key(token_str, &key); VerifyWithKey::verify_with_key(token_str, &key);
@@ -36,7 +43,6 @@ impl JwtToken {
let claims = token.claims(); let claims = token.claims();
Ok(JwtToken { Ok(JwtToken {
user_id: claims["user_id"], user_id: claims["user_id"],
body: encoded_token,
}) })
} }
Err(_err) => Err("could not decode token"), Err(_err) => Err("could not decode token"),
+2 -2
View File
@@ -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"),
} }
} }
+3 -3
View File
@@ -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."),
} }
} }
+9
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder}; use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize; use serde::Serialize;
use crate::reader::structs::feed::FeedAggregate; use crate::reader::structs::feed::FeedAggregate;
-1
View File
@@ -1,7 +1,6 @@
pub mod articles; pub mod articles;
pub mod login; pub mod login;
pub mod new_feed; pub mod new_feed;
pub mod new_feed_item;
pub mod new_user; pub mod new_user;
pub mod read_feed_item; pub mod read_feed_item;
pub mod readable; pub mod readable;
-9
View File
@@ -1,9 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewFeedItemSchema {
pub content: String,
pub feed_id: i32,
pub url: String,
pub title: String,
}
+1 -1
View File
@@ -1,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
View File
@@ -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
} }
+1 -1
View File
@@ -18,6 +18,6 @@ pub struct User {
impl User { impl User {
pub fn verify(self, password: String) -> bool { pub fn verify(self, password: String) -> bool {
return verify(password.as_str(), &self.password).unwrap(); verify(password.as_str(), &self.password).unwrap()
} }
} }
+25
View File
@@ -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());
}
}
+41
View File
@@ -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);
}
}
+52
View File
@@ -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
View File
@@ -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();
}
}
+91
View File
@@ -0,0 +1,91 @@
#![cfg(test)]
use diesel::prelude::*;
use uuid::Uuid;
use crate::models::feed::new_feed::NewFeed;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::{feed, feed_item, users};
// Test fixtures are written through the same models/schema as the app and
// cleaned up explicitly afterwards (rather than wrapped in a rolled-back
// transaction), because every handler opens its own DB connection via
// `establish_connection`, so a transaction held by the test would not be
// visible to the handler under test. Random suffixes keep parallel test runs
// from colliding on the unique username/email/url constraints.
pub fn unique_suffix() -> String {
Uuid::new_v4().to_string()
}
pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("test_user_{suffix}"),
format!("test_{suffix}@example.test"),
password.to_string(),
);
diesel::insert_into(users::table)
.values(&new_user)
.get_result(connection)
.expect("failed to insert test user")
}
pub fn delete_user(connection: &mut PgConnection, user_id: i32) {
diesel::delete(users::table.filter(users::id.eq(user_id)))
.execute(connection)
.ok();
}
pub fn insert_feed(connection: &mut PgConnection, owner_id: i32) -> Feed {
let suffix = unique_suffix();
let new_feed = NewFeed::new(
format!("Test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
owner_id,
);
diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(connection)
.expect("failed to insert test feed")
}
pub fn delete_feed(connection: &mut PgConnection, id: i32) {
diesel::delete(feed::table.filter(feed::id.eq(id)))
.execute(connection)
.ok();
}
pub fn insert_feed_item(connection: &mut PgConnection, owning_feed_id: i32, read: bool) -> FeedItem {
let suffix = unique_suffix();
let new_item = NewFeedItem::new(
owning_feed_id,
format!("Content {suffix}"),
format!("Title {suffix}"),
format!("https://example.test/article/{suffix}"),
None,
);
let item: FeedItem = diesel::insert_into(feed_item::table)
.values(&new_item)
.get_result(connection)
.expect("failed to insert test feed item");
if read {
diesel::update(&item)
.set(feed_item::read.eq(true))
.execute(connection)
.expect("failed to mark test feed item as read");
}
item
}
pub fn delete_feed_item(connection: &mut PgConnection, id: i32) {
diesel::delete(feed_item::table.filter(feed_item::id.eq(id)))
.execute(connection)
.ok();
}
+66
View File
@@ -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());
}
}
+24 -2
View File
@@ -1,3 +1,25 @@
pub async fn logout() -> String { use actix_web::HttpResponse;
"logout view".to_string()
// JWT auth is stateless and there is no token blacklist, so logging out is
// purely a client-side action (discarding the stored token). This endpoint
// exists so the frontend has something to call and gets a clean response.
pub async fn logout() -> HttpResponse {
HttpResponse::Ok().finish()
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::logout;
#[actix_web::test]
async fn logout_returns_ok() {
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
let req = test::TestRequest::post().uri("/logout").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
}
} }
+58
View File
@@ -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);
}
}
-12
View File
@@ -1,12 +0,0 @@
.header {
background: #034f84;
margin-bottom: 0.3rem;
}
.header p {
color: white;
display: inline-block;
margin: 0.5rem;
margin-right: 0.4rem;
margin-left: 0.4rem;
}
-4
View File
@@ -1,4 +0,0 @@
<div class="header">
<p>complete tasks: </p><p id="completeNum"></p>
<p>pending tasks: </p><p id="pendingNum"></p>
</div>
-51
View File
@@ -1,51 +0,0 @@
<html>
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1.0" />
<meta name="description" content="This is a simple to do app" />
<meta httpEquiv="X-UA-Compatiable" content="ie=edge" />
<title>Login</title>
</head>
<style>
{{BASE_CSS}}
{{CSS}}
.loginButtonStyle {
display: inline-block;
background: #f7786b;
border: none;
padding: 0.5rem;
padding-left: 2rem;
padding-right: 2rem;
color: white;
}
.loginButtonStyle:hover {
background: #f7686b;
color: black;
}
</style>
<body>
<div class="mainContainer">
<h2 class="ContainerTitle" style="text-align:center;">Login</h2>
<p id="loginMessage" class="FeedbackMessage" style="text-align:center;"></p>
<form style="text-align:center;" action="submit">
<input type="text" value="" placeholder="Username" class="formInputContainer"
id="defaultLoginFormUsername"><br>
<p></p>
<input type="password" value="" placeholder="Password" class="formInputContainer"
id="defaultLoginFormPassword"><br><br>
<input type="button" value="Submit" class="loginButtonStyle" id="loginButton" style="text-align:center;">
</form>
</div>
</body>
<script>
{{JAVASCRIPT}}
</script>
</html>
-26
View File
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width,
initial-scale=1.0"
/>
<meta httpEquiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="This is a simple to do app" />
<title>ToDo App</title>
</head>
<style>
BASE_CSS
CSS
HEADER_CSS
</style>
<body>
<div id="mainContainer" class="mainContainer"></div>
</body>
<script>
JAVASCRIPT;
</script>
</html>
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+16
View File
@@ -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
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2628 -38
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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
View File
@@ -1,78 +1,11 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import AppNav from './components/AppNav.vue'
const route = useRoute()
</script> </script>
<template> <template>
<!-- <header> --> <AppNav v-if="route.meta.requiresAuth" />
<!-- <div class="wrapper"> -->
<!-- <nav> -->
<!-- <p onclick="$refs.sync">Sync</p> -->
<!-- <p>Login</p> -->
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
<!-- </nav> -->
<!-- </div> -->
<!-- </header> -->
<RouterView /> <RouterView />
</template> </template>
<style>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav p.router-link-exact-active {
color: var(--color-text);
}
nav p.router-link-exact-active:hover {
background-color: transparent;
}
nav p {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
nav p:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>
+1 -1
View File
@@ -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
View File
@@ -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;
}
} }
+22 -4
View File
@@ -1,6 +1,11 @@
input { input {
margin: 15px; display: block;
width: 100%;
min-height: 44px;
margin: 0.5rem 0 1rem;
padding: 0.5rem;
font-size: 1rem;
} }
.modal-mask { .modal-mask {
@@ -16,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;
} }
/* /*
+74
View File
@@ -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>
-44
View File
@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true
}
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
+48 -33
View File
@@ -2,57 +2,40 @@
import axios from 'axios' import axios from 'axios'
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const error = ref('')
const router = useRouter() const router = useRouter()
async function login() { async function login() {
error.value = ''
const loginData = {
"username": username.value,
"password": password.value,
}
const jsonData = JSON.stringify(loginData)
console.log('test')
try { try {
const response = await axios.post('login/rss', jsonData, { const response = await axios.post('/api/v1/auth/login', {
username: username.value,
password: password.value,
}, {
headers: { headers: {
'Content-Type': 'application/json', // Set the content type to JSON 'Content-Type': 'application/json',
'crossDomain': true,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
'strict-origin-when-cross-origin': false
}, },
}); });
// Handle the response data here
console.log('Response:', response.data);
// You can also access the HTTP status code
console.log('HTTP Status Code:', response.status);
if (response.status == 200) { if (response.status == 200) {
let token = response.headers.token localStorage.setItem("user-token", response.headers.token)
let user_id = response.headers.user_id localStorage.setItem("user-id", response.headers.user_id)
localStorage.setItem("user-token", token) 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>
+27 -35
View File
@@ -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>
-86
View File
@@ -1,86 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
-86
View File
@@ -1,86 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -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>
-7
View File
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
-19
View File
@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+3 -3
View File
@@ -15,7 +15,7 @@ async function save() {
submitted.value = true; submitted.value = true;
console.log('saved ' + url.value) console.log('saved ' + url.value)
try { try {
const response = await axios.post("feeds/add", { const response = await axios.post("/api/v1/article/add", {
url: url.value, url: url.value,
title: title.value, title: title.value,
user_id: parseInt(localStorage.getItem("user-id")) user_id: parseInt(localStorage.getItem("user-id"))
@@ -44,7 +44,7 @@ async function save() {
<div class="modal-header"> <div class="modal-header">
<slot name="header">Add RSS Feed</slot> <slot name="header">Add RSS Feed</slot>
</div> </div>
<form @submit.prevent="submitForm"> <form @submit.prevent="save">
<label for="name">URL:</label> <label for="name">URL:</label>
<input v-model="url" id="url" type="text" required /> <input v-model="url" id="url" type="text" required />
<label for="name">Title:</label> <label for="name">Title:</label>
@@ -56,7 +56,7 @@ async function save() {
<div class="modal-footer"> <div class="modal-footer">
<slot name="footer"> <slot name="footer">
<button type="submit" @click="save">Save</button> <button type="submit">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button> <button class="modal-default-button" @click="$emit('close')">Close</button>
</slot> </slot>
</div> </div>
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AddUrl from '../AddUrl.vue'
vi.mock('axios')
describe('AddUrl', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
it('posts the entered url and title and shows a success message', async () => {
axios.post.mockResolvedValueOnce({ status: 201 })
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/add',
{ url: 'https://example.test/feed.xml', title: 'Example feed', user_id: 7 },
expect.anything(),
)
expect(wrapper.text()).toContain('saved successfully')
})
it('surfaces the error message when the request fails', async () => {
axios.post.mockRejectedValueOnce({ message: 'Network Error' })
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Network Error')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest'
import router from '../index'
describe('router auth guard', () => {
beforeEach(() => {
localStorage.clear()
})
it('redirects unauthenticated users away from protected routes', async () => {
await router.push('/feeds')
expect(router.currentRoute.value.name).toBe('login')
})
it('lets authenticated users reach protected routes', async () => {
localStorage.setItem('user-token', 'abc123')
await router.push('/feeds')
expect(router.currentRoute.value.name).toBe('feeds')
})
it('redirects the root path to the feeds route', async () => {
localStorage.setItem('user-token', 'abc123')
await router.push('/')
expect(router.currentRoute.value.name).toBe('feeds')
})
})
+3 -17
View File
@@ -1,32 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'home', redirect: '/feeds',
component: HomeView,
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
}, },
{ {
path: '/feeds', path: '/feeds',
name: 'feeds', name: 'feeds',
// route level code-splitting // route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route // this generates a separate chunk (Feed.[hash].js) for this route
// which is lazy-loaded when the route is visited. // which is lazy-loaded when the route is visited.
component: () => import('../views/FeedView.vue'), component: () => import('../views/FeedView.vue'),
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication meta: { requiresAuth: true },
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue'),
meta: { requiresAuth: true }, // Add a meta field to indicate that this route requires authentication
}, },
{ {
path: '/login', path: '/login',
@@ -38,13 +26,11 @@ const router = createRouter({
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) { if (to.meta.requiresAuth) {
// TODO Check if the user is authenticated (e.g., check for a valid token)
let isAuthenticated = false; let isAuthenticated = false;
if (localStorage.getItem("user-token") != null){ if (localStorage.getItem("user-token") != null){
isAuthenticated = true; isAuthenticated = true;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Redirect to the login page // Redirect to the login page
next('/login'); next('/login');
-15
View File
@@ -1,15 +0,0 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
-9
View File
@@ -1,9 +0,0 @@
<script setup>
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>
+6 -28
View File
@@ -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,
}, },
}) })