added anyhow, improve hamburger menu, improve dw articles
This commit is contained in:
Generated
+1
@@ -2278,6 +2278,7 @@ dependencies = [
|
|||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dateparser",
|
"dateparser",
|
||||||
|
|||||||
@@ -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
@@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
<template v-if="onFeedsPage">
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
||||||
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
||||||
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
||||||
</button>
|
</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="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="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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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])/)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user