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
+22 -5
View File
@@ -3,29 +3,46 @@ name = "rss-reader"
version = "0.1.0" version = "0.1.0"
edition = "2021" 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 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
reqwest = { version = "0.11", features = ["json"] } reqwest = { version = "0.11", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.1" } rss = { version = "2.0.1" }
actix-web = "4.1.0" actix-web = "4.1.0"
actix-rt = "2.7.0" actix-rt = "2.7.0"
futures = "0.3.24" futures = "0.3.24"
serde = {version = "1.0.144", features = ["alloc","derive","serde_derive"]} serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
serde_derive = "1.0.145" serde_derive = "1.0.145"
actix-service = "2.0.2" 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" dotenv = "0.15.0"
bcrypt = "0.13.0" bcrypt = "0.13.0"
uuid = {version = "1.2.1", features=["serde", "v4"]} uuid = {version = "1.2.1", features=["serde", "v4"]}
jwt = "0.16.0" jwt = "0.16.0"
hmac = "0.12.1" hmac = "0.12.1"
sha2 = "0.10.6" sha2 = "0.10.6"
log = "0.4.17"
env_logger = "0.9.3"
scraper = "0.14.0" scraper = "0.14.0"
actix-cors = "0.6.4" 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] [dependencies.serde_json]
version = "1.0.86" 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" version: "3.7"
services: services:
# vue-app:
# build:
# context: ./vue/
# dockerfile: Dockerfile
# ports:
# - "8080:8080" # Adjust the port as needed for your Rust application
# networks:
# - app-network
postgres: postgres:
restart: always
container_name: "rss-postgres" container_name: "rss-postgres"
image: "postgres:15" image: "postgres:15"
ports: ports:
@@ -11,6 +22,23 @@ services:
- "POSTGRES_PASSWORD=secret+123" - "POSTGRES_PASSWORD=secret+123"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - 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: volumes:
postgres_data: 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> { 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) => 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::check_password;
use crate::auth::processes::extract_header_token; use crate::auth::processes::extract_header_token;
#[tracing::instrument(name = "Process token")]
pub fn process_token(request: &ServiceRequest) -> Result<String, &'static str> { pub fn process_token(request: &ServiceRequest) -> Result<String, &'static str> {
match extract_header_token(request) { match extract_header_token(request) {
Ok(token) => check_password(token), Ok(token) => check_password(token),
+10 -11
View File
@@ -1,21 +1,19 @@
use super::jwt; use super::jwt;
use actix_web::dev::ServiceRequest; use actix_web::dev::ServiceRequest;
use secrecy::{ExposeSecret, Secret};
pub fn check_password(password: String) -> Result<String, &'static str> { pub fn check_password(password: Secret<String>) -> Result<String, &'static str> {
match jwt::JwtToken::decode(password) { match jwt::JwtToken::decode(password.expose_secret().to_string()) {
Ok(_token) => Ok(String::from("passed")), Ok(_token) => Ok(String::from("passed")),
Err(message) => Err(message), Err(message) => Err(message),
} }
} }
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> { #[tracing::instrument(name = "Extract Header Token")]
log::info!("Request: {:?}", request); pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>, &'static str> {
match request.headers().get("user-token") { match request.headers().get("user-token") {
Some(token) => match token.to_str() { Some(token) => match token.to_str() {
Ok(processed_password) => { Ok(processed_password) => Ok(Secret::new(String::from(processed_password))),
log::info!("Token provided: {}", processed_password);
Ok(String::from(processed_password))
}
Err(_processed_password) => Err("there was an error processing token"), Err(_processed_password) => Err("there was an error processing token"),
}, },
None => Err("there is no token"), None => Err("there is no token"),
@@ -25,6 +23,7 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
#[cfg(test)] #[cfg(test)]
mod processes_test { mod processes_test {
use actix_web::test::TestRequest; use actix_web::test::TestRequest;
use secrecy::{ExposeSecret, Secret};
use crate::auth::jwt::JwtToken; use crate::auth::jwt::JwtToken;
@@ -32,7 +31,7 @@ mod processes_test {
#[test] #[test]
fn check_correct_password() { 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); let result = check_password(password_string);
@@ -44,7 +43,7 @@ mod processes_test {
#[test] #[test]
fn incorrect_check_password() { fn incorrect_check_password() {
let password: String = String::from("test"); let password: Secret<String> = Secret::new(String::from("test"));
match check_password(password) { match check_password(password) {
Err(message) => assert_eq!("could not decode token", message), Err(message) => assert_eq!("could not decode token", message),
@@ -59,7 +58,7 @@ mod processes_test {
.to_srv_request(); .to_srv_request();
match super::extract_header_token(&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"), _ => 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::pg::PgConnection;
use diesel::prelude::*; use diesel::r2d2::{ConnectionManager, Pool};
use dotenv::dotenv;
use std::env;
pub fn establish_connection() -> PgConnection { pub fn get_connection_pool(url: &str) -> Pool<ConnectionManager<PgConnection>> {
dotenv().ok(); let manager = ConnectionManager::<PgConnection>::new(url);
// Refer to the `r2d2` documentation for more methods to use
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); // when building a connection pool
PgConnection::establish(&database_url) Pool::builder()
.unwrap_or_else(|_| panic!("Error connecting to database {}", database_url)) .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 reqwest::StatusCode;
use serde::Serialize; use serde::Serialize;
use crate::reader::structs::feed::Feed; use crate::reader::structs::feed::FeedAggregate;
#[derive(Serialize)] #[derive(Serialize)]
pub struct Articles { pub struct Articles {
pub feeds: Vec<Feed>, pub feeds: Vec<FeedAggregate>,
} }
impl Responder for Articles { impl Responder for Articles {
+4
View File
@@ -3,3 +3,7 @@ pub mod login;
pub mod new_feed; pub mod new_feed;
pub mod new_feed_item; pub mod new_feed_item;
pub mod new_user; 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; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct NewFeedSchema { pub struct NewFeedSchema {
pub title: String, pub title: String,
pub url: String, pub url: String,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize, Debug)]
pub struct NewUserSchema { pub struct NewUserSchema {
pub name: String, pub name: String,
pub email: 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;
+23 -52
View File
@@ -1,61 +1,32 @@
extern crate diesel; use std::net::TcpListener;
extern crate dotenv;
use actix_service::Service; use diesel::{
use actix_web::{App, HttpResponse, HttpServer}; r2d2::{ConnectionManager, Pool},
use futures::future::{ok, Either}; PgConnection,
mod auth; };
mod database; use rss_reader::{
mod json_serialization; configuration::get_configuration,
mod models; database::get_connection_pool,
mod reader; startup::run,
mod schema; telemetry::{get_subscriber, init_subscriber},
mod views; };
use secrecy::ExposeSecret;
#[actix_rt::main] #[actix_rt::main]
async fn main() -> std::io::Result<()> { 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 configuration = get_configuration().expect("Failed to read configuration.");
let app = App::new()
.wrap_fn(|req, srv| {
let mut passed: bool;
let request_url: String = String::from(req.uri().path());
log::info!("Request Url: {}", request_url); let connection_pool: Pool<ConnectionManager<PgConnection>> =
if req.path().contains("/article/") { get_connection_pool(configuration.database.connection_string().expose_secret());
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") { let address = format!(
passed = true; "{}:{}",
} configuration.application.host, configuration.application.port
);
log::info!("passed: {:?}", passed); let listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await
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
} }
+1 -1
View File
@@ -1,2 +1,2 @@
pub mod new_feed_item; 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 chrono::NaiveDateTime;
// use bcrypt::{hash, DEFAULT_COST};
use diesel::Insertable; use diesel::Insertable;
// use uuid::Uuid;
use crate::schema::feed_item; use crate::schema::feed_item;
@@ -13,15 +10,23 @@ pub struct NewFeedItem {
pub content: String, pub content: String,
pub title: String, pub title: String,
pub url: String, pub url: String,
pub created_ts: Option<NaiveDateTime>,
} }
impl NewFeedItem { 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 { Self {
feed_id, feed_id,
content, content,
title, title,
url, 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; use crate::schema::feed_item;
use chrono::NaiveDateTime;
#[derive(Clone, Queryable, Identifiable, Associations)] use diesel::{Associations, Identifiable, Queryable, Selectable};
#[derive(Clone, Identifiable, Selectable, Queryable, Associations)]
#[diesel(belongs_to(Feed))] #[diesel(belongs_to(Feed))]
#[diesel(table_name=feed_item)] #[diesel(table_name=feed_item)]
pub struct Feed { pub struct FeedItem {
pub id: i32, pub id: i32,
pub feed_id: i32, pub feed_id: i32,
pub title: String,
pub url: String,
pub content: String, pub content: String,
pub read: bool, 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 bcrypt::{hash, DEFAULT_COST};
use diesel::Insertable; use diesel::Insertable;
use secrecy::{ExposeSecret, Secret};
use uuid::Uuid; use uuid::Uuid;
use crate::schema::users; use crate::schema::users;
@@ -16,8 +17,9 @@ 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: Secret<String>) -> NewUser {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap(); let hashed_password: String =
hash(password.expose_secret().as_str(), DEFAULT_COST).unwrap();
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
NewUser { NewUser {
username, username,
+30 -11
View File
@@ -1,17 +1,39 @@
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::RunQueryDsl; use diesel::{
r2d2::{ConnectionManager, Pool},
use crate::{ PgConnection, RunQueryDsl,
database::establish_connection, json_serialization::new_feed::NewFeedSchema,
models::feed::new_feed::NewFeed, schema::feed,
}; };
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse { use crate::{
let mut connection = establish_connection(); 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 title: String = new_feed.title.clone();
let url: String = new_feed.url.clone(); let url: String = new_feed.url.clone();
let user_id: i32 = new_feed.user_id; 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 new_feed = NewFeed::new(title, url, user_id);
let insert_result = diesel::insert_into(feed::table) 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 { match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(), Ok(_) => HttpResponse::Created().await.unwrap(),
Err(e) => { Err(_) => HttpResponse::Conflict().await.unwrap(),
log::error!("{e}");
HttpResponse::Conflict().await.unwrap()
}
} }
} }
+1 -1
View File
@@ -2,9 +2,9 @@ use std::error::Error;
use rss::Channel; use rss::Channel;
#[tracing::instrument(name = "Get Channel Feed")]
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> { pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
let content = reqwest::get(feed).await?.bytes().await?; let content = reqwest::get(feed).await?.bytes().await?;
let channel = Channel::read_from(&content[..])?; let channel = Channel::read_from(&content[..])?;
log::info!("{:?}", channel);
Ok(channel) Ok(channel)
} }
+71 -34
View File
@@ -1,44 +1,81 @@
use crate::{auth::jwt::JwtToken, reader::feeds, json_serialization::articles::Articles}; use crate::json_serialization::user::JsonUser;
use actix_web::{HttpRequest, Responder}; use crate::models::feed::rss_feed::Feed;
use scraper::{Html, Selector }; 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 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 mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
let feed_items: Vec<Article> = feed.into_items().into_iter().map(|item| { for feed in feeds {
let title = item.title.unwrap(); feed_aggregates.push(get_feed_aggregate(feed, &mut connection))
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 articles: Articles = Articles {
Article { feeds: feed_aggregates,
title, };
content,
}
} ).collect();
let feeds = vec![(Feed {title: feed_title, items: feed_items})];
let articles: Articles = Articles { feeds };
articles.respond_to(&request) 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; mod add;
pub mod feeds; pub mod feeds;
mod get; mod get;
mod mark_read;
mod read;
mod scraper;
pub mod structs; pub mod structs;
mod sync; mod sync;
@@ -13,7 +16,7 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
backend: true, backend: true,
}; };
app.route( app.route(
&base_path.define(String::from("/get")), &base_path.define(String::from("/get/{user_id}")),
web::get().to(get::get), web::get().to(get::get),
); );
app.route( app.route(
@@ -24,4 +27,12 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
&base_path.define(String::from("/sync")), &base_path.define(String::from("/sync")),
actix_web::Route::to(web::post(), sync::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 struct Article {
pub title: String, pub title: String,
pub content: String, pub content: String,
pub url: String,
pub timestamp: String,
pub id: i32,
} }
// impl Article { // impl Article {
+1 -7
View File
@@ -3,13 +3,7 @@ use serde::Serialize;
use super::article::Article; use super::article::Article;
#[derive(Serialize)] #[derive(Serialize)]
pub struct Feed { pub struct FeedAggregate {
pub title: String, pub title: String,
pub items: Vec<Article>, pub items: Vec<Article>,
} }
//
// impl Feed {
// pub fn new(title: String, items: Vec<Article>) -> Feed {
// Feed { title, items }
// }
// }
+91 -93
View File
@@ -1,48 +1,56 @@
use super::feeds; use super::feeds;
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;
use crate::{ use crate::models::feed_item::rss_feed_item::FeedItem;
database::establish_connection, use crate::schema::feed_item::{feed_id, title};
schema::{ use crate::schema::{
feed::{self, user_id}, feed::{self, user_id},
feed_item, 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 diesel::prelude::*;
use futures::StreamExt; use diesel::r2d2::{ConnectionManager, Pool};
use rss::Item;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use serde_derive::Deserialize;
#[derive(Deserialize)] #[tracing::instrument(name = "Get Date")]
pub struct JsonUser { fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
user_id: String, // 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 { #[tracing::instrument(name = "Create Feed Item", skip(connection))]
let mut connection: diesel::PgConnection = establish_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 let frag = Html::parse_fragment(base_content);
.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 mut content = "".to_string(); let mut content = "".to_string();
let frag_clone = frag.clone(); let frag_clone = frag.clone();
frag.tree.into_iter().for_each(|node| { 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 existing_item: Vec<FeedItem> = feed_item::table
let new_feed_item = .filter(feed_id.eq(feed.id))
NewFeedItem::new(feed.id, content.clone(), title.clone(), feed.url.clone()); .filter(title.eq(&item_title))
let insert_result = diesel::insert_into(feed_item::table) .load(connection)
.values(&new_feed_item) .unwrap();
.execute(&mut connection);
log::info!("{:?}", insert_result); 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,
// Execute the asynchronous stream content.clone(),
let result = tokio::spawn(feed_stream.for_each(|_| async {})).await; item_title.clone(),
item.link.unwrap(),
if result.is_err() { Some(time),
log::error!("{:?}", result); );
HttpResponse::InternalServerError() let _insert_result = diesel::insert_into(feed_item::table)
} else { .values(&new_feed_item)
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 req_user_id: i32 = data.user_id;
// 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 feed_stream = stream::iter(feed.iter().cloned()).map(|feed: Feed| { let feeds: Vec<Feed> = feed::table
// let feed_list = feeds::get_feed(&feed.url).await.unwrap(); .filter(user_id.eq(req_user_id))
// // Process feed_list here .load::<Feed>(&mut connection)
// }); .unwrap();
//
// tokio::spawn(feed_stream.for_each(|_| async {})); for feed in feeds {
// // let feed_list = feeds::get_feed(&feed.url).await.unwrap(); let result = feeds::get_feed(&feed.url).await;
// // feed.iter().for_each(|feed: &Feed| {
// // }); match result {
// Ok(channel) => {
// HttpResponse::Ok() 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, read -> Bool,
title -> Varchar, title -> Varchar,
url -> 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::diesel;
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;
@@ -6,13 +5,18 @@ use crate::schema::users;
use crate::{auth::jwt::JwtToken, schema::users::username}; use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::prelude::*; 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 username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone(); let password: String = credentials.password.clone();
let mut connection = establish_connection();
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)
@@ -21,10 +25,6 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
if users.is_empty() { if users.is_empty() {
return HttpResponse::NotFound().await.unwrap(); return HttpResponse::NotFound().await.unwrap();
} else if users.len() > 1 { } else if users.len() > 1 {
log::error!(
"multiple user have the usernam: {}",
credentials.username.clone()
);
return HttpResponse::Conflict().await.unwrap(); return HttpResponse::Conflict().await.unwrap();
} }
@@ -32,7 +32,6 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
match user.clone().verify(password) { match user.clone().verify(password) {
true => { true => {
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() HttpResponse::Ok()
.insert_header(("token", token)) .insert_header(("token", token))
+1 -1
View File
@@ -1,3 +1,3 @@
pub async fn logout() -> String { 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 { match self.backend {
true => { true => {
let path: String = self.prefix.to_owned() + &following_path; 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, 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::diesel;
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::*,
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 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: Secret<String> = Secret::new(new_user.password.clone());
let new_user = NewUser::new(name, email, new_password); 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" ]
+8 -5
View File
@@ -1,13 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>RSS-Reader</title>
</head> </head>
<body>
<body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
+22
View File
@@ -8,6 +8,7 @@
"name": "rss", "name": "rss",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0", "axios": "^1.5.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",
@@ -17,6 +18,7 @@
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
"@vitejs/plugin-vue": "^4.3.1", "@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^8.0.0",
"dotenv": "^16.4.5",
"eslint": "^8.46.0", "eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1", "eslint-plugin-vue": "^9.16.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
@@ -489,6 +491,14 @@
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1016,6 +1026,18 @@
"node": ">=6.0.0" "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": { "node_modules/esbuild": {
"version": "0.18.20", "version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
+2
View File
@@ -10,6 +10,7 @@
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0", "axios": "^1.5.0",
"vue": "^3.3.4", "vue": "^3.3.4",
"vue-router": "^4.2.4", "vue-router": "^4.2.4",
@@ -19,6 +20,7 @@
"@rushstack/eslint-patch": "^1.3.2", "@rushstack/eslint-patch": "^1.3.2",
"@vitejs/plugin-vue": "^4.3.1", "@vitejs/plugin-vue": "^4.3.1",
"@vue/eslint-config-prettier": "^8.0.0", "@vue/eslint-config-prettier": "^8.0.0",
"dotenv": "^16.4.5",
"eslint": "^8.46.0", "eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1", "eslint-plugin-vue": "^9.16.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
+17 -25
View File
@@ -1,27 +1,24 @@
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router' import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
</script> </script>
<template> <template>
<header> <!-- <header> -->
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> <!-- <div class="wrapper"> -->
<!-- <nav> -->
<div class="wrapper"> <!-- <p onclick="$refs.sync">Sync</p> -->
<HelloWorld msg="You did it!" /> <!-- <p>Login</p> -->
<!-- <RouterLink to="/">Home</RouterLink> -->
<nav> <!-- <RouterLink to="/about">About</RouterLink> -->
<RouterLink to="/">Home</RouterLink> <!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
<RouterLink to="/about">About</RouterLink> <!-- </nav> -->
<RouterLink to="/feeds">Feeds</RouterLink> <!-- </div> -->
</nav> <!-- </header> -->
</div>
</header>
<RouterView /> <RouterView />
</template> </template>
<style scoped> <style>
header { header {
line-height: 1.5; line-height: 1.5;
max-height: 100vh; max-height: 100vh;
@@ -39,27 +36,27 @@ nav {
margin-top: 2rem; margin-top: 2rem;
} }
nav a.router-link-exact-active { nav p.router-link-exact-active {
color: var(--color-text); color: var(--color-text);
} }
nav a.router-link-exact-active:hover { nav p.router-link-exact-active:hover {
background-color: transparent; background-color: transparent;
} }
nav a { nav p {
display: inline-block; display: inline-block;
padding: 0 1rem; padding: 0 1rem;
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
cursor: pointer;
} }
nav a:first-of-type { nav p:first-of-type {
border: 0; border: 0;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
header { header {
display: flex;
place-items: center; place-items: center;
padding-right: calc(var(--section-gap) / 2); padding-right: calc(var(--section-gap) / 2);
} }
@@ -68,11 +65,6 @@ nav a:first-of-type {
margin: 0 2rem 0 0; margin: 0 2rem 0 0;
} }
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav { nav {
text-align: left; text-align: left;
+88 -11
View File
@@ -15,21 +15,98 @@ a,
transition: 0.4s; 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) { @media (hover: hover) {
a:hover { a:hover {
background-color: hsla(160, 100%, 37%, 0.2); background-color: hsla(160, 100%, 37%, 0.2);
} }
} }
@media (min-width: 1024px) { .feed-title {
body { cursor: pointer;
display: flex; font-family: 'Courier New';
place-items: center; font-size: 22px;
} border-bottom: 1px solid #ccc;
padding: 1em;
#app { }
display: grid;
grid-template-columns: 1fr 1fr; .feed-content {
padding: 0 2rem; 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) localStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-id", user_id) sessionStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-token", token) sessionStorage.setItem("user-token", token)
router.push({ name: 'about' }) router.push({ name: 'feeds' })
} }
// Handle success // Handle success
} catch (error) { } catch (error) {
+164 -28
View File
@@ -1,28 +1,18 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, unref, onMounted, nextTick } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { Readability } from '@mozilla/readability';
import Modal from './modal/AddUrl.vue';
const showMessage = ref(false)
const feeds = ref([]); const feeds = ref([]);
const buttonText = 'Sync' const message = ref('')
const showModal = ref(false)
const fetchData = async () => { async function getReadable(feed, index) {
try { try {
const response = await axios.get('feeds/get', { const response = await axios.post("feeds/read", {
headers: { url: feed.url
'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")
}, },
{ {
headers: { 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) { } catch (error) {
console.error('Error sync', error) console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
} }
} }
onMounted(() => { async function markRead(id) {
fetchData(); 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)
}
}
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> </script>
<template> <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> <div>
<h1>Feeds</h1> <button @click="sync">{{ buttonText }}</button> <h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
<div id='aricle'> <div v-if="showMessage" class="message">{{ message }}</div>
<template v-for="feed in feeds"> <div id='article' class='article'>
<h2>{{ feed.title }}</h2> <p v-if="feeds.length == 0">No unread articles.</p>
<p v-html='feed.content'></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> </template>
</div> </div>
</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 { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -17,24 +25,37 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
'/login/rss': { '/login/rss': {
target: 'http://localhost:8001/api/v1/auth/login', target: `${process.env.VITE_API_BASE_URL}/api/v1/auth/login`,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path.replace(/^\/login\/rss/, ''), rewrite: (path) => path.replace(/^\/login\/rss/, ''),
}, },
'/feeds/get': { '/feeds/get': {
target: 'http://localhost:8001/api/v1/article/get', target: `${process.env.VITE_API_BASE_URL}/api/v1/article/get`,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path.replace(/^\/feeds\/get/, ''), rewrite: (path) => path.replace(/^\/feeds\/get/, ''),
}, },
'/feeds/sync': { '/feeds/sync': {
target: 'http://localhost:8001/api/v1/article/sync', target: `${process.env.VITE_API_BASE_URL}/api/v1/article/sync`,
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
rewrite: (path) => path.replace(/^\/feeds\/sync/, ''), 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 cors: false
}, },