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
+10 -4
View File
@@ -3,29 +3,36 @@ extern crate jwt;
extern crate sha2;
use std::collections::BTreeMap;
use std::env;
use actix_web::HttpRequest;
use dotenv::dotenv;
use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use sha2::Sha256;
pub struct JwtToken {
pub user_id: i32,
pub body: String,
}
type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 {
dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
HmacSha256::new_from_slice(secret.as_bytes()).unwrap()
}
impl JwtToken {
pub fn encode(user_id: i32) -> String {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
let key: HmacSha256 = signing_key();
let mut claims = BTreeMap::new();
claims.insert("user_id", user_id);
claims.sign_with_key(&key).unwrap()
}
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
let key: HmacSha256 = signing_key();
let token_str: &str = encoded_token.as_str();
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
VerifyWithKey::verify_with_key(token_str, &key);
@@ -36,7 +43,6 @@ impl JwtToken {
let claims = token.claims();
Ok(JwtToken {
user_id: claims["user_id"],
body: encoded_token,
})
}
Err(_err) => Err("could not decode token"),
+2 -2
View File
@@ -4,7 +4,7 @@ pub mod processes;
use crate::auth::processes::check_password;
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) {
Ok(token) => check_password(token),
Err(message) => Err(message),
@@ -26,7 +26,7 @@ mod mod_test {
.to_srv_request();
match process_token(&request) {
Ok(message) => assert_eq!("passed", message),
Ok(user_id) => assert_eq!(32, user_id),
Err(_) => panic!("process token failed"),
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
use super::jwt;
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) {
Ok(_token) => Ok(String::from("passed")),
Ok(token) => Ok(token.user_id),
Err(message) => Err(message),
}
}
@@ -37,7 +37,7 @@ mod processes_test {
let result = check_password(password_string);
match result {
Ok(check) => assert_eq!("passed", check),
Ok(user_id) => assert_eq!(32, user_id),
_ => panic!("Check correct password failed."),
}
}
+9
View File
@@ -1,8 +1,11 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub fn establish_connection() -> PgConnection {
dotenv().ok();
@@ -10,3 +13,9 @@ pub fn establish_connection() -> PgConnection {
PgConnection::establish(&database_url)
.unwrap_or_else(|e| panic!("Error connecting to database {}: {}", database_url, e))
}
pub fn run_migrations(connection: &mut PgConnection) {
connection
.run_pending_migrations(MIGRATIONS)
.expect("Failed to run database migrations");
}
+1 -1
View File
@@ -1,5 +1,5 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize;
use crate::reader::structs::feed::FeedAggregate;
-1
View File
@@ -1,7 +1,6 @@
pub mod articles;
pub mod login;
pub mod new_feed;
pub mod new_feed_item;
pub mod new_user;
pub mod read_feed_item;
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 reqwest::StatusCode;
use serde::Serialize;
#[derive(Serialize)]
+24 -3
View File
@@ -1,22 +1,39 @@
extern crate diesel;
extern crate dotenv;
use actix_cors::Cors;
use actix_service::Service;
use actix_web::{App, HttpResponse, HttpServer};
use dotenv::dotenv;
use futures::future::{ok, Either};
use std::env;
mod auth;
mod database;
mod json_serialization;
mod models;
mod reader;
mod schema;
#[cfg(test)]
mod test_helpers;
mod views;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
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()
.wrap_fn(|req, srv| {
let mut passed: bool;
@@ -25,7 +42,10 @@ async fn main() -> std::io::Result<()> {
log::info!("Request Url: {}", request_url);
if req.path().contains("/article/") {
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,
}
} else {
@@ -52,10 +72,11 @@ async fn main() -> std::io::Result<()> {
Ok(result)
}
})
.wrap(cors)
.configure(views::views_factory);
app
})
.bind("127.0.0.1:8001")?
.bind("0.0.0.0:8001")?
.run()
.await
}
+1 -1
View File
@@ -18,6 +18,6 @@ pub struct User {
impl User {
pub fn verify(self, password: String) -> bool {
return verify(password.as_str(), &self.password).unwrap();
verify(password.as_str(), &self.password).unwrap()
}
}
+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)
}
#[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()
}
#[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};
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
// let format_string = "%a, %d %b %Y %H:%M:%S %z";
let format_string = "%Y-%m-%dT%H:%M:%S%Z";
let result = parse(date_str).unwrap();
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),
},
}
}
if let Ok(result) = parse(date_str) {
log::info!("Date: {:?}", result);
return Ok(result.with_timezone(&Local).naive_local());
}
DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local())
}
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let item_title = item.title.clone().unwrap();
log::info!("Create feed item: {}", item_title);
let base_content: &str = match item.content() {
Some(c) => c,
None => match item.description() {
Some(c) => c,
None => "",
},
};
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
let frag = Html::parse_fragment(base_content);
let mut content = "".to_string();
@@ -133,3 +114,79 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
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(),
}
}
#[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 {
"logout view".to_string()
use actix_web::HttpResponse;
// JWT auth is stateless and there is no token blacklist, so logging out is
// purely a client-side action (discarding the stored token). This endpoint
// exists so the frontend has something to call and gets a clean response.
pub async fn logout() -> HttpResponse {
HttpResponse::Ok().finish()
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::logout;
#[actix_web::test]
async fn logout_returns_ok() {
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
let req = test::TestRequest::post().uri("/logout").to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
}
}
+58
View File
@@ -23,3 +23,61 @@ pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
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);
}
}