22 Commits

Author SHA1 Message Date
mathias 9492ee9db7 cleanup unused files 2024-03-29 12:47:40 +01:00
mathias 5a18e5728e added telemetry 2024-03-29 12:42:22 +01:00
mathias b4843108fc switch to R2D2 pool 2024-03-29 10:21:20 +01:00
mathias 2db4972394 configuration refactoring [wip] 2024-03-28 17:07:59 +01:00
mathias c1615a1bcb added environment for vue, split dockerfiles, add rust dependencies. 2024-03-28 14:52:06 +01:00
mathias a2e2ff141e added modal.css 2024-01-06 11:46:49 +01:00
mathias e33bb76f50 centralize modal css 2023-11-26 12:27:51 +01:00
mathias 20d76c2a56 database error message, date sorting 2023-11-19 14:03:03 +01:00
mathias 91eeb29b06 added dates, clippy fixes, sync date improvement 2023-11-14 19:09:21 +01:00
mathias ea754b6f3e Improve parsing 2023-11-12 11:05:40 +01:00
mathias d920f5b9b9 Add new feed url 2023-11-10 17:17:29 +01:00
mathias f9f274f6e2 modal, add feed [wip] 2023-11-05 13:26:02 +01:00
mathias 0fae2e407c minor navigation changes 2023-11-02 17:26:42 +01:00
mathias 579fa1f7ca backend, mark item as read 2023-10-30 19:52:51 +01:00
mathias abf9a1b818 mark read backend 2023-10-29 17:06:19 +01:00
mathias 1789458830 observer frontend 2023-10-29 12:52:40 +01:00
mathias b0280b0a41 observer for article divs 2023-10-28 22:43:29 +02:00
mathias e4a65416c7 added timestamp for feed items 2023-10-24 19:19:47 +02:00
mathias ee80cbd53b Added readable mode for article content 2023-10-15 17:44:05 +02:00
mathias 3d77c6f30f Change get articles to read from database instead of dummy data. 2023-10-14 18:32:49 +02:00
mathias c8ca91e90b cleanup, sync article url 2023-10-11 19:23:18 +02:00
mathias 3129c521b4 Sync working 2023-10-11 19:04:26 +02:00
62 changed files with 2130 additions and 447 deletions
+6
View File
@@ -0,0 +1,6 @@
target/
tests/
Dockerfile
scripts/
migrations/
-1
View File
@@ -1 +0,0 @@
DATABASE_URL=postgres://admin:secret+123@localhost/rss
Generated
+856 -90
View File
File diff suppressed because it is too large Load Diff
Executable → Regular
+21 -4
View File
@@ -3,10 +3,17 @@ name = "rss-reader"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "rss-reader"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
reqwest = { version = "0.11", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.1" }
actix-web = "4.1.0"
@@ -15,17 +22,27 @@ futures = "0.3.24"
serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
serde_derive = "1.0.145"
actix-service = "2.0.2"
diesel = { version = "2.0.2", features = ["postgres"]}
diesel = { version = "2.0.2", features = ["postgres", "chrono", "r2d2"] }
dotenv = "0.15.0"
bcrypt = "0.13.0"
uuid = {version = "1.2.1", features=["serde", "v4"]}
jwt = "0.16.0"
hmac = "0.12.1"
sha2 = "0.10.6"
log = "0.4.17"
env_logger = "0.9.3"
scraper = "0.14.0"
actix-cors = "0.6.4"
chrono = { version = "0.4.31", features = ["serde"] }
dateparser = "0.2.0"
tracing-appender = "0.2.3"
once_cell = "1.19.0"
secrecy = { version = "0.8.0", features = ["serde"] }
tracing-actix-web = "0.7.10"
tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] }
tracing-log = "0.2.0"
config = "0.14.0"
diesel-connection = "4.1.0"
tracing = { version = "0.1.40", features = ["log"] }
tracing-bunyan-formatter = "0.3.9"
[dependencies.serde_json]
version = "1.0.86"
+41
View File
@@ -0,0 +1,41 @@
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
RUN apt update && apt install lld clang -y
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin rss-reader
RUN cargo install diesel_cli --no-default-features --features postgres
# Runtime stage
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Install OpenSSL - it is dynamically linked by some of our dependencies
# Install ca-certificates - it is needed to verify TLS certificates
# when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y openssl ca-certificates pkg-config\
&& apt-get install -y libpq5 \
&& apt-get autoremove -y \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/*
# Copy diesel_cli from builder to runtime
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/cargo/bin/diesel
COPY --from=builder /app/target/release/rss-reader rss-reader
EXPOSE 8001
# COPY configuration configuration
# ENV APP_ENVIRONMENT production
# ENTRYPOINT ["./rss-reader"]
ENTRYPOINT ["sh", "-c", "/app/rss-reader && diesel migration run"]
+8
View File
@@ -0,0 +1,8 @@
application:
port: 8001
database:
host: "localhost"
port: 5432
username: "admin"
password: "secret+123"
database_name: "rss"
+2
View File
@@ -0,0 +1,2 @@
application:
host: 127.0.0.1
+2
View File
@@ -0,0 +1,2 @@
application:
host: 0.0.0.0
+28
View File
@@ -1,6 +1,17 @@
version: "3.7"
services:
# vue-app:
# build:
# context: ./vue/
# dockerfile: Dockerfile
# ports:
# - "8080:8080" # Adjust the port as needed for your Rust application
# networks:
# - app-network
postgres:
restart: always
container_name: "rss-postgres"
image: "postgres:15"
ports:
@@ -11,6 +22,23 @@ services:
- "POSTGRES_PASSWORD=secret+123"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
# rust-app:
# build:
# context: . # Specify the path to your Rust application's Dockerfile
# dockerfile: Dockerfile
# ports:
# - "8001:8001" # Adjust the port as needed for your Rust application
# depends_on:
# - postgres
# networks:
# - app-network
networks:
app-network:
driver: bridge
volumes:
postgres_data:
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE feed_item
DROP COLUMN created_ts;
@@ -0,0 +1,6 @@
-- Your SQL goes here
ALTER TABLE feed_item
ADD COLUMN created_ts TIMESTAMP;
ALTER TABLE feed_item
ALTER COLUMN created_ts SET DEFAULT now();
+1
View File
@@ -43,6 +43,7 @@ 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())),
+1
View File
@@ -4,6 +4,7 @@ pub mod processes;
use crate::auth::processes::check_password;
use crate::auth::processes::extract_header_token;
#[tracing::instrument(name = "Process token")]
pub fn process_token(request: &ServiceRequest) -> Result<String, &'static str> {
match extract_header_token(request) {
Ok(token) => check_password(token),
+10 -11
View File
@@ -1,21 +1,19 @@
use super::jwt;
use actix_web::dev::ServiceRequest;
use secrecy::{ExposeSecret, Secret};
pub fn check_password(password: String) -> Result<String, &'static str> {
match jwt::JwtToken::decode(password) {
pub fn check_password(password: Secret<String>) -> Result<String, &'static str> {
match jwt::JwtToken::decode(password.expose_secret().to_string()) {
Ok(_token) => Ok(String::from("passed")),
Err(message) => Err(message),
}
}
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
log::info!("Request: {:?}", request);
#[tracing::instrument(name = "Extract Header Token")]
pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>, &'static str> {
match request.headers().get("user-token") {
Some(token) => match token.to_str() {
Ok(processed_password) => {
log::info!("Token provided: {}", processed_password);
Ok(String::from(processed_password))
}
Ok(processed_password) => Ok(Secret::new(String::from(processed_password))),
Err(_processed_password) => Err("there was an error processing token"),
},
None => Err("there is no token"),
@@ -25,6 +23,7 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
#[cfg(test)]
mod processes_test {
use actix_web::test::TestRequest;
use secrecy::{ExposeSecret, Secret};
use crate::auth::jwt::JwtToken;
@@ -32,7 +31,7 @@ mod processes_test {
#[test]
fn check_correct_password() {
let password_string: String = JwtToken::encode(32);
let password_string: Secret<String> = Secret::new(JwtToken::encode(32));
let result = check_password(password_string);
@@ -44,7 +43,7 @@ mod processes_test {
#[test]
fn incorrect_check_password() {
let password: String = String::from("test");
let password: Secret<String> = Secret::new(String::from("test"));
match check_password(password) {
Err(message) => assert_eq!("could not decode token", message),
@@ -59,7 +58,7 @@ mod processes_test {
.to_srv_request();
match super::extract_header_token(&request) {
Ok(processed_password) => assert_eq!("token", processed_password),
Ok(processed_password) => assert_eq!("token", processed_password.expose_secret()),
_ => panic!("failed extract_header_token"),
}
}
+118
View File
@@ -0,0 +1,118 @@
use config::{Config, ConfigError};
use secrecy::{ExposeSecret, Secret};
#[derive(serde::Deserialize, Debug)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(serde::Deserialize, Debug)]
pub struct ApplicationSettings {
pub port: u16,
pub host: String,
}
#[derive(serde::Deserialize, Debug)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
pub port: u16,
pub host: String,
pub database_name: String,
}
impl TryFrom<Config> for Settings {
type Error = ConfigError;
fn try_from(builder: config::Config) -> Result<Self, Self::Error> {
// Extract values from the builder and construct Settings
let database = builder.get::<DatabaseSettings>("database")?;
let application = builder.get::<ApplicationSettings>("application")?;
Ok(Settings {
database,
application,
})
}
}
impl DatabaseSettings {
pub fn connection_string(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}/{}",
self.username,
self.password.expose_secret(),
self.host,
self.port,
self.database_name
))
}
pub fn connection_string_without_db(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}",
self.username,
self.password.expose_secret(),
self.host,
self.port
))
}
}
pub fn get_configuration() -> Result<Settings, ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory.");
let configuration_directory = base_path.join("configuration");
// Detect the running environment
// Default to `local`
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT.");
let environment_filename = format!("{}.yaml", environment.as_str());
// Initialise our configuration reader
let settings = config::Config::builder()
// Add configuration values from a file named `configuration.yaml`.
.add_source(config::File::from(
configuration_directory.join("base.yaml"),
))
.add_source(config::File::from(
configuration_directory.join(environment_filename),
))
.build()?;
// Try to convert the configuration values it read into
// our Settings type
settings.try_deserialize::<Settings>()
}
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{} is not a supported environement. \
Use either 'local' or 'production'.",
other
)),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
application:
port: 8000
host: 127.0.0.1
database:
host: "127.0.0.1"
port: 5432
username: "postgres"
password: "password"
database_name: "newsletter"
+9 -9
View File
@@ -1,12 +1,12 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use dotenv::dotenv;
use std::env;
use diesel::r2d2::{ConnectionManager, Pool};
pub fn establish_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
PgConnection::establish(&database_url)
.unwrap_or_else(|_| panic!("Error connecting to database {}", database_url))
pub fn get_connection_pool(url: &str) -> Pool<ConnectionManager<PgConnection>> {
let manager = ConnectionManager::<PgConnection>::new(url);
// Refer to the `r2d2` documentation for more methods to use
// when building a connection pool
Pool::builder()
.test_on_check_out(true)
.build(manager)
.expect("Could not build connection pool")
}
+2 -2
View File
@@ -2,11 +2,11 @@ use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize;
use crate::reader::structs::feed::Feed;
use crate::reader::structs::feed::FeedAggregate;
#[derive(Serialize)]
pub struct Articles {
pub feeds: Vec<Feed>,
pub feeds: Vec<FeedAggregate>,
}
impl Responder for Articles {
+4
View File
@@ -3,3 +3,7 @@ pub mod login;
pub mod new_feed;
pub mod new_feed_item;
pub mod new_user;
pub mod read_feed_item;
pub mod readable;
pub mod url;
pub mod user;
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
pub struct NewFeedSchema {
pub title: String,
pub url: String,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize)]
#[derive(Deserialize, Debug)]
pub struct NewUserSchema {
pub name: String,
pub email: String,
+6
View File
@@ -0,0 +1,6 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub struct ReadItem {
pub id: i32,
}
+17
View File
@@ -0,0 +1,17 @@
use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize;
#[derive(Serialize)]
pub struct Readable {
pub content: String,
}
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)
}
}
+6
View File
@@ -0,0 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
pub struct UrlJson {
pub url: String,
}
+6
View File
@@ -0,0 +1,6 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
pub struct JsonUser {
pub user_id: i32,
}
+13
View File
@@ -0,0 +1,13 @@
extern crate diesel;
extern crate dotenv;
pub mod auth;
pub mod configuration;
pub mod database;
pub mod json_serialization;
pub mod models;
pub mod reader;
pub mod schema;
pub mod startup;
pub mod telemetry;
pub mod views;
+25 -54
View File
@@ -1,61 +1,32 @@
extern crate diesel;
extern crate dotenv;
use std::net::TcpListener;
use actix_service::Service;
use actix_web::{App, HttpResponse, HttpServer};
use futures::future::{ok, Either};
mod auth;
mod database;
mod json_serialization;
mod models;
mod reader;
mod schema;
mod views;
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use rss_reader::{
configuration::get_configuration,
database::get_connection_pool,
startup::run,
telemetry::{get_subscriber, init_subscriber},
};
use secrecy::ExposeSecret;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber);
HttpServer::new(|| {
let app = App::new()
.wrap_fn(|req, srv| {
let mut passed: bool;
let request_url: String = String::from(req.uri().path());
let configuration = get_configuration().expect("Failed to read configuration.");
log::info!("Request Url: {}", request_url);
if req.path().contains("/article/") {
match auth::process_token(&req) {
Ok(_token) => passed = true,
Err(_message) => passed = false,
}
} else {
log::warn!("No auth check done.");
passed = true;
}
if req.path().contains("user/create") {
passed = true;
}
log::info!("passed: {:?}", passed);
let end_result = match passed {
true => Either::Left(srv.call(req)),
false => Either::Right(ok(req.into_response(
HttpResponse::Unauthorized().finish().map_into_boxed_body(),
))),
};
async move {
let result = end_result.await?;
log::info!("{} -> {}", request_url, &result.status());
Ok(result)
}
})
.configure(views::views_factory);
return app;
})
.bind("127.0.0.1:8001")?
.run()
.await
let connection_pool: Pool<ConnectionManager<PgConnection>> =
get_connection_pool(configuration.database.connection_string().expose_secret());
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
let listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await
}
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod new_feed_item;
mod rss_feed_item;
pub mod rss_feed_item;
+10 -5
View File
@@ -1,8 +1,5 @@
// extern crate bcrypt;
// use bcrypt::{hash, DEFAULT_COST};
use chrono::NaiveDateTime;
use diesel::Insertable;
// use uuid::Uuid;
use crate::schema::feed_item;
@@ -13,15 +10,23 @@ pub struct NewFeedItem {
pub content: String,
pub title: String,
pub url: String,
pub created_ts: Option<NaiveDateTime>,
}
impl NewFeedItem {
pub fn new(feed_id: i32, content: String, title: String, url: String) -> Self {
pub fn new(
feed_id: i32,
content: String,
title: String,
url: String,
created_ts: Option<NaiveDateTime>,
) -> Self {
Self {
feed_id,
content,
title,
url,
created_ts,
}
}
}
+30 -7
View File
@@ -1,15 +1,38 @@
use diesel::{Associations, Identifiable, Queryable};
use crate::models::feed::rss_feed::Feed;
use crate::schema::feed_item;
#[derive(Clone, Queryable, Identifiable, Associations)]
use chrono::NaiveDateTime;
use diesel::{Associations, Identifiable, Queryable, Selectable};
#[derive(Clone, Identifiable, Selectable, Queryable, Associations)]
#[diesel(belongs_to(Feed))]
#[diesel(table_name=feed_item)]
pub struct Feed {
pub struct FeedItem {
pub id: i32,
pub feed_id: i32,
pub title: String,
pub url: String,
pub content: String,
pub read: bool,
pub title: String,
pub url: String,
pub created_ts: Option<NaiveDateTime>,
}
impl FeedItem {
pub fn new(
id: i32,
feed_id: i32,
title: String,
url: String,
content: String,
read: bool,
created_ts: Option<NaiveDateTime>,
) -> Self {
Self {
id,
feed_id,
title,
url,
content,
read,
created_ts,
}
}
}
+4 -2
View File
@@ -2,6 +2,7 @@ extern crate bcrypt;
use bcrypt::{hash, DEFAULT_COST};
use diesel::Insertable;
use secrecy::{ExposeSecret, Secret};
use uuid::Uuid;
use crate::schema::users;
@@ -16,8 +17,9 @@ 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: Secret<String>) -> NewUser {
let hashed_password: String =
hash(password.expose_secret().as_str(), DEFAULT_COST).unwrap();
let uuid = Uuid::new_v4();
NewUser {
username,
+30 -11
View File
@@ -1,17 +1,39 @@
use actix_web::{web, HttpResponse};
use diesel::RunQueryDsl;
use crate::{
database::establish_connection, json_serialization::new_feed::NewFeedSchema,
models::feed::new_feed::NewFeed, schema::feed,
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection, RunQueryDsl,
};
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
let mut connection = establish_connection();
use crate::{
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
};
use super::feeds;
#[tracing::instrument(name = "Add new feed", skip(pool))]
pub async fn add(
new_feed: web::Json<NewFeedSchema>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
let title: String = new_feed.title.clone();
let url: String = new_feed.url.clone();
let user_id: i32 = new_feed.user_id;
let result = feeds::get_feed(&url).await;
match result {
Ok(channel) => {
if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap();
}
}
Err(_) => {
return HttpResponse::NotFound().await.unwrap();
}
}
let new_feed = NewFeed::new(title, url, user_id);
let insert_result = diesel::insert_into(feed::table)
@@ -20,9 +42,6 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(),
Err(e) => {
log::error!("{e}");
HttpResponse::Conflict().await.unwrap()
}
Err(_) => HttpResponse::Conflict().await.unwrap(),
}
}
+1 -1
View File
@@ -2,9 +2,9 @@ use std::error::Error;
use rss::Channel;
#[tracing::instrument(name = "Get Channel Feed")]
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
let content = reqwest::get(feed).await?.bytes().await?;
let channel = Channel::read_from(&content[..])?;
log::info!("{:?}", channel);
Ok(channel)
}
+71 -34
View File
@@ -1,44 +1,81 @@
use crate::{auth::jwt::JwtToken, reader::feeds, json_serialization::articles::Articles};
use actix_web::{HttpRequest, Responder};
use scraper::{Html, Selector };
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::reader::structs::feed::FeedAggregate;
use crate::schema::feed_item::{feed_id, id, read};
use crate::{
json_serialization::articles::Articles,
schema::feed::{self, user_id},
schema::feed_item,
};
use actix_web::{web, HttpRequest, Responder};
use chrono::Local;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::{prelude::*, r2d2};
use super::structs::{article::Article, feed::Feed};
use super::structs::article::Article;
pub async fn get(req: HttpRequest) -> impl Responder {
#[tracing::instrument(name = "Get feeds", skip(pool))]
pub async fn get(
path: web::Path<JsonUser>,
req: HttpRequest,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> impl Responder {
let request = req.clone();
let _token: JwtToken = JwtToken::decode_from_request(req).unwrap();
let req_user_id = path.user_id;
// Clone the Arc containing the connection pool
let pool_arc = pool.get_ref().clone();
// Acquire a connection from the pool
let mut connection = pool_arc.get().expect("Failed to get database connection");
let feed = feeds::get_feed("https://www.heise.de/rss/heise-Rubrik-Wissen.rdf").await.unwrap();
let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
let feed_title: String = feed.title.clone();
let feed_items: Vec<Article> = feed.into_items().into_iter().map(|item| {
let title = item.title.unwrap();
let frag = Html::parse_fragment(&item.content.unwrap());
let mut content = "".to_string();
let frag_clone = frag.clone();
frag.tree.into_iter().for_each(|node| {
let selector_img = Selector::parse("img").unwrap();
for element in frag_clone.select(&selector_img) {
if !content.starts_with("<img") {
content.push_str(&element.html());
content.push_str("<br>")
}
}
if let scraper::node::Node::Text(text) = node {
content.push_str(&text.text);
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
for feed in feeds {
feed_aggregates.push(get_feed_aggregate(feed, &mut connection))
}
});
Article {
title,
content,
}
} ).collect();
let feeds = vec![(Feed {title: feed_title, items: feed_items})];
let articles: Articles = Articles { feeds };
let articles: Articles = Articles {
feeds: feed_aggregates,
};
articles.respond_to(&request)
}
#[tracing::instrument(name = "Get feed aggregate", skip(connection))]
pub fn get_feed_aggregate(
feed: Feed,
connection: &mut r2d2::PooledConnection<ConnectionManager<PgConnection>>,
) -> FeedAggregate {
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(read.eq(false))
.order(id.asc())
.load(connection)
.unwrap();
let article_list: Vec<Article> = existing_item
.into_iter()
.map(|feed_item: FeedItem| {
let time: String = match feed_item.created_ts {
Some(r) => r.to_string(),
None => Local::now().naive_local().to_string(),
};
Article {
title: feed_item.title,
content: feed_item.content,
url: feed_item.url,
timestamp: time,
id: feed_item.id,
}
})
.collect();
FeedAggregate {
title: feed.title,
items: article_list,
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::schema::feed_item::{id, read};
use crate::{
json_serialization::read_feed_item::ReadItem, models::feed_item::rss_feed_item::FeedItem,
schema::feed_item,
};
use actix_web::{web, HttpRequest, HttpResponse};
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::{ExpressionMethods, QueryDsl};
use diesel::{PgConnection, RunQueryDsl};
#[tracing::instrument(name = "Mark as read", skip(pool))]
pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
let feed_items: Vec<FeedItem> = feed_item::table
.filter(id.eq(path.id))
.load::<FeedItem>(&mut connection)
.unwrap();
if feed_items.len() != 1 {
return HttpResponse::NotFound().await.unwrap();
}
let feed_item: &FeedItem = feed_items.first().unwrap();
let _result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
.set(read.eq(true))
.execute(&mut connection);
HttpResponse::Ok().await.unwrap()
}
+12 -1
View File
@@ -4,6 +4,9 @@ use crate::views::path::Path;
mod add;
pub mod feeds;
mod get;
mod mark_read;
mod read;
mod scraper;
pub mod structs;
mod sync;
@@ -13,7 +16,7 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
backend: true,
};
app.route(
&base_path.define(String::from("/get")),
&base_path.define(String::from("/get/{user_id}")),
web::get().to(get::get),
);
app.route(
@@ -24,4 +27,12 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
&base_path.define(String::from("/sync")),
actix_web::Route::to(web::post(), sync::sync),
);
app.route(
&base_path.define(String::from("/read")),
actix_web::Route::to(web::post(), read::read),
);
app.route(
&base_path.define(String::from("/read/{id}")),
actix_web::Route::to(web::put(), mark_read::mark_read),
);
}
+17
View File
@@ -0,0 +1,17 @@
use actix_web::{web, HttpRequest, Responder};
use crate::json_serialization::{readable::Readable, url::UrlJson};
use super::scraper::content::do_throttled_request;
#[tracing::instrument(name = "Read Feed")]
pub async fn read(_req: HttpRequest, data: web::Json<UrlJson>) -> impl Responder {
let result = do_throttled_request(&data.url);
let content = match result.await {
Ok(cont) => cont,
Err(e) => e.to_string(),
};
Readable { content }
}
+8
View File
@@ -0,0 +1,8 @@
use reqwest::Error;
// Do a request for the given URL, with a minimum time between requests
// to avoid overloading the server.
pub async fn do_throttled_request(url: &str) -> Result<String, Error> {
let response = reqwest::get(url).await?;
response.text().await
}
+1
View File
@@ -0,0 +1 @@
pub mod content;
+3
View File
@@ -4,6 +4,9 @@ use serde::Serialize;
pub struct Article {
pub title: String,
pub content: String,
pub url: String,
pub timestamp: String,
pub id: i32,
}
// impl Article {
+1 -7
View File
@@ -3,13 +3,7 @@ use serde::Serialize;
use super::article::Article;
#[derive(Serialize)]
pub struct Feed {
pub struct FeedAggregate {
pub title: String,
pub items: Vec<Article>,
}
//
// impl Feed {
// pub fn new(title: String, items: Vec<Article>) -> Feed {
// Feed { title, items }
// }
// }
+92 -94
View File
@@ -1,48 +1,56 @@
use super::feeds;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::{
database::establish_connection,
schema::{
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item::{feed_id, title};
use crate::schema::{
feed::{self, user_id},
feed_item,
},
};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{DateTime, Local, NaiveDateTime};
use dateparser::parse;
use diesel::prelude::*;
use futures::StreamExt;
use diesel::r2d2::{ConnectionManager, Pool};
use rss::Item;
use scraper::{Html, Selector};
use serde_derive::Deserialize;
#[derive(Deserialize)]
pub struct JsonUser {
user_id: String,
#[tracing::instrument(name = "Get Date")]
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
// let format_string = "%a, %d %b %Y %H:%M:%S %z";
let format_string = "%Y-%m-%dT%H:%M:%S%Z";
let result = parse(date_str).unwrap();
match NaiveDateTime::parse_from_str(&result.to_string(), format_string) {
Ok(r) => Ok(r),
Err(_) => {
let datetime = DateTime::parse_from_rfc2822(date_str);
match datetime {
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
Err(_) => match DateTime::parse_from_rfc2822(date_str) {
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
Err(e) => Err(e),
},
}
}
}
}
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
let mut connection: diesel::PgConnection = establish_connection();
#[tracing::instrument(name = "Create Feed Item", skip(connection))]
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let item_title = item.title.clone().unwrap();
let req_user_id = data.user_id.parse::<i32>().unwrap();
let base_content: &str = match item.content() {
Some(c) => c,
None => match item.description() {
Some(c) => c,
None => "",
},
};
let feed: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
log::info!("Found {} feeds to sync.", feed.len());
// Create an asynchronous stream of Feed items
let feed_stream = futures::stream::iter(feed.clone().into_iter()).map(|feed| {
// Asynchronously fetch the feed_list for each feed
log::info!("processing feed: {:?}", feed);
async move {
log::info!("start moved");
let feed_list: rss::Channel = feeds::get_feed(&feed.url).await.unwrap();
log::info!("{:?}", feed_list);
feed_list.into_items().into_iter().for_each(|item| {
let title = item.title.unwrap();
let frag = Html::parse_fragment(&item.content.unwrap());
let frag = Html::parse_fragment(base_content);
let mut content = "".to_string();
let frag_clone = frag.clone();
frag.tree.into_iter().for_each(|node| {
@@ -59,71 +67,61 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
}
});
let mut connection: diesel::PgConnection = establish_connection();
let new_feed_item =
NewFeedItem::new(feed.id, content.clone(), title.clone(), feed.url.clone());
let insert_result = diesel::insert_into(feed_item::table)
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title))
.load(connection)
.unwrap();
if existing_item.is_empty() {
let mut time: NaiveDateTime = Local::now().naive_local();
if item.pub_date().is_some() {
time = match get_date(item.pub_date().unwrap()) {
Ok(date) => date,
Err(_err) => time,
};
}
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.unwrap(),
Some(time),
);
let _insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(&mut connection);
log::info!("{:?}", insert_result);
});
}
});
// Execute the asynchronous stream
let result = tokio::spawn(feed_stream.for_each(|_| async {})).await;
if result.is_err() {
log::error!("{:?}", result);
HttpResponse::InternalServerError()
} else {
HttpResponse::Ok()
.execute(connection);
}
}
// pub async fn sync(req: HttpRequest) -> impl Responder {
// let request = req.clone();
// let mut connection: diesel::PgConnection = establish_connection();
//
// let feed: Vec<Feed> = feed::table.load::<Feed>(&mut connection).unwrap();
// let feed = feeds::get_feed("https://www.heise.de/rss/heise-Rubrik-Wissen.rdf").await.unwrap();
#[tracing::instrument(name = "sync", skip(pool))]
pub async fn sync(
_req: HttpRequest,
data: web::Json<JsonUser>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
// let feed_title: String = feed.title.clone();
// let feed_items: Vec<Article> = feed
// .into_items()
// .into_iter()
// .map(|item| {
// let title = item.title.unwrap();
// let frag = Html::parse_fragment(&item.content.unwrap());
// let mut content = "".to_string();
// let frag_clone = frag.clone();
// frag.tree.into_iter().for_each(|node| {
// let selector_img = Selector::parse("img").unwrap();
//
// for element in frag_clone.select(&selector_img) {
// if !content.starts_with("<img") {
// content.push_str(&element.html());
// content.push_str("<br>")
// }
// }
// if let scraper::node::Node::Text(text) = node {
// content.push_str(&text.text);
// }
// });
// Article { title, content }
// })
// .collect();
let req_user_id: i32 = data.user_id;
// let feed_stream = stream::iter(feed.iter().cloned()).map(|feed: Feed| {
// let feed_list = feeds::get_feed(&feed.url).await.unwrap();
// // Process feed_list here
// });
//
// tokio::spawn(feed_stream.for_each(|_| async {}));
// // let feed_list = feeds::get_feed(&feed.url).await.unwrap();
// // feed.iter().for_each(|feed: &Feed| {
// // });
//
// HttpResponse::Ok()
// }
let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
for feed in feeds {
let result = feeds::get_feed(&feed.url).await;
match result {
Ok(channel) => {
for item in channel.into_items() {
create_feed_item(item, &feed, &mut connection);
}
}
Err(_e) => return HttpResponse::InternalServerError().await.unwrap(),
}
}
HttpResponse::Ok().await.unwrap()
}
+1
View File
@@ -17,6 +17,7 @@ diesel::table! {
read -> Bool,
title -> Varchar,
url -> Varchar,
created_ts -> Nullable<Timestamp>,
}
}
+56
View File
@@ -0,0 +1,56 @@
use std::net::TcpListener;
use actix_service::Service;
use actix_web::web;
use actix_web::{dev::Server, App, HttpResponse, HttpServer};
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::PgConnection;
use futures::future::{ok, Either};
use crate::auth;
use crate::views;
#[tracing::instrument(name = "Run application", skip(connection, listener))]
pub fn run(
listener: TcpListener,
connection: Pool<ConnectionManager<PgConnection>>,
) -> Result<Server, std::io::Error> {
let wrapper = web::Data::new(connection);
let server = HttpServer::new(move || {
App::new()
.wrap_fn(|req, srv| {
let mut passed: bool;
if req.path().contains("/article/") {
match auth::process_token(&req) {
Ok(_token) => passed = true,
Err(_message) => passed = false,
}
} else {
passed = true;
}
if req.path().contains("user/create") {
passed = true;
}
let end_result = match passed {
true => Either::Left(srv.call(req)),
false => Either::Right(ok(req.into_response(
HttpResponse::Unauthorized().finish().map_into_boxed_body(),
))),
};
async move {
let result = end_result.await?;
Ok(result)
}
})
.app_data(wrapper.clone())
.configure(views::views_factory)
})
.listen(listener)?
.run();
Ok(server)
}
+27
View File
@@ -0,0 +1,27 @@
use tracing::{dispatcher::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
pub fn get_subscriber<Sink>(
name: String,
env_filter: String,
sink: Sink,
) -> impl Subscriber + Send + Sync
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
let formatting_layer = BunyanFormattingLayer::new(name, sink);
Registry::default()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger.");
set_global_default(subscriber.into()).expect("Failed to set subscriber.");
}
+8 -9
View File
@@ -1,4 +1,3 @@
use crate::database::establish_connection;
use crate::diesel;
use crate::json_serialization::login::Login;
use crate::models::user::rss_user::User;
@@ -6,13 +5,18 @@ use crate::schema::users;
use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
pub async fn login(
credentials: web::Json<Login>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
let username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone();
let mut connection = establish_connection();
let users: Vec<User> = users::table
.filter(username.eq(username_cred.as_str()))
.load::<User>(&mut connection)
@@ -21,10 +25,6 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
if users.is_empty() {
return HttpResponse::NotFound().await.unwrap();
} else if users.len() > 1 {
log::error!(
"multiple user have the usernam: {}",
credentials.username.clone()
);
return HttpResponse::Conflict().await.unwrap();
}
@@ -32,7 +32,6 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
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()
.insert_header(("token", token))
+1 -1
View File
@@ -1,3 +1,3 @@
pub async fn logout() -> String {
format!("logout view")
"logout view".to_string()
}
+1 -2
View File
@@ -8,10 +8,9 @@ impl Path {
match self.backend {
true => {
let path: String = self.prefix.to_owned() + &following_path;
return String::from("/api/v1") + &path;
String::from("/api/v1") + &path
}
false => self.prefix.to_owned() + &following_path,
}
}
}
+14 -5
View File
@@ -1,16 +1,25 @@
use crate::database::establish_connection;
use crate::diesel;
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::*;
use diesel::{
prelude::*,
r2d2::{ConnectionManager, Pool},
};
use secrecy::Secret;
#[tracing::instrument(name = "Create new User", skip(pool))]
pub async fn create(
new_user: web::Json<NewUserSchema>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
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_password: Secret<String> = Secret::new(new_user.password.clone());
let new_user = NewUser::new(name, email, new_password);
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8001
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://rust-app:8001
+22
View File
@@ -0,0 +1,22 @@
FROM node:lts-alpine
# install simple http server for serving static content
RUN npm install -g http-server
# make the 'app' folder the current working directory
WORKDIR /app
# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
# install project dependencies
RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . .
# build app for production with minification
RUN npm run build
EXPOSE 8080
CMD [ "http-server", "dist" ]
+4 -1
View File
@@ -1,13 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>RSS-Reader</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+22
View File
@@ -8,6 +8,7 @@
"name": "rss",
"version": "0.0.0",
"dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
@@ -17,6 +18,7 @@
"@rushstack/eslint-patch": "^1.3.2",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0",
"dotenv": "^16.4.5",
"eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1",
"prettier": "^3.0.0",
@@ -489,6 +491,14 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg=="
},
"node_modules/@mozilla/readability": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/@mozilla/readability/-/readability-0.4.4.tgz",
"integrity": "sha512-MCgZyANpJ6msfvVMi6+A0UAsvZj//4OHREYUB9f2087uXHVoU+H+SWhuihvb1beKpM323bReQPRio0WNk2+V6g==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1016,6 +1026,18 @@
"node": ">=6.0.0"
}
},
"node_modules/dotenv": {
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
+2
View File
@@ -10,6 +10,7 @@
"format": "prettier --write src/"
},
"dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
@@ -19,6 +20,7 @@
"@rushstack/eslint-patch": "^1.3.2",
"@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0",
"dotenv": "^16.4.5",
"eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1",
"prettier": "^3.0.0",
+17 -25
View File
@@ -1,27 +1,24 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<RouterLink to="/feeds">Feeds</RouterLink>
</nav>
</div>
</header>
<!-- <header> -->
<!-- <div class="wrapper"> -->
<!-- <nav> -->
<!-- <p onclick="$refs.sync">Sync</p> -->
<!-- <p>Login</p> -->
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
<!-- </nav> -->
<!-- </div> -->
<!-- </header> -->
<RouterView />
</template>
<style scoped>
<style>
header {
line-height: 1.5;
max-height: 100vh;
@@ -39,27 +36,27 @@ nav {
margin-top: 2rem;
}
nav a.router-link-exact-active {
nav p.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
nav p.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
nav p {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
nav a:first-of-type {
nav p:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
@@ -68,11 +65,6 @@ nav a:first-of-type {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
+85 -8
View File
@@ -15,21 +15,98 @@ a,
transition: 0.4s;
}
.message {
background-color: #3498db;
color: white;
padding: 10px;
border-radius: 4px;
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
.feed-title {
cursor: pointer;
font-family: 'Courier New';
font-size: 22px;
border-bottom: 1px solid #ccc;
padding: 1em;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
.feed-content {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 20px;
padding: 1em;
display: flex;
flex-direction: column;
align-items: left;
text-align: left;
}
.feed-content p {
padding: 1em;
}
.feed-content h2,
h3,
h4,
h5,
h6 {
padding: 1em;
font-size: 21px;
font-weight: bold;
}
.feed-content img {
max-width: 100%;
margin-bottom: 10px;
/* Adjust spacing between image and text */
}
h3 {
font-size: 14px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"],
input[type="password"] {
/* width: 100%; */
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
.error {
color: red;
}
+62
View File
@@ -0,0 +1,62 @@
input {
margin: 15px;
}
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
transition: opacity 0.3s ease;
}
.modal-container {
width: 300px;
margin: auto;
padding: 20px 30px;
background-color: #fff;
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.3s ease;
}
.modal-header h3 {
margin-top: 0;
color: #42b983;
}
.modal-body {
margin: 20px 0;
}
.modal-default-button {
float: right;
}
/*
* The following styles are auto-applied to elements with
* transition="modal" when their visibility is toggled
* by Vue.js.
*
* You can easily play with the modal transition by editing
* these styles.
*/
.modal-enter-from {
opacity: 0;
}
.modal-leave-to {
opacity: 0;
}
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
-webkit-transform: scale(1.1);
transform: scale(1.1);
}
+1 -1
View File
@@ -38,7 +38,7 @@ async function login() {
localStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-token", token)
router.push({ name: 'about' })
router.push({ name: 'feeds' })
}
// Handle success
} catch (error) {
+162 -26
View File
@@ -1,28 +1,18 @@
<script setup>
import { ref, onMounted } from 'vue';
import { ref, unref, onMounted, nextTick } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
import Modal from './modal/AddUrl.vue';
const showMessage = ref(false)
const feeds = ref([]);
const buttonText = 'Sync'
const message = ref('')
const showModal = ref(false)
const fetchData = async () => {
async function getReadable(feed, index) {
try {
const response = await axios.get('feeds/get', {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
});
feeds.value = response.data.feeds[0].items;
} catch (error) {
console.error('Error fetching data:', error);
}
};
async function sync() {
try {
const repsponse = await axios.post('feeds/sync', {
user_id: localStorage.getItem("user-id")
const response = await axios.post("feeds/read", {
url: feed.url
},
{
headers: {
@@ -31,24 +21,170 @@ async function sync() {
}
})
const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
const article = new Readability(doc).parse();
feeds.value[index].content = article.content;
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
}
async function markRead(id) {
try {
const response = await axios.put("feeds/read/" + id,
null,
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
}
)
console.log(response.status)
} catch (error) {
console.log(error)
}
}
function showMessageForXSeconds(text, seconds) {
message.value = text;
showMessage.value = true;
// Set a timeout to hide the message after x seconds
setTimeout(() => {
showMessage.value = false;
message.value = '';
}, seconds * 1000); // Convert seconds to milliseconds
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try {
const response = await axios.get("feeds/get/" + user_id, {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
});
const sortedItems = response.data.feeds.flatMap(feed => feed.items)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
feeds.value.push(...sortedItems);
await nextTick();
setupIntersectionObserver();
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
};
async function sync() {
try {
const response = await axios.post('feeds/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
if (response.status == 200) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
} catch (error) {
console.error('Error sync', error)
showMessageForXSeconds(error, 5)
}
}
onMounted(() => {
fetchData();
let observer; // Declare observer outside the setup function
function setupIntersectionObserver() {
observer = new IntersectionObserver(handleIntersection, {
root: null, // Use the viewport as the root
rootMargin: '0px',
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
});
const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) {
observedDivs.forEach(observedDiv => {
observer.observe(observedDiv);
})
}
}
async function handleIntersection(entries) {
// The callback function for when the target element enters or exits the viewport
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is in sight');
} else if (initialLoad === true) {
console.log(entry.isIntersecting)
// Element is out of sight
if (entry.isVisible === false && entry.boundingClientRect.y < 0) {
console.log('Element is out of sight ' + entry.intersectionRatio);
//console.log(feeds.value[entry.target.id])
markRead(feeds.value[entry.target.id].id).await
removeFeed(entry.target.id)
document.getElementById(0).scrollIntoView()
}
}
})
}
function removeFeed(index) {
const array = unref(feeds);
array.splice(index, 1);
}
let initialLoad = false
onMounted(() => {
initialLoad = false
fetchData().await
setTimeout(function () {
initialLoad = true
console.log('set to true')
}, 2000);
});
</script>
<template>
<header>
<div class="wrapper">
<nav>
<p @click="sync">Sync</p>
<!-- <p @click="updateShow(true)">Add RSS</p> -->
<p @click="showModal = true">Add RSS</p>
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
</nav>
</div>
</header>
<Teleport to="body">
<!-- use the modal component, pass in the prop -->
<modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</modal>
</Teleport>
<div>
<h1>Feeds</h1> <button @click="sync">{{ buttonText }}</button>
<div id='aricle'>
<template v-for="feed in feeds">
<h2>{{ feed.title }}</h2>
<p v-html='feed.content'></p>
<h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
<div v-if="showMessage" class="message">{{ message }}</div>
<div id='article' class='article'>
<p v-if="feeds.length == 0">No unread articles.</p>
<template v-for="( feed, index ) in feeds ">
<div v-bind:id="index" class="observe">
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h3>{{ feed.timestamp }}</h3>
<p class="feed-content" v-html='feed.content'></p>
</div>
</template>
</div>
</div>
+68
View File
@@ -0,0 +1,68 @@
<script setup>
import '@/assets/modal.css';
import { ref } from 'vue';
import axios from 'axios';
const props = defineProps({
show: Boolean
})
const submitted = ref(false)
const url = ref('')
const title = ref('')
const output = ref('')
async function save() {
output.value = ''
submitted.value = true;
console.log('saved ' + url.value)
try {
const response = await axios.post("feeds/add", {
url: url.value,
title: title.value,
user_id: parseInt(localStorage.getItem("user-id"))
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
}
)
console.log(response)
output.value = 'saved successfully'
} catch (error) {
console.error(error.message)
output.value = error.message
}
}
</script>
<template>
<Transition name="modal">
<div v-if="show" class="modal-mask">
<div class="modal-container">
<div class="modal-header">
<slot name="header">Add RSS Feed</slot>
</div>
<form @submit.prevent="submitForm">
<label for="name">URL:</label>
<input v-model="url" id="url" type="text" required />
<label for="name">Title:</label>
<input v-model="title" id="title" type="text" required />
<div v-if="submitted">
<p>{{ output }}</p>
</div>
<div class="modal-footer">
<slot name="footer">
<button type="submit" @click="save">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button>
</slot>
</div>
</form>
</div>
</div>
</Transition>
</template>
+24 -3
View File
@@ -2,6 +2,14 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dotenv from 'dotenv';
console.log('process.env:', process.env);
console.log('TEst:', process.env.VITE_API_BASE_URL);
// Load environment variables based on the environment mode
dotenv.config({
path: `.env.${process.env.NODE_ENV || 'development'}`
});
// https://vitejs.dev/config/
export default defineConfig({
@@ -17,24 +25,37 @@ export default defineConfig({
server: {
proxy: {
'/login/rss': {
target: 'http://localhost:8001/api/v1/auth/login',
target: `${process.env.VITE_API_BASE_URL}/api/v1/auth/login`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/login\/rss/, ''),
},
'/feeds/get': {
target: 'http://localhost:8001/api/v1/article/get',
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/get`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/get/, ''),
},
'/feeds/sync': {
target: 'http://localhost:8001/api/v1/article/sync',
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/sync`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/sync/, ''),
},
'/feeds/read': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/read`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/read/, ''),
},
'/feeds/add': {
target: `${process.env.VITE_API_BASE_URL}/api/v1/article/add`,
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/feeds\/add/, ''),
},
},
cors: false
},