added anyhow, improve hamburger menu, improve dw articles

This commit is contained in:
2026-06-10 18:51:55 +02:00
parent 0420cf0dd5
commit 52ea84747a
22 changed files with 226 additions and 91 deletions
Generated
+1
View File
@@ -2278,6 +2278,7 @@ dependencies = [
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"actix-web", "actix-web",
"anyhow",
"bcrypt", "bcrypt",
"chrono", "chrono",
"dateparser", "dateparser",
+1
View File
@@ -6,6 +6,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
anyhow = "1"
reqwest = { version = "0.13", features = ["json", "blocking"] } reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.13" } rss = { version = "2.0.13" }
+10 -3
View File
@@ -20,7 +20,8 @@ type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 { fn signing_key() -> HmacSha256 {
dotenv().ok(); dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
HmacSha256::new_from_slice(secret.as_bytes()).unwrap() // HMAC-SHA256 accepts a key of any length, so this cannot fail.
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length")
} }
impl JwtToken { impl JwtToken {
@@ -28,7 +29,10 @@ impl JwtToken {
let key: HmacSha256 = signing_key(); 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() // Signing a simple map of claims with a valid HMAC key cannot fail.
claims
.sign_with_key(&key)
.expect("signing claims with a valid HMAC key cannot fail")
} }
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> { pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
@@ -52,7 +56,10 @@ impl JwtToken {
#[allow(dead_code)] #[allow(dead_code)]
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> { pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
match request.headers().get("user-token") { match request.headers().get("user-token") {
Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())), Some(token) => match token.to_str() {
Ok(token_str) => JwtToken::decode(String::from(token_str)),
Err(_) => Err("token header is not valid text"),
},
None => Err("There is no token"), None => Err("There is no token"),
} }
} }
+34
View File
@@ -0,0 +1,34 @@
use actix_web::{HttpResponse, ResponseError};
use std::fmt;
/// Wraps any error so it can be returned with `?` from request handlers.
/// Always surfaces as a 500 to the client; the real error is logged.
pub struct AppError(anyhow::Error);
impl fmt::Debug for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
log::error!("Unhandled error: {:?}", self.0);
HttpResponse::InternalServerError().finish()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError(err.into())
}
}
+7 -2
View File
@@ -13,7 +13,12 @@ impl Responder for Articles {
type Body = String; type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap(); match serde_json::to_string(&self) {
HttpResponse::with_body(StatusCode::OK, body) Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
} }
} }
+7 -2
View File
@@ -18,7 +18,12 @@ impl Responder for FeedInfoList {
type Body = String; type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap(); match serde_json::to_string(&self) {
HttpResponse::with_body(StatusCode::OK, body) Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
} }
} }
+7 -2
View File
@@ -11,7 +11,12 @@ impl Responder for Readable {
type Body = String; type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap(); match serde_json::to_string(&self) {
HttpResponse::with_body(StatusCode::OK, body) Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
} }
} }
+1
View File
@@ -9,6 +9,7 @@ use futures::future::{ok, Either};
use std::env; use std::env;
mod auth; mod auth;
mod database; mod database;
mod error;
mod json_serialization; mod json_serialization;
mod models; mod models;
mod reader; mod reader;
+4 -4
View File
@@ -16,14 +16,14 @@ pub struct NewUser {
} }
impl NewUser { impl NewUser {
pub fn new(username: String, email: String, password: String) -> NewUser { pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap(); let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
NewUser { Ok(NewUser {
username, username,
email, email,
password: hashed_password, password: hashed_password,
unique_id: uuid.to_string(), unique_id: uuid.to_string(),
} })
} }
} }
+2 -2
View File
@@ -17,7 +17,7 @@ pub struct User {
} }
impl User { impl User {
pub fn verify(self, password: String) -> bool { pub fn verify(self, password: String) -> anyhow::Result<bool> {
verify(password.as_str(), &self.password).unwrap() Ok(verify(password.as_str(), &self.password)?)
} }
} }
+4 -4
View File
@@ -19,12 +19,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
Ok(channel) => { Ok(channel) => {
log::info!("valid channel"); log::info!("valid channel");
if channel.items.is_empty() { if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap(); return HttpResponse::ServiceUnavailable().finish();
} }
} }
Err(e) => { Err(e) => {
log::error!("{:?}", e); log::error!("{:?}", e);
return HttpResponse::NotFound().await.unwrap(); return HttpResponse::NotFound().finish();
} }
} }
@@ -35,10 +35,10 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
.execute(&mut connection); .execute(&mut connection);
match insert_result { match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(), Ok(_) => HttpResponse::Created().finish(),
Err(e) => { Err(e) => {
log::error!("{e}"); log::error!("{e}");
HttpResponse::Conflict().await.unwrap() HttpResponse::Conflict().finish()
} }
} }
} }
+5 -6
View File
@@ -1,3 +1,4 @@
use crate::error::AppError;
use crate::json_serialization::user::JsonUser; use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed; use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::rss_feed_item::FeedItem; use crate::models::feed_item::rss_feed_item::FeedItem;
@@ -15,7 +16,7 @@ use diesel::prelude::*;
use super::structs::article::Article; use super::structs::article::Article;
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder { pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> Result<impl Responder, AppError> {
let request = req.clone(); let request = req.clone();
let req_user_id = path.user_id; let req_user_id = path.user_id;
log::info!("Received user_id: {}", req_user_id); log::info!("Received user_id: {}", req_user_id);
@@ -23,8 +24,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
let mut connection: diesel::PgConnection = establish_connection(); let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)?;
.unwrap();
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new(); let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
for feed in feeds { for feed in feeds {
@@ -32,8 +32,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
.filter(read.eq(false)) .filter(read.eq(false))
.order(id.asc()) .order(id.asc())
.load(&mut connection) .load(&mut connection)?;
.unwrap();
log::info!( log::info!(
"Load {} feed items for feed: {}", "Load {} feed items for feed: {}",
@@ -70,7 +69,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
feeds: feed_aggregates, feeds: feed_aggregates,
}; };
articles.respond_to(&request) Ok(articles.respond_to(&request))
} }
#[cfg(test)] #[cfg(test)]
+7 -4
View File
@@ -3,12 +3,16 @@ use diesel::prelude::*;
use crate::{ use crate::{
database::establish_connection, database::establish_connection,
error::AppError,
json_serialization::feed_info::{FeedInfo, FeedInfoList}, json_serialization::feed_info::{FeedInfo, FeedInfoList},
json_serialization::user::JsonUser, json_serialization::user::JsonUser,
schema::feed::{self, user_id}, schema::feed::{self, user_id},
}; };
pub async fn list_feeds(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder { pub async fn list_feeds(
path: web::Path<JsonUser>,
req: HttpRequest,
) -> Result<impl Responder, AppError> {
let request = req.clone(); let request = req.clone();
let req_user_id = path.user_id; let req_user_id = path.user_id;
@@ -16,15 +20,14 @@ pub async fn list_feeds(path: web::Path<JsonUser>, req: HttpRequest) -> impl Res
let feeds = feed::table let feeds = feed::table
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.select((feed::id, feed::title, feed::url)) .select((feed::id, feed::title, feed::url))
.load::<(i32, String, String)>(&mut connection) .load::<(i32, String, String)>(&mut connection)?;
.unwrap();
let feed_list: Vec<FeedInfo> = feeds let feed_list: Vec<FeedInfo> = feeds
.into_iter() .into_iter()
.map(|(id, title, url)| FeedInfo { id, title, url }) .map(|(id, title, url)| FeedInfo { id, title, url })
.collect(); .collect();
FeedInfoList { feeds: feed_list }.respond_to(&request) Ok(FeedInfoList { feeds: feed_list }.respond_to(&request))
} }
#[cfg(test)] #[cfg(test)]
+12 -9
View File
@@ -1,3 +1,4 @@
use crate::error::AppError;
use crate::schema::feed_item::{id, read}; use crate::schema::feed_item::{id, read};
use crate::{ use crate::{
database::establish_connection, json_serialization::read_feed_item::ReadItem, database::establish_connection, json_serialization::read_feed_item::ReadItem,
@@ -7,27 +8,29 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::RunQueryDsl; use diesel::RunQueryDsl;
use diesel::{ExpressionMethods, QueryDsl}; use diesel::{ExpressionMethods, QueryDsl};
pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Responder { pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
) -> Result<impl Responder, AppError> {
let mut connection = establish_connection(); let mut connection = establish_connection();
log::info!("Id: {}", path.id); log::info!("Id: {}", path.id);
let feed_items: Vec<FeedItem> = feed_item::table let mut feed_items: Vec<FeedItem> = feed_item::table
.filter(id.eq(path.id)) .filter(id.eq(path.id))
.load::<FeedItem>(&mut connection) .load::<FeedItem>(&mut connection)?;
.unwrap();
if feed_items.len() != 1 { if feed_items.len() != 1 {
return HttpResponse::NotFound(); return Ok(HttpResponse::NotFound().finish());
} }
let feed_item: &FeedItem = feed_items.first().unwrap(); let feed_item: FeedItem = feed_items.remove(0);
let result: Result<usize, diesel::result::Error> = diesel::update(feed_item) let result = diesel::update(&feed_item)
.set(read.eq(true)) .set(read.eq(true))
.execute(&mut connection); .execute(&mut connection)?;
log::info!("Mark as read: {:?}", result); log::info!("Mark as read: {:?}", result);
HttpResponse::Ok() Ok(HttpResponse::Ok().finish())
} }
#[cfg(test)] #[cfg(test)]
+34 -17
View File
@@ -1,4 +1,5 @@
use super::feeds; use super::feeds;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser; use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed; use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem; use crate::models::feed_item::new_feed_item::NewFeedItem;
@@ -60,8 +61,17 @@ fn enclosure_image_html(item: &Item) -> Option<String> {
)) ))
} }
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> anyhow::Result<()> {
let item_title = item.title.clone().unwrap(); // Items without a title or link are malformed/unusable — skip them rather
// than failing the whole sync over one bad entry from an external feed.
let Some(item_title) = item.title.clone() else {
log::warn!("Skipping feed item without a title.");
return Ok(());
};
if item.link.is_none() {
log::warn!("Skipping feed item without a link: {}", item_title);
return Ok(());
}
log::info!("Create feed item: {}", item_title); log::info!("Create feed item: {}", item_title);
// Items without a pub_date are treated as current (inserted unconditionally) // Items without a pub_date are treated as current (inserted unconditionally)
@@ -83,7 +93,7 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let frag = Html::parse_fragment(base_content); let frag = Html::parse_fragment(base_content);
let mut content = "".to_string(); let mut content = "".to_string();
let selector_img = Selector::parse("img").unwrap(); let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
match frag.select(&selector_img).find(image_src_is_resolvable) { match frag.select(&selector_img).find(image_src_is_resolvable) {
Some(image) => { Some(image) => {
content.push_str(&image.html()); content.push_str(&image.html());
@@ -106,15 +116,14 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let existing_item: Vec<FeedItem> = feed_item::table let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title)) .filter(title.eq(&item_title))
.load(connection) .load(connection)?;
.unwrap();
if existing_item.is_empty() { if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new( let new_feed_item = NewFeedItem::new(
feed.id, feed.id,
content.clone(), content.clone(),
item_title.clone(), item_title.clone(),
item.link.unwrap(), item.link.expect("checked above"),
Some(time), Some(time),
); );
let insert_result = diesel::insert_into(feed_item::table) let insert_result = diesel::insert_into(feed_item::table)
@@ -125,17 +134,21 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
} else { } else {
log::info!("Item {} already exists.", item_title); log::info!("Item {} already exists.", item_title);
} }
Ok(())
} }
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder { pub async fn sync(
_req: HttpRequest,
data: web::Json<JsonUser>,
) -> Result<impl Responder, AppError> {
let mut connection: diesel::PgConnection = establish_connection(); let mut connection: diesel::PgConnection = establish_connection();
let req_user_id: i32 = data.user_id; let req_user_id: i32 = data.user_id;
let feeds: Vec<Feed> = feed::table let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)?;
.unwrap();
log::info!("Found {} feeds to sync.", feeds.len()); log::info!("Found {} feeds to sync.", feeds.len());
@@ -147,14 +160,16 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
Ok(channel) => { Ok(channel) => {
for item in channel.into_items() { for item in channel.into_items() {
log::info!("{:?}", item); log::info!("{:?}", item);
create_feed_item(item, &feed, &mut connection); if let Err(e) = create_feed_item(item, &feed, &mut connection) {
log::error!("Could not create feed item for {}: {:?}", feed.url, e);
}
} }
} }
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e), Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
} }
} }
HttpResponse::Ok() Ok(HttpResponse::Ok())
} }
#[cfg(test)] #[cfg(test)]
@@ -261,7 +276,8 @@ mod tests {
format!("age_test_{suffix}"), format!("age_test_{suffix}"),
format!("age_{suffix}@example.test"), format!("age_{suffix}@example.test"),
"secret".to_string(), "secret".to_string(),
); )
.unwrap();
let user: User = diesel::insert_into(users::table) let user: User = diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.get_result(&mut connection) .get_result(&mut connection)
@@ -295,8 +311,8 @@ mod tests {
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}"))); fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
fresh_item.set_content(Some("<p>fresh</p>".to_string())); fresh_item.set_content(Some("<p>fresh</p>".to_string()));
create_feed_item(old_item, &feed, &mut connection); create_feed_item(old_item, &feed, &mut connection).unwrap();
create_feed_item(fresh_item, &feed, &mut connection); create_feed_item(fresh_item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
@@ -325,7 +341,8 @@ mod tests {
format!("sync_test_{suffix}"), format!("sync_test_{suffix}"),
format!("sync_{suffix}@example.test"), format!("sync_{suffix}@example.test"),
"secret".to_string(), "secret".to_string(),
); )
.unwrap();
let user: User = diesel::insert_into(users::table) let user: User = diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.get_result(&mut connection) .get_result(&mut connection)
@@ -346,8 +363,8 @@ mod tests {
item.set_link(Some(format!("https://example.test/article/{suffix}"))); item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string())); item.set_content(Some("<p>Hello world</p>".to_string()));
create_feed_item(item.clone(), &feed, &mut connection); create_feed_item(item.clone(), &feed, &mut connection).unwrap();
create_feed_item(item, &feed, &mut connection); create_feed_item(item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
+2 -1
View File
@@ -28,7 +28,8 @@ pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
format!("test_user_{suffix}"), format!("test_user_{suffix}"),
format!("test_{suffix}@example.test"), format!("test_{suffix}@example.test"),
password.to_string(), password.to_string(),
); )
.expect("failed to hash test user password");
diesel::insert_into(users::table) diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.get_result(connection) .get_result(connection)
+9 -10
View File
@@ -1,5 +1,6 @@
use crate::database::establish_connection; use crate::database::establish_connection;
use crate::diesel; use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::login::Login; use crate::json_serialization::login::Login;
use crate::models::user::rss_user::User; use crate::models::user::rss_user::User;
use crate::schema::users; use crate::schema::users;
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::prelude::*; use diesel::prelude::*;
pub async fn login(credentials: web::Json<Login>) -> HttpResponse { pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppError> {
let username_cred: String = credentials.username.clone(); let username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone(); let password: String = credentials.password.clone();
@@ -15,32 +16,30 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
let users: Vec<User> = users::table let users: Vec<User> = users::table
.filter(username.eq(username_cred.as_str())) .filter(username.eq(username_cred.as_str()))
.load::<User>(&mut connection) .load::<User>(&mut connection)?;
.unwrap();
if users.is_empty() { if users.is_empty() {
return HttpResponse::NotFound().await.unwrap(); return Ok(HttpResponse::NotFound().finish());
} else if users.len() > 1 { } else if users.len() > 1 {
log::error!( log::error!(
"multiple user have the usernam: {}", "multiple user have the usernam: {}",
credentials.username.clone() credentials.username.clone()
); );
return HttpResponse::Conflict().await.unwrap(); return Ok(HttpResponse::Conflict().finish());
} }
let user: &User = &users[0]; let user: &User = &users[0];
match user.clone().verify(password) { match user.clone().verify(password)? {
true => { true => {
log::info!("verified password successfully for user {}", user.id); log::info!("verified password successfully for user {}", user.id);
let token: String = JwtToken::encode(user.clone().id); let token: String = JwtToken::encode(user.clone().id);
HttpResponse::Ok() Ok(HttpResponse::Ok()
.insert_header(("token", token)) .insert_header(("token", token))
.insert_header(("user_id", user.id)) .insert_header(("user_id", user.id))
.await .finish())
.unwrap()
} }
false => HttpResponse::Unauthorized().await.unwrap(), false => Ok(HttpResponse::Unauthorized().finish()),
} }
} }
+7 -6
View File
@@ -1,27 +1,28 @@
use crate::database::establish_connection; use crate::database::establish_connection;
use crate::diesel; use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::new_user::NewUserSchema; use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser; use crate::models::user::new_user::NewUser;
use crate::schema::users; use crate::schema::users;
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::prelude::*; use diesel::prelude::*;
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse { pub async fn create(new_user: web::Json<NewUserSchema>) -> Result<HttpResponse, AppError> {
let mut connection = establish_connection(); let mut connection = establish_connection();
let name: String = new_user.name.clone(); let name: String = new_user.name.clone();
let email: String = new_user.email.clone(); let email: String = new_user.email.clone();
let new_password: String = new_user.password.clone(); let new_password: String = new_user.password.clone();
let new_user = NewUser::new(name, email, new_password); let new_user = NewUser::new(name, email, new_password)?;
let insert_result = diesel::insert_into(users::table) let insert_result = diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.execute(&mut connection); .execute(&mut connection);
match insert_result { Ok(match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(), Ok(_) => HttpResponse::Created().finish(),
Err(_) => HttpResponse::Conflict().await.unwrap(), Err(_) => HttpResponse::Conflict().finish(),
} })
} }
#[cfg(test)] #[cfg(test)]
+22 -8
View File
@@ -1,11 +1,15 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds' import { useFeeds } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const onFeedsPage = computed(() => route.path === '/feeds')
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length) const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -78,20 +82,30 @@ function handleToggleLayout() {
> >
<div class="app-nav__menu-panel"> <div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink> <RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode"> <template v-if="onFeedsPage">
{{ viewMode === 'list' ? 'Article view' : 'List view' }} <button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
</button> {{ viewMode === 'list' ? 'Article view' : 'List view' }}
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout"> </button>
{{ layout === 'list' ? 'Card layout' : 'List layout' }} <button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
</button> {{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button> <button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button> <button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink> <RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button> <button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div> </div>
</nav> </nav>
</Transition> </Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header> </header>
</template> </template>
-9
View File
@@ -1,13 +1,11 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import Modal from './modal/AddUrl.vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
feeds, feeds,
showMessage, showMessage,
message, message,
showModal,
viewMode, viewMode,
currentIndex, currentIndex,
leaveArticleView, leaveArticleView,
@@ -42,13 +40,6 @@ onMounted(async () => {
</script> </script>
<template> <template>
<Teleport to="body">
<modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</modal>
</Teleport>
<div> <div>
<div v-if="showMessage" class="message">{{ message }}</div> <div v-if="showMessage" class="message">{{ message }}</div>
+29 -1
View File
@@ -116,6 +116,31 @@ describe('useFeeds', () => {
setInitialLoad(false) setInitialLoad(false)
}) })
it('strips leftover embedded-video placeholder headings', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<h2 aria-label="Eingebettetes Video — Iran-Krieg belastet Wirtschaft und Märkte in Deutschland">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
Iran-Krieg belastet Wirtschaft und Märkte in Deutschland
</h2>
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('Eingebettetes Video')
expect(feeds.value[0].content).not.toContain('<svg')
})
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => { it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
feeds.value = [{ feeds.value = [{
id: 1, id: 1,
@@ -137,7 +162,10 @@ describe('useFeeds', () => {
await getReadable(feeds.value[0], 0) await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_MASTER_LANDSCAPE.jpg"') // "MASTER_LANDSCAPE" is a symbolic name from DW's CMS, not a valid value
// for the CDN's numeric `formatId` — it must be mapped to "6" or the
// resulting URL 400s and the image fails to load.
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_6.jpg"')
// The rendered `src` is what matters — `data-url` retaining the raw // The rendered `src` is what matters — `data-url` retaining the raw
// template is harmless since browsers don't load images from data-* attrs. // template is harmless since browsers don't load images from data-* attrs.
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/) expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
+21 -1
View File
@@ -35,8 +35,19 @@ function authHeaders() {
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/ const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
// `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"),
// but their image CDN only accepts numeric format ids in the URL — the
// template's `${formatId}` literally means a number. Substituting the
// symbolic name verbatim produces a 400 (image fails to load). DW generates
// the same fixed set of numeric variants for every image, so map the
// symbolic names we've seen to their numeric equivalent.
const DW_FORMAT_IDS = {
MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio
}
function resolveTemplatedImage(img) { function resolveTemplatedImage(img) {
const format = img.getAttribute('data-format') const rawFormat = img.getAttribute('data-format')
const format = rawFormat && (DW_FORMAT_IDS[rawFormat] ?? (/^\d+$/.test(rawFormat) ? rawFormat : null))
const dataUrl = img.getAttribute('data-url') const dataUrl = img.getAttribute('data-url')
if (format) { if (format) {
@@ -81,6 +92,15 @@ async function getReadable(feed, index) {
doc.head.prepend(base); doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage); doc.querySelectorAll('img').forEach(resolveTemplatedImage);
doc.querySelectorAll('video, audio').forEach(el => el.remove()); doc.querySelectorAll('video, audio').forEach(el => el.remove());
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
// for an embedded video player whose actual <video>/<iframe> we already
// stripped — without it, the heading is just a giant orphaned icon that
// takes up space and links nowhere.
doc.querySelectorAll('[aria-label]').forEach(el => {
if (/^(Eingebettetes|Embedded) Video/i.test(el.getAttribute('aria-label'))) {
el.remove()
}
})
const article = new Readability(doc).parse(); const article = new Readability(doc).parse();
feeds.value[index].content = article.content; feeds.value[index].content = article.content;
feeds.value[index].readable = true; feeds.value[index].readable = true;