added anyhow, improve hamburger menu, improve dw articles
This commit is contained in:
Generated
+1
@@ -2278,6 +2278,7 @@ dependencies = [
|
||||
"actix-rt",
|
||||
"actix-service",
|
||||
"actix-web",
|
||||
"anyhow",
|
||||
"bcrypt",
|
||||
"chrono",
|
||||
"dateparser",
|
||||
|
||||
@@ -6,6 +6,7 @@ edition = "2024"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rss = { version = "2.0.13" }
|
||||
|
||||
+10
-3
@@ -20,7 +20,8 @@ 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()
|
||||
// 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 {
|
||||
@@ -28,7 +29,10 @@ impl JwtToken {
|
||||
let key: HmacSha256 = signing_key();
|
||||
let mut claims = BTreeMap::new();
|
||||
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> {
|
||||
@@ -52,7 +56,10 @@ impl JwtToken {
|
||||
#[allow(dead_code)]
|
||||
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,12 @@ impl Responder for Articles {
|
||||
type Body = String;
|
||||
|
||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||
let body = serde_json::to_string(&self).unwrap();
|
||||
HttpResponse::with_body(StatusCode::OK, body)
|
||||
match serde_json::to_string(&self) {
|
||||
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||
Err(err) => {
|
||||
log::error!("Failed to serialize response: {}", err);
|
||||
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ impl Responder for FeedInfoList {
|
||||
type Body = String;
|
||||
|
||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||
let body = serde_json::to_string(&self).unwrap();
|
||||
HttpResponse::with_body(StatusCode::OK, body)
|
||||
match serde_json::to_string(&self) {
|
||||
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||
Err(err) => {
|
||||
log::error!("Failed to serialize response: {}", err);
|
||||
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ impl Responder for Readable {
|
||||
type Body = String;
|
||||
|
||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||
let body = serde_json::to_string(&self).unwrap();
|
||||
HttpResponse::with_body(StatusCode::OK, body)
|
||||
match serde_json::to_string(&self) {
|
||||
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||
Err(err) => {
|
||||
log::error!("Failed to serialize response: {}", err);
|
||||
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use futures::future::{ok, Either};
|
||||
use std::env;
|
||||
mod auth;
|
||||
mod database;
|
||||
mod error;
|
||||
mod json_serialization;
|
||||
mod models;
|
||||
mod reader;
|
||||
|
||||
@@ -16,14 +16,14 @@ pub struct NewUser {
|
||||
}
|
||||
|
||||
impl NewUser {
|
||||
pub fn new(username: String, email: String, password: String) -> NewUser {
|
||||
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
|
||||
pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
|
||||
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
|
||||
let uuid = Uuid::new_v4();
|
||||
NewUser {
|
||||
Ok(NewUser {
|
||||
username,
|
||||
email,
|
||||
password: hashed_password,
|
||||
unique_id: uuid.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct User {
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn verify(self, password: String) -> bool {
|
||||
verify(password.as_str(), &self.password).unwrap()
|
||||
pub fn verify(self, password: String) -> anyhow::Result<bool> {
|
||||
Ok(verify(password.as_str(), &self.password)?)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -19,12 +19,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
||||
Ok(channel) => {
|
||||
log::info!("valid channel");
|
||||
if channel.items.is_empty() {
|
||||
return HttpResponse::ServiceUnavailable().await.unwrap();
|
||||
return HttpResponse::ServiceUnavailable().finish();
|
||||
}
|
||||
}
|
||||
Err(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);
|
||||
|
||||
match insert_result {
|
||||
Ok(_) => HttpResponse::Created().await.unwrap(),
|
||||
Ok(_) => HttpResponse::Created().finish(),
|
||||
Err(e) => {
|
||||
log::error!("{e}");
|
||||
HttpResponse::Conflict().await.unwrap()
|
||||
HttpResponse::Conflict().finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-6
@@ -1,3 +1,4 @@
|
||||
use crate::error::AppError;
|
||||
use crate::json_serialization::user::JsonUser;
|
||||
use crate::models::feed::rss_feed::Feed;
|
||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||
@@ -15,7 +16,7 @@ use diesel::prelude::*;
|
||||
|
||||
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 req_user_id = path.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 feeds: Vec<Feed> = feed::table
|
||||
.filter(user_id.eq(req_user_id))
|
||||
.load::<Feed>(&mut connection)
|
||||
.unwrap();
|
||||
.load::<Feed>(&mut connection)?;
|
||||
|
||||
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
|
||||
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(read.eq(false))
|
||||
.order(id.asc())
|
||||
.load(&mut connection)
|
||||
.unwrap();
|
||||
.load(&mut connection)?;
|
||||
|
||||
log::info!(
|
||||
"Load {} feed items for feed: {}",
|
||||
@@ -70,7 +69,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
|
||||
feeds: feed_aggregates,
|
||||
};
|
||||
|
||||
articles.respond_to(&request)
|
||||
Ok(articles.respond_to(&request))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,12 +3,16 @@ use diesel::prelude::*;
|
||||
|
||||
use crate::{
|
||||
database::establish_connection,
|
||||
error::AppError,
|
||||
json_serialization::feed_info::{FeedInfo, FeedInfoList},
|
||||
json_serialization::user::JsonUser,
|
||||
schema::feed::{self, user_id},
|
||||
};
|
||||
|
||||
pub async fn list_feeds(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
|
||||
pub async fn list_feeds(
|
||||
path: web::Path<JsonUser>,
|
||||
req: HttpRequest,
|
||||
) -> Result<impl Responder, AppError> {
|
||||
let request = req.clone();
|
||||
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
|
||||
.filter(user_id.eq(req_user_id))
|
||||
.select((feed::id, feed::title, feed::url))
|
||||
.load::<(i32, String, String)>(&mut connection)
|
||||
.unwrap();
|
||||
.load::<(i32, String, String)>(&mut connection)?;
|
||||
|
||||
let feed_list: Vec<FeedInfo> = feeds
|
||||
.into_iter()
|
||||
.map(|(id, title, url)| FeedInfo { id, title, url })
|
||||
.collect();
|
||||
|
||||
FeedInfoList { feeds: feed_list }.respond_to(&request)
|
||||
Ok(FeedInfoList { feeds: feed_list }.respond_to(&request))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+12
-9
@@ -1,3 +1,4 @@
|
||||
use crate::error::AppError;
|
||||
use crate::schema::feed_item::{id, read};
|
||||
use crate::{
|
||||
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::{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();
|
||||
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))
|
||||
.load::<FeedItem>(&mut connection)
|
||||
.unwrap();
|
||||
.load::<FeedItem>(&mut connection)?;
|
||||
|
||||
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))
|
||||
.execute(&mut connection);
|
||||
.execute(&mut connection)?;
|
||||
|
||||
log::info!("Mark as read: {:?}", result);
|
||||
|
||||
HttpResponse::Ok()
|
||||
Ok(HttpResponse::Ok().finish())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+34
-17
@@ -1,4 +1,5 @@
|
||||
use super::feeds;
|
||||
use crate::error::AppError;
|
||||
use crate::json_serialization::user::JsonUser;
|
||||
use crate::models::feed::rss_feed::Feed;
|
||||
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) {
|
||||
let item_title = item.title.clone().unwrap();
|
||||
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> anyhow::Result<()> {
|
||||
// Items without a title or link are malformed/unusable — skip them rather
|
||||
// than failing the whole sync over one bad entry from an external feed.
|
||||
let Some(item_title) = item.title.clone() else {
|
||||
log::warn!("Skipping feed item without a title.");
|
||||
return Ok(());
|
||||
};
|
||||
if item.link.is_none() {
|
||||
log::warn!("Skipping feed item without a link: {}", item_title);
|
||||
return Ok(());
|
||||
}
|
||||
log::info!("Create feed item: {}", item_title);
|
||||
|
||||
// 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 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) {
|
||||
Some(image) => {
|
||||
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
|
||||
.filter(feed_id.eq(feed.id))
|
||||
.filter(title.eq(&item_title))
|
||||
.load(connection)
|
||||
.unwrap();
|
||||
.load(connection)?;
|
||||
|
||||
if existing_item.is_empty() {
|
||||
let new_feed_item = NewFeedItem::new(
|
||||
feed.id,
|
||||
content.clone(),
|
||||
item_title.clone(),
|
||||
item.link.unwrap(),
|
||||
item.link.expect("checked above"),
|
||||
Some(time),
|
||||
);
|
||||
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 {
|
||||
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 req_user_id: i32 = data.user_id;
|
||||
|
||||
let feeds: Vec<Feed> = feed::table
|
||||
.filter(user_id.eq(req_user_id))
|
||||
.load::<Feed>(&mut connection)
|
||||
.unwrap();
|
||||
.load::<Feed>(&mut connection)?;
|
||||
|
||||
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) => {
|
||||
for item in channel.into_items() {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponse::Ok()
|
||||
Ok(HttpResponse::Ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -261,7 +276,8 @@ mod tests {
|
||||
format!("age_test_{suffix}"),
|
||||
format!("age_{suffix}@example.test"),
|
||||
"secret".to_string(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
let user: User = diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.get_result(&mut connection)
|
||||
@@ -295,8 +311,8 @@ mod tests {
|
||||
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
|
||||
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
|
||||
|
||||
create_feed_item(old_item, &feed, &mut connection);
|
||||
create_feed_item(fresh_item, &feed, &mut connection);
|
||||
create_feed_item(old_item, &feed, &mut connection).unwrap();
|
||||
create_feed_item(fresh_item, &feed, &mut connection).unwrap();
|
||||
|
||||
let items: Vec<FeedItem> = feed_item::table
|
||||
.filter(feed_id.eq(feed.id))
|
||||
@@ -325,7 +341,8 @@ mod tests {
|
||||
format!("sync_test_{suffix}"),
|
||||
format!("sync_{suffix}@example.test"),
|
||||
"secret".to_string(),
|
||||
);
|
||||
)
|
||||
.unwrap();
|
||||
let user: User = diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.get_result(&mut connection)
|
||||
@@ -346,8 +363,8 @@ mod tests {
|
||||
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);
|
||||
create_feed_item(item.clone(), &feed, &mut connection).unwrap();
|
||||
create_feed_item(item, &feed, &mut connection).unwrap();
|
||||
|
||||
let items: Vec<FeedItem> = feed_item::table
|
||||
.filter(feed_id.eq(feed.id))
|
||||
|
||||
+2
-1
@@ -28,7 +28,8 @@ pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
|
||||
format!("test_user_{suffix}"),
|
||||
format!("test_{suffix}@example.test"),
|
||||
password.to_string(),
|
||||
);
|
||||
)
|
||||
.expect("failed to hash test user password");
|
||||
diesel::insert_into(users::table)
|
||||
.values(&new_user)
|
||||
.get_result(connection)
|
||||
|
||||
+9
-10
@@ -1,5 +1,6 @@
|
||||
use crate::database::establish_connection;
|
||||
use crate::diesel;
|
||||
use crate::error::AppError;
|
||||
use crate::json_serialization::login::Login;
|
||||
use crate::models::user::rss_user::User;
|
||||
use crate::schema::users;
|
||||
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
|
||||
use actix_web::{web, HttpResponse};
|
||||
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 password: String = credentials.password.clone();
|
||||
|
||||
@@ -15,32 +16,30 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
|
||||
|
||||
let users: Vec<User> = users::table
|
||||
.filter(username.eq(username_cred.as_str()))
|
||||
.load::<User>(&mut connection)
|
||||
.unwrap();
|
||||
.load::<User>(&mut connection)?;
|
||||
|
||||
if users.is_empty() {
|
||||
return HttpResponse::NotFound().await.unwrap();
|
||||
return Ok(HttpResponse::NotFound().finish());
|
||||
} else if users.len() > 1 {
|
||||
log::error!(
|
||||
"multiple user have the usernam: {}",
|
||||
credentials.username.clone()
|
||||
);
|
||||
return HttpResponse::Conflict().await.unwrap();
|
||||
return Ok(HttpResponse::Conflict().finish());
|
||||
}
|
||||
|
||||
let user: &User = &users[0];
|
||||
|
||||
match user.clone().verify(password) {
|
||||
match user.clone().verify(password)? {
|
||||
true => {
|
||||
log::info!("verified password successfully for user {}", user.id);
|
||||
let token: String = JwtToken::encode(user.clone().id);
|
||||
HttpResponse::Ok()
|
||||
Ok(HttpResponse::Ok()
|
||||
.insert_header(("token", token))
|
||||
.insert_header(("user_id", user.id))
|
||||
.await
|
||||
.unwrap()
|
||||
.finish())
|
||||
}
|
||||
false => HttpResponse::Unauthorized().await.unwrap(),
|
||||
false => Ok(HttpResponse::Unauthorized().finish()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
use crate::database::establish_connection;
|
||||
use crate::diesel;
|
||||
use crate::error::AppError;
|
||||
use crate::json_serialization::new_user::NewUserSchema;
|
||||
use crate::models::user::new_user::NewUser;
|
||||
use crate::schema::users;
|
||||
use actix_web::{web, HttpResponse};
|
||||
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 name: String = new_user.name.clone();
|
||||
let email: String = new_user.email.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)
|
||||
.values(&new_user)
|
||||
.execute(&mut connection);
|
||||
|
||||
match insert_result {
|
||||
Ok(_) => HttpResponse::Created().await.unwrap(),
|
||||
Err(_) => HttpResponse::Conflict().await.unwrap(),
|
||||
}
|
||||
Ok(match insert_result {
|
||||
Ok(_) => HttpResponse::Created().finish(),
|
||||
Err(_) => HttpResponse::Conflict().finish(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useFeeds } from '@/composables/useFeeds'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
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 menuOpen = ref(false)
|
||||
@@ -78,20 +82,30 @@ function handleToggleLayout() {
|
||||
>
|
||||
<div class="app-nav__menu-panel">
|
||||
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
|
||||
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
||||
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
||||
</button>
|
||||
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
||||
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
||||
</button>
|
||||
<template v-if="onFeedsPage">
|
||||
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
||||
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
||||
</button>
|
||||
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
||||
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
||||
</button>
|
||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||
</template>
|
||||
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
||||
<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>
|
||||
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
||||
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
||||
</div>
|
||||
</nav>
|
||||
</Transition>
|
||||
|
||||
<Teleport to="body">
|
||||
<Modal :show="showModal" @close="showModal = false">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
</template>
|
||||
</Modal>
|
||||
</Teleport>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import Modal from './modal/AddUrl.vue';
|
||||
import { useFeeds } from '@/composables/useFeeds';
|
||||
|
||||
const {
|
||||
feeds,
|
||||
showMessage,
|
||||
message,
|
||||
showModal,
|
||||
viewMode,
|
||||
currentIndex,
|
||||
leaveArticleView,
|
||||
@@ -42,13 +40,6 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<modal :show="showModal" @close="showModal = false">
|
||||
<template #header>
|
||||
<h3>Add RSS Feed</h3>
|
||||
</template>
|
||||
</modal>
|
||||
</Teleport>
|
||||
<div>
|
||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
||||
|
||||
|
||||
@@ -116,6 +116,31 @@ describe('useFeeds', () => {
|
||||
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 () => {
|
||||
feeds.value = [{
|
||||
id: 1,
|
||||
@@ -137,7 +162,10 @@ describe('useFeeds', () => {
|
||||
|
||||
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
|
||||
// template is harmless since browsers don't load images from data-* attrs.
|
||||
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
|
||||
|
||||
@@ -35,8 +35,19 @@ function authHeaders() {
|
||||
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
|
||||
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
|
||||
|
||||
// `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"),
|
||||
// but their image CDN only accepts numeric format ids in the URL — the
|
||||
// template's `${formatId}` literally means a number. Substituting the
|
||||
// symbolic name verbatim produces a 400 (image fails to load). DW generates
|
||||
// the same fixed set of numeric variants for every image, so map the
|
||||
// symbolic names we've seen to their numeric equivalent.
|
||||
const DW_FORMAT_IDS = {
|
||||
MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio
|
||||
}
|
||||
|
||||
function resolveTemplatedImage(img) {
|
||||
const 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')
|
||||
|
||||
if (format) {
|
||||
@@ -81,6 +92,15 @@ async function getReadable(feed, index) {
|
||||
doc.head.prepend(base);
|
||||
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
|
||||
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();
|
||||
feeds.value[index].content = article.content;
|
||||
feeds.value[index].readable = true;
|
||||
|
||||
Reference in New Issue
Block a user