diff --git a/Cargo.lock b/Cargo.lock index f20c130c..02ac15a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1776,6 +1776,7 @@ dependencies = [ "frankenstein", "fs_extra", "futures", + "futures-util", "jsonwebtoken", "libsqlite3-sys", "log", @@ -1784,6 +1785,7 @@ dependencies = [ "rand", "regex", "reqwest", + "rpassword", "rss", "serde", "serde_derive", @@ -2019,6 +2021,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "rpassword" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6678cf63ab3491898c0d021b493c94c9b221d91295294a2a5746eacbe5928322" +dependencies = [ + "libc", + "rtoolbox", + "winapi", +] + [[package]] name = "rss" version = "2.0.3" @@ -2031,6 +2044,16 @@ dependencies = [ "quick-xml", ] +[[package]] +name = "rtoolbox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "034e22c514f5c0cb8a10ff341b9b048b5ceb21591f31c8f44c43b960f9b3524a" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "rust-embed" version = "6.6.1" diff --git a/Cargo.toml b/Cargo.toml index 72d4464d..837777d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] awc = {version="3.1.1", features = ["rustls"]} +rpassword = "7.2.0" reqwest = {version="0.11.14", features = ["blocking", "json", "async-compression", "rustls"]} actix = "0.13.0" async-recursion = "1.0.2" @@ -19,6 +20,7 @@ actix-files = "0.6.2" actix-web = {version="4.3.0", features=["rustls"]} jsonwebtoken = {version="8.2.0"} log = "0.4.17" +futures-util = "0.3.28" opml = "1.1.5" rand = "0.8.5" env_logger = "0.10.0" diff --git a/LICENSE b/LICENSE index 4e382b2f..f67c4985 100644 --- a/LICENSE +++ b/LICENSE @@ -13,7 +13,7 @@ the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common + Other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or @@ -30,7 +30,7 @@ "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, - and conversions to other media types. + and conversions to Other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a @@ -39,7 +39,7 @@ "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications + editorial revisions, annotations, elaborations, or Other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, @@ -91,7 +91,7 @@ modifications, and in Source or Object form, provided that You meet the following conditions: - (a) You must give any other recipients of the Work or + (a) You must give any Other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices @@ -159,16 +159,16 @@ RESULT of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor + Other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this + or Other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, + of any Other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. diff --git a/README.md b/README.md index 55270c02..ec211f75 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Podfetch is a self-hosted podcast manager. It is a web app that lets you download podcasts and listen to them online. It is written in Rust and uses React for the frontend. +It also contains a GPodder integration so you can continue using your current podcast app. Every time a new commit is pushed to the main branch, a new docker image is built and pushed to docker hub. So it is best to use something like [watchtower](https://github.com/containrrr/watchtower) to automatically update the docker image. @@ -65,6 +66,10 @@ Several Auth methods are described here: [AUTH.md](docs/AUTH.md) Hosting options are described here: [HOSTING.md](docs/HOSTING.md) +# CLI usage + +The cli usage is described here: [CLI.md](docs/CLI.md) + # Environment Variables | Variable | Description | Default | @@ -114,6 +119,17 @@ To configure it you need to create an account on that website. After creating an After successful setup you should see on the settings page a green checkmark next to the Podindex config section. +# GPodder + +Podfetch also supports the GPodder api. You can use your current GPodder account to login to Podfetch and continue using your current podcast app. +To do that just go to the settings page and enter your GPodder username and password. + +To enable it you need to set the following environment variables: +| Variable | Description | Default | +|---------------------|---------------------------------------|---------| +| GPODDER_INTEGRATION_ENABLED | Activates the GPodder integration via your server url | false| + + # Roadmap - [x] Add podcasts via Itunes api diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 00000000..c4f6c918 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,23 @@ +# CLI usage + +The CLI can be used to update, remove, list registered users in PodFetch. You can get help anytime by typing --help/help + +# Usage + +# Get general help + +```bash +podfetch --help +``` + +# Get help for a specific command + +```bash +podfetch --help +``` + +e.g. + +```bash +podfetch users --help +``` \ No newline at end of file diff --git a/migrations/2023-04-23-115251_gpodder_api/down.sql b/migrations/2023-04-23-115251_gpodder_api/down.sql new file mode 100644 index 00000000..47bce505 --- /dev/null +++ b/migrations/2023-04-23-115251_gpodder_api/down.sql @@ -0,0 +1,5 @@ +-- This file should undo anything in `up.sql` +DROP TABLE devices; +DROP TABLE sessions; +DROP TABLE subscriptions; +DROP TABLE episodes; \ No newline at end of file diff --git a/migrations/2023-04-23-115251_gpodder_api/up.sql b/migrations/2023-04-23-115251_gpodder_api/up.sql new file mode 100644 index 00000000..710480e3 --- /dev/null +++ b/migrations/2023-04-23-115251_gpodder_api/up.sql @@ -0,0 +1,56 @@ +-- Your SQL goes here +CREATE TABLE devices( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + deviceid VARCHAR(255) NOT NULL, + kind TEXT CHECK(kind IN ('desktop', 'laptop', 'server','mobile', 'Other')) NOT NULL, + name VARCHAR(255) NOT NULL, + username VARCHAR(255) NOT NULL, + FOREIGN KEY (username) REFERENCES users(username) +); + +CREATE TABLE sessions( + username VARCHAR(255) NOT NULL, + session_id VARCHAR(255) NOT NULL, + expires DATETIME NOT NULL, + PRIMARY KEY (username, session_id) +); + +CREATE TABLE subscriptions( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + username TEXT NOT NULL, + device TEXT NOT NULL, + podcast TEXT NOT NULL, + created Datetime NOT NULL, + deleted Datetime, + UNIQUE (username, device, podcast) +); + +CREATE TABLE episodes( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + username VARCHAR(255) NOT NULL, + device VARCHAR(255) NOT NULL, + podcast VARCHAR(255) NOT NULL, + episode VARCHAR(255) NOT NULL, + timestamp DATETIME NOT NULL, + guid VARCHAR(255), + action VARCHAR(255) NOT NULL, + started INTEGER, + position INTEGER, + total INTEGER, + UNIQUE (username, device, podcast, episode, timestamp) +); + + +CREATE table if not exists podcast_history_items2 ( + id integer primary key not null, + podcast_id integer not null, + episode_id TEXT not null, + watched_time integer not null, + date DATETIME not null, + username text not null, + FOREIGN KEY (podcast_id) REFERENCES podcasts(id)); + +INSERT INTO podcast_history_items2 SELECT * FROM podcast_history_items; + +DROP TABLE podcast_history_items; +ALTER TABLE podcast_history_items2 RENAME TO podcast_history_items; \ No newline at end of file diff --git a/src/command_line_runner.rs b/src/command_line_runner.rs new file mode 100644 index 00000000..6f7e0dd3 --- /dev/null +++ b/src/command_line_runner.rs @@ -0,0 +1,273 @@ +use std::env::Args; +use std::io::{Error, ErrorKind, stdin, stdout, Write}; +use std::process::exit; +use std::str::FromStr; +use log::error; +use sha256::{digest}; +use crate::config::dbconfig::establish_connection; +use crate::constants::constants::Role; +use crate::models::user::{User, UserWithoutPassword}; +use crate::utils::time::get_current_timestamp_str; +use rpassword::read_password; +use crate::models::device::Device; +use crate::models::episode::Episode; +use crate::models::favorites::Favorite; +use crate::models::models::PodcastHistoryItem; +use crate::models::session::Session; +use crate::models::subscription::Subscription; + + +pub fn start_command_line(mut args: Args){ + println!("Starting from command line"); + match args.nth(1).unwrap().as_str() { + + "help"|"--help"=>{ + println!(r" The following commands are available: + users => Handles user management + ") + } + "users"=>{ + println!("User management"); + match args.nth(0).unwrap().as_str() { + "add"=> { + let mut user = read_user_account(); + + + println!("Should a user with the following settings be applied {:?}",user); + + match ask_for_confirmation(){ + Ok(..)=>{ + user.password = Some(digest(user.password.unwrap())); + match User::insert_user(&mut user, &mut establish_connection()){ + Ok(..)=>{ + println!("User succesfully created") + }, + Err(..)=>{ + + } + } + }, + Err(..)=> { + } + } + } + "remove"=> { + let mut username = String::new(); + // remove user + let available_users = list_users(); + retry_read("Please enter the username of the user you want to delete", + &mut username); + username = trim_string(username); + println!("{}", username); + match available_users.iter().find(|u|u.username==username){ + Some(..)=>{ + PodcastHistoryItem::delete_by_username(trim_string(username.clone()), + &mut establish_connection()) + .expect("Error deleting entries for podcast history item"); + Device::delete_by_username(username.clone(), &mut + establish_connection()) + .expect("Error deleting devices"); + Episode::delete_by_username_and_episode(trim_string(username.clone()), + &mut establish_connection()) + .expect("Error deleting episodes"); + Favorite::delete_by_username(trim_string(username.clone()), + &mut establish_connection()) + .expect("Error deleting favorites"); + Session::delete_by_username(&trim_string(username.clone()), + &mut establish_connection()) + .expect("Error deleting sessions"); + Subscription::delete_by_username(&trim_string(username.clone()), + &mut establish_connection()).expect("TODO: panic message"); + User::delete_by_username(trim_string(username.clone()), + &mut establish_connection()) + .expect("Error deleting user"); + println!("User deleted") + }, + None=>{ + println!("Username not found") + } + } + } + "update"=>{ + //update a user + list_users(); + let mut username = String::new(); + + retry_read("Please enter the username of the user you want to delete", + &mut username); + username = trim_string(username); + println!(">{}<", username); + match User::find_by_username(username.as_str(), &mut establish_connection()){ + Some(user)=>{ + do_user_update(user) + }, + None=>{ + println!("Username not found") + } + } + + } + "list"=> { + // list users + + list_users(); + } + "help"|"--help"=>{ + println!(r" The following commands are available: + add => Adds a user + remove => Removes a user + update => Updates a user + list => Lists all users + ") + } + _ => { + error!("Command not found") + } + } + } + _ => { + error!("Command not found") + } + } +} + +fn list_users() -> Vec { + let users = User::find_all_users(&mut establish_connection()); + + users.iter().for_each(|u| { + println!("|Username|Role|Explicit Consent|Created at|", ); + println!("|{}|{}|{}|{}|", u.username, u.role, u.explicit_consent, u.created_at); + }); + users +} + + +pub fn read_user_account()->User{ + let mut username = String::new(); + let password; + + let role = Role::VALUES.map(|v|{ + return v.to_string() + }).join(", "); + retry_read("Enter your username: ", &mut username); + + let user_exists = User::find_by_username(&username, &mut establish_connection()).is_some(); + if user_exists{ + println!("User already exists"); + exit(1); + } + password = retry_read_secret("Enter your password: "); + let assigned_role = retry_read_role(&format!("Select your role {}",&role)); + + let user = User{ + id: 0, + username: trim_string(username.clone()), + role: assigned_role.to_string(), + password: Some(trim_string(password)), + explicit_consent: false, + created_at: get_current_timestamp_str(), + }; + + user +} + +pub fn retry_read(prompt: &str, input: &mut String){ + println!("{}",prompt); + stdin().read_line(input).unwrap(); + match input.len()>0{ + true => { + if input.trim().len()==0{ + retry_read(prompt, input); + } + } + false => { + retry_read(prompt, input); + } + } +} + + +pub fn retry_read_secret(prompt: &str)->String{ + println!("{}",prompt); + stdout().flush().unwrap(); + let input = read_password().unwrap(); + match input.len()>0{ + true => { + if input.trim().len()==0{ + retry_read_secret(prompt); + } + } + false => { + retry_read_secret(prompt); + } + } + input +} + +pub fn retry_read_role(prompt: &str)->Role{ + let mut input = String::new(); + println!("{}",prompt); + stdin().read_line(&mut input).unwrap(); + let res = Role::from_str(&trim_string(input)); + match res{ + Err(..)=> { + println!("Error setting role. Please choose one of the possible roles."); + return retry_read_role(prompt); + } + Ok(..)=>{ + res.unwrap() + } + } +} + +fn ask_for_confirmation()->Result<(),Error>{ + let mut input = String::new(); + println!("Y[es]/N[o]"); + stdin().read_line(&mut input).expect("Error reading from terminal"); + match input.to_lowercase().starts_with("y") { + true=>Ok(()), + false=>Err(Error::new(ErrorKind::WouldBlock, "Interrupted by user.")) + } +} + + +fn trim_string(string_to_trim: String)->String{ + string_to_trim.trim_end_matches("\n").trim().parse().unwrap() +} + + +fn do_user_update(mut user:User){ + let mut input = String::new(); + println!("The following settings of a user should be updated: {:?}",user); + println!("Enter which field of a user should be updated [role, password, \ + explicit_consent]"); + stdin().read_line(&mut input) + .expect("Error reading from terminal"); + input = trim_string(input); + match input.as_str() { + "role" =>{ + user.role = Role::to_string(&retry_read_role("Enter the new role [user,admin]")); + User::update_user(user, &mut establish_connection()) + .expect("Error updating role"); + println!("Role updated"); + }, + "password"=>{ + let mut password = retry_read_secret("Enter the new username"); + password = digest(password); + user.password = Some(password); + User::update_user(user, &mut establish_connection()) + .expect("Error updating username"); + println!("Password updated"); + }, + "explicit_consent"=>{ + user.explicit_consent = !user.explicit_consent; + User::update_user(user, &mut establish_connection()) + .expect("Error updating explicit_consent"); + println!("Explicit consent updated"); + } + _=>{ + println!("Field not found"); + } + } + +} \ No newline at end of file diff --git a/src/constants/constants.rs b/src/constants/constants.rs index 7e984f18..73983833 100644 --- a/src/constants/constants.rs +++ b/src/constants/constants.rs @@ -64,6 +64,9 @@ impl FromStr for Role{ } } } +impl Role{ + pub const VALUES: [Self; 3] = [Self::User, Self::Admin, Self::Uploader]; +} // environment keys pub const OIDC_AUTH:&str = "OIDC_AUTH"; diff --git a/src/controllers/watch_time_controller.rs b/src/controllers/watch_time_controller.rs index c4357e7b..c33df7b6 100644 --- a/src/controllers/watch_time_controller.rs +++ b/src/controllers/watch_time_controller.rs @@ -1,10 +1,11 @@ use crate::db::DB; -use crate::models::models::PodcastWatchedPostModel; +use crate::models::models::{PodcastWatchedEpisodeModelWithPodcastEpisode, PodcastWatchedPostModel}; use actix_web::web::Data; use actix_web::{get, post, web, HttpResponse, Responder, HttpRequest}; use std::sync::{Mutex}; use crate::constants::constants::STANDARD_USER; use crate::DbPool; +use crate::models::episode::Episode; use crate::models::user::User; use crate::mutex::LockResultExt; @@ -51,8 +52,34 @@ Responder { let designated_username = res.unwrap(); let mut db = db.lock().ignore_poison(); - let last_watched = db.get_last_watched_podcasts(&mut conn.get().unwrap(), designated_username).unwrap(); - HttpResponse::Ok().json(last_watched) + let last_watched = db.get_last_watched_podcasts(&mut conn.get().unwrap(), designated_username + .clone()).unwrap(); + + let episodes = Episode::get_last_watched_episodes(designated_username, &mut conn.get().unwrap + (), + ); + + let mut episodes_with_logs = last_watched.iter().map(|e|{ + let episode = episodes.iter().find(|e1| e1.episode_id == e.episode_id); + match episode { + Some(episode) => { + if episode.watched_time>e.watched_time{ + return episode + } + e + }, + None => { + e + } + } + }).collect::>(); + + episodes.iter().for_each(|x|{ + if episodes_with_logs.iter().find(|e| e.episode_id == x.episode_id).is_none(){ + episodes_with_logs.push(x); + } + }); + HttpResponse::Ok().json(episodes_with_logs) } #[utoipa::path( diff --git a/src/db.rs b/src/db.rs index 337dda3e..31a098b9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -8,7 +8,7 @@ use crate::models::models::{ use crate::models::settings::Setting; use crate::service::mapping_service::MappingService; use crate::utils::podcast_builder::PodcastExtra; -use chrono::{DateTime, Duration, Utc}; +use chrono::{DateTime, Duration, NaiveDateTime, Utc}; use diesel::dsl::sql; use diesel::prelude::*; use diesel::{insert_into, sql_query, RunQueryDsl, delete}; @@ -16,7 +16,8 @@ use rss::Item; use std::io::Error; use std::sync::MutexGuard; use std::time::SystemTime; -use diesel::sql_types::Text; +use diesel::sql_types::{Text, Timestamp}; +use crate::models::episode::{Episode, EpisodeAction}; use crate::models::favorites::Favorite; use crate::utils::do_retry::do_retry; @@ -136,6 +137,21 @@ impl DB { Ok(found_podcast_episode) } + pub fn query_podcast_episode_by_url( + conn: &mut SqliteConnection, + podcas_episode_url_to_be_found: &str, + ) -> Result, String> { + use crate::schema::podcast_episodes::dsl::*; + + let found_podcast_episode = podcast_episodes + .filter(url.like("%".to_owned()+podcas_episode_url_to_be_found+"%")) + .first::(conn) + .optional() + .expect("Error loading podcast by id"); + + Ok(found_podcast_episode) + } + pub fn get_podcast_by_track_id(conn: &mut SqliteConnection, podcast_id: i32) -> Result, String> { use crate::schema::podcasts::directory_id; use crate::schema::podcasts::dsl::podcasts; @@ -238,8 +254,8 @@ impl DB { podcast_id_to_be_searched: i32, last_id: Option, ) -> Result, String> { - use crate::schema::podcast_episodes::dsl::podcast_episodes; use crate::schema::podcast_episodes::*; + use crate::schema::podcast_episodes::dsl::podcast_episodes; match last_id { Some(last_id) => { let podcasts_found = podcast_episodes @@ -316,20 +332,38 @@ impl DB { match result { Some(found_podcast) => { let history_item = podcast_history_items - .filter(episode_id.eq(podcast_id_tos_search).and(username.eq(username_to_find))) + .filter(episode_id.eq(podcast_id_tos_search).and(username.eq(username_to_find + .clone()))) .order(date.desc()) .first::(conn) .optional() .expect("Error loading podcast episode by id"); + return match history_item { - Some(found_history_item) => Ok(found_history_item), + Some(found_history_item) => { + let option_episode = Episode::get_watch_log_by_username_and_episode + (username_to_find.clone(), conn, found_podcast.clone().url); + if option_episode.is_some(){ + let episode = option_episode.unwrap(); + if episode.action == EpisodeAction::Play.to_string() && episode + .position.unwrap()>found_history_item.watched_time && episode.timestamp>found_history_item.date{ + + let found_podcast_item = Self::get_podcast(conn, found_history_item + .podcast_id).unwrap(); + return Ok(Episode::convert_to_podcast_history_item(&episode, + found_podcast_item, + found_podcast)); + } + } + Ok(found_history_item) + }, None => Ok(PodcastHistoryItem { id: 0, podcast_id: found_podcast.podcast_id, episode_id: found_podcast.episode_id, watched_time: 0, username: STANDARD_USER.to_string(), - date: "".to_string(), + date: Utc::now().naive_utc() }), }; } @@ -344,10 +378,8 @@ impl DB { conn: &mut SqliteConnection, designated_username: String) -> Result, String> { let result = sql_query( - "SELECT * FROM (SELECT * FROM podcast_history_items WHERE username=? ORDER BY \ - datetime\ - (date) \ - DESC) GROUP BY episode_id LIMIT 10;", + "SELECT * FROM (SELECT * FROM podcast_history_items WHERE username=? ORDER BY date \ + DESC) GROUP BY episode_id LIMIT 10;", ) .bind::(designated_username) .load::(&mut self.conn) @@ -758,4 +790,30 @@ impl DB { res.load::<(PodcastEpisode, Podcast)>(conn).expect("Error loading podcast episode by id") } + + pub fn get_watch_logs_by_username(username_to_search: String, conn: &mut SqliteConnection, + since: NaiveDateTime) + -> + Vec<(PodcastHistoryItem, PodcastEpisode, Podcast)> { + + + let res = sql_query("SELECT * FROM podcast_history_items,podcast_episodes, podcasts WHERE \ + podcast_history_items.episode_id = podcast_episodes.episode_id AND podcast_history_items\ + .podcast_id= podcasts.id AND username=? AND date >= ?") + .bind::(&username_to_search) + .bind::(&since) + .load::<(PodcastHistoryItem, PodcastEpisode, Podcast)>(conn) + .expect("Error loading watch logs"); + + res + } + + pub fn get_podcast_by_rss_feed(rss_feed_1:String, conn: &mut SqliteConnection) -> Podcast { + use crate::schema::podcasts::dsl::*; + + podcasts + .filter(rssfeed.eq(rss_feed_1)) + .first::(conn) + .expect("Error loading podcast by rss feed") + } } diff --git a/src/gpodder/auth/auth.rs b/src/gpodder/auth/auth.rs new file mode 100644 index 00000000..2303e2c7 --- /dev/null +++ b/src/gpodder/auth/auth.rs @@ -0,0 +1,72 @@ +use std::sync::{Mutex}; +use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use actix_web::web::Data; +use sha256::digest; +use crate::{DbPool, extract_basic_auth}; +use crate::models::user::User; +use actix_web::{post}; +use crate::mutex::LockResultExt; +use crate::service::environment_service::EnvironmentService; +use awc::cookie::{Cookie, SameSite}; +use crate::models::session::Session; + +#[post("/auth/{username}/login.json")] +pub async fn login(username:web::Path, rq: HttpRequest, conn:Data, + env_service: Data>) + ->impl +Responder { + let env = env_service.lock().ignore_poison(); + + match rq.clone().cookie("sessionid") { + Some(cookie) => { + let session = cookie.value(); + let opt_session = Session::find_by_session_id(session, &mut conn.get().unwrap()); + if opt_session.is_ok(){ + let user_cookie = create_session_cookie(opt_session.unwrap()); + return HttpResponse::Ok().cookie(user_cookie).finish(); + } + } + None=>{} + } + + let authorization = rq.headers().get("Authorization").unwrap().to_str().unwrap(); + let unwrapped_username = username.into_inner(); + let (username_basic, password) = basic_auth_login(authorization.to_string()); + if username_basic != unwrapped_username { + return HttpResponse::Unauthorized().finish(); + } + if unwrapped_username == env.username && password == env.password { + return HttpResponse::Ok().finish(); + } else { + match User::find_by_username(&unwrapped_username, &mut conn.get().unwrap()) { + Some(user) => { + if user.clone().password.unwrap()== digest(password) { + let session = Session::new(user.username); + Session::insert_session(&session, &mut conn.get().unwrap()).expect("Error inserting session"); + let user_cookie = create_session_cookie(session); + HttpResponse::Ok().cookie(user_cookie).finish() + } else { + HttpResponse::Unauthorized().finish() + } + } + None => { + return HttpResponse::Unauthorized().finish() + } + } + } +} + +fn create_session_cookie(session: Session) -> Cookie<'static> { + let user_cookie = Cookie::build("sessionid", session.session_id) + .http_only(true) + .secure(false) + .same_site + (SameSite::Strict).path("/api").finish(); + user_cookie +} + +pub fn basic_auth_login(rq: String) -> (String, String) { + let (u,p) = extract_basic_auth(rq.as_str()); + + return (u.to_string(),p.to_string()) +} \ No newline at end of file diff --git a/src/gpodder/auth/mod.rs b/src/gpodder/auth/mod.rs new file mode 100644 index 00000000..5696e21f --- /dev/null +++ b/src/gpodder/auth/mod.rs @@ -0,0 +1 @@ +pub mod auth; \ No newline at end of file diff --git a/src/gpodder/device/device_controller.rs b/src/gpodder/device/device_controller.rs new file mode 100644 index 00000000..7b424171 --- /dev/null +++ b/src/gpodder/device/device_controller.rs @@ -0,0 +1,53 @@ +use actix_web::{HttpResponse, Responder, web}; +use crate::gpodder::device::dto::device_post::DevicePost; +use crate::models::device::{Device, DeviceResponse}; +use actix_web::{post, get}; +use actix_web::web::Data; +use crate::DbPool; +use crate::models::session::Session; + + +#[post("/devices/{username}/{deviceid}.json")] +pub async fn post_device( + query: web::Path<(String, String)>, + device_post: web::Json, + opt_flag: Option>, + conn: Data) -> impl Responder { + + match opt_flag{ + Some(flag)=>{ + let username = query.clone().0; + let deviceid = query.clone().1; + if flag.username!= username{ + return HttpResponse::Unauthorized().finish(); + } + + let device = Device::new(device_post.into_inner(), deviceid, username); + + let result = device.save(&mut conn.get().unwrap()).unwrap(); + + HttpResponse::Ok().json(result) + } + None=>{ + HttpResponse::Unauthorized().finish() + } + } +} + +#[get("/devices/{username}.json")] +pub async fn get_devices_of_user(query: web::Path,opt_flag: Option>, conn: Data) -> impl Responder { + match opt_flag { + Some(flag) => { + if flag.username!= query.clone(){ + return HttpResponse::Unauthorized().finish(); + } + let devices = Device::get_devices_of_user(&mut conn.get().unwrap(), query.clone()).unwrap(); + + let dtos = devices.iter().map(|d| d.to_dto()).collect::>(); + HttpResponse::Ok().json(dtos) + } + None => { + HttpResponse::Unauthorized().finish() + } + } +} \ No newline at end of file diff --git a/src/gpodder/device/dto/device_post.rs b/src/gpodder/device/dto/device_post.rs new file mode 100644 index 00000000..a8f863fd --- /dev/null +++ b/src/gpodder/device/dto/device_post.rs @@ -0,0 +1,6 @@ +#[derive(Serialize, Deserialize, Clone)] +pub struct DevicePost{ + pub caption: String, + #[serde(rename = "type")] + pub kind: String +} \ No newline at end of file diff --git a/src/gpodder/device/dto/mod.rs b/src/gpodder/device/dto/mod.rs new file mode 100644 index 00000000..5e6d2b5d --- /dev/null +++ b/src/gpodder/device/dto/mod.rs @@ -0,0 +1 @@ +pub mod device_post; \ No newline at end of file diff --git a/src/gpodder/device/mod.rs b/src/gpodder/device/mod.rs new file mode 100644 index 00000000..a06a51c2 --- /dev/null +++ b/src/gpodder/device/mod.rs @@ -0,0 +1,2 @@ +pub mod device_controller; +pub mod dto; \ No newline at end of file diff --git a/src/gpodder/episodes/episodes.rs b/src/gpodder/episodes/episodes.rs new file mode 100644 index 00000000..ec108e7c --- /dev/null +++ b/src/gpodder/episodes/episodes.rs @@ -0,0 +1,116 @@ +use actix_web::{HttpResponse, Responder, web}; + +use actix_web::{get,post}; +use actix_web::web::Data; +use crate::db::DB; +use crate::DbPool; +use crate::models::episode::{Episode, EpisodeAction, EpisodeDto}; +use std::borrow::Borrow; +use chrono::NaiveDateTime; +use crate::models::session::Session; +use crate::utils::time::{get_current_timestamp}; + +#[derive(Serialize, Deserialize)] +pub struct EpisodeActionResponse{ + actions: Vec, + timestamp: i64 +} + + +#[derive(Serialize, Deserialize)] +pub struct EpisodeActionPostResponse{ + update_urls: Vec, + timestamp: i64 +} + +#[derive(Serialize, Deserialize)] +pub struct EpisodeSinceRequest{ + since: i64 +} + +#[get("/episodes/{username}.json")] +pub async fn get_episode_actions(username: web::Path, pool: Data, + opt_flag: Option>, + since: web::Query) -> impl Responder { + match opt_flag { + Some(flag) => { + let username = username.clone(); + if flag.username != username.clone() { + return HttpResponse::Unauthorized().finish(); + } + + let since_date = NaiveDateTime::from_timestamp_opt(since.since as i64, 0); + println!("{}",since_date.unwrap()); + let mut actions = Episode::get_actions_by_username(username.clone(), &mut *pool.get().unwrap(), since_date) + .await; + let watch_logs = DB::get_watch_logs_by_username(username.clone(), &mut *pool.get() + .unwrap(), since_date.unwrap()).iter().map(|watch_log| { + Episode{ + id: 0, + username: watch_log.clone().0.username, + device: "".to_string(), + podcast: watch_log.clone().2.rssfeed, + episode: watch_log.clone().1.url, + timestamp: watch_log.clone().0.date, + guid: None, + action: EpisodeAction::Play.to_string(), + started: Option::from(watch_log.clone().0.watched_time), + position: Option::from(watch_log.clone().0.watched_time), + total: Option::from(watch_log.clone().1.total_time), + } + }).collect::>(); + + actions.append(&mut watch_logs.clone().to_vec()); + HttpResponse::Ok().json(EpisodeActionResponse { + actions, + timestamp: get_current_timestamp() + }) + } + None => { + HttpResponse::Unauthorized().finish() + } + } +} + + +#[post("/episodes/{username}.json")] +pub async fn upload_episode_actions(username: web::Path, podcast_episode: web::Json>,opt_flag: Option>, conn: Data) -> impl +Responder { + match opt_flag { + Some(flag) => { + if flag.username != username.clone() { + return HttpResponse::Unauthorized().finish(); + } + let mut inserted_episodes: Vec = vec![]; + podcast_episode.iter().for_each(|episode| { + let episode = Episode::convert_to_episode(episode, username.clone()); + inserted_episodes.push(Episode::insert_episode(episode.borrow(), &mut *conn.get().unwrap()) + .expect("Unable to insert episode")); + + if EpisodeAction::from_string(&episode.clone().action) == EpisodeAction::Play { + let mut episode_url = episode.clone().episode; + // Sometimes podcast provider like to check which browser access their podcast + let mut first_split = episode.episode.split("?"); + let res = first_split.next(); + + if res.is_some() { + episode_url = res.unwrap().parse().unwrap() + } + + let podcast_episode = DB::query_podcast_episode_by_url(&mut *conn.get().unwrap(), + &*episode_url); + if podcast_episode.clone().unwrap().is_none() { + return; + } + } + }); + HttpResponse::Ok().json(EpisodeActionPostResponse { + update_urls: vec![], + timestamp: get_current_timestamp() + }) + } + None => { + HttpResponse::Unauthorized().finish() + } + } +} \ No newline at end of file diff --git a/src/gpodder/episodes/mod.rs b/src/gpodder/episodes/mod.rs new file mode 100644 index 00000000..e8ea3a47 --- /dev/null +++ b/src/gpodder/episodes/mod.rs @@ -0,0 +1 @@ +pub mod episodes; diff --git a/src/gpodder/mod.rs b/src/gpodder/mod.rs new file mode 100644 index 00000000..1f5e8557 --- /dev/null +++ b/src/gpodder/mod.rs @@ -0,0 +1,7 @@ +pub mod routes; +pub mod device; +pub mod parametrization; +pub mod auth; +pub mod subscription; +mod episodes; +mod session_middleware; \ No newline at end of file diff --git a/src/gpodder/parametrization.rs b/src/gpodder/parametrization.rs new file mode 100644 index 00000000..3455b09e --- /dev/null +++ b/src/gpodder/parametrization.rs @@ -0,0 +1,37 @@ +use std::sync::Mutex; +use actix_web::{HttpResponse, Responder}; +use actix_web::web::Data; +use crate::mutex::LockResultExt; +use crate::service::environment_service::EnvironmentService; +use actix_web::get; + +#[derive(Serialize, Deserialize)] +pub struct ClientParametrization{ + mygpo: BaseURL, + #[serde(rename = "mygpo-feedservice")] + mygpo_feedservice: BaseURL, + update_timeout: i32, +} + +#[derive(Serialize, Deserialize)] +pub struct BaseURL{ + #[serde(rename = "baseurl")] + base_url: String +} + +#[get("/clientconfig.json")] +pub async fn get_client_parametrization(environment_service: Data>) + ->impl Responder { + let env_service = environment_service.lock().ignore_poison(); + let answer = ClientParametrization { + mygpo_feedservice: BaseURL { + base_url: env_service.clone().server_url + }, + mygpo: BaseURL { + base_url: env_service.clone().server_url + "rss" + }, + update_timeout: 604800 + }; + + HttpResponse::Ok().json(answer) +} \ No newline at end of file diff --git a/src/gpodder/routes.rs b/src/gpodder/routes.rs new file mode 100644 index 00000000..1fffbeef --- /dev/null +++ b/src/gpodder/routes.rs @@ -0,0 +1,37 @@ +use actix_web::{Error, Scope, web}; +use actix_web::body::{BoxBody, EitherBody}; +use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse}; +use crate::gpodder::device::device_controller::{get_devices_of_user, post_device}; +use crate::gpodder::auth::auth::login; +use crate::gpodder::episodes::episodes::{get_episode_actions, upload_episode_actions}; +use crate::gpodder::session_middleware::{CookieFilter}; +use crate::gpodder::subscription::subscriptions::{get_subscriptions, upload_subscription_changes}; +use crate::service::environment_service::EnvironmentService; + +pub fn get_gpodder_api(environment_service: EnvironmentService) ->Scope{ + + if environment_service.gpodder_integration_enabled { + web::scope("/api/2") + .service(login) + .service(get_authenticated_api()) + } else { + web::scope("/api/2") + } +} + + +pub fn get_authenticated_api() + ->Scope>, Error = Error, InitError = ()>>{ + web::scope("") + .wrap(CookieFilter::new()) + .service(post_device) + .service(get_devices_of_user) + .service(get_subscriptions) + .service(upload_subscription_changes) + .service(get_episode_actions) + .service(upload_episode_actions) +} + + diff --git a/src/gpodder/session_middleware.rs b/src/gpodder/session_middleware.rs new file mode 100644 index 00000000..6d481b09 --- /dev/null +++ b/src/gpodder/session_middleware.rs @@ -0,0 +1,88 @@ +use std::rc::Rc; +use actix::fut::{ok}; +use futures_util::FutureExt; +use actix_web::{dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, Error, HttpMessage}; +use actix_web::body::{EitherBody, MessageBody}; +use actix_web::error::{ErrorForbidden, ErrorUnauthorized}; +use diesel::{OptionalExtension, QueryDsl, RunQueryDsl}; +use futures_util::future::{LocalBoxFuture, Ready}; +use crate::models::session::Session; +use diesel::ExpressionMethods; +use crate::config::dbconfig::establish_connection; + +pub struct CookieFilter { +} + +impl CookieFilter { + pub fn new() -> Self { + CookieFilter { } + } +} + +pub struct CookieFilterMiddleware{ + service: Rc, +} + +impl Transform for CookieFilter + where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = CookieFilterMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ok(CookieFilterMiddleware { + service: Rc::new(service) + }) + } +} + +impl Service for CookieFilterMiddleware + where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: MessageBody + 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let cookie = req.cookie("sessionid"); + if cookie.is_none(){ + return Box::pin(ok(req.error_response(ErrorUnauthorized("Unauthorized")) + .map_into_right_body + ())); + } + let binding = cookie.unwrap(); + let extracted_cookie = binding.value(); + + use crate::schema::sessions::dsl::*; + let session = sessions.filter(session_id.eq(extracted_cookie)) + .first::(&mut establish_connection()) + .optional() + .expect("Error connecting to database"); + if session.is_none(){ + return Box::pin(ok(req.error_response(ErrorForbidden("Forbidden")) + .map_into_right_body())); + } + + let service = Rc::clone(&self.service); + + req.extensions_mut().insert(session.unwrap()); + async move { + service + .call(req) + .await + .map(|res| res.map_into_left_body()) + } + .boxed_local() + } +} \ No newline at end of file diff --git a/src/gpodder/subscription/mod.rs b/src/gpodder/subscription/mod.rs new file mode 100644 index 00000000..053d543c --- /dev/null +++ b/src/gpodder/subscription/mod.rs @@ -0,0 +1 @@ +pub mod subscriptions; \ No newline at end of file diff --git a/src/gpodder/subscription/subscriptions.rs b/src/gpodder/subscription/subscriptions.rs new file mode 100644 index 00000000..4f076561 --- /dev/null +++ b/src/gpodder/subscription/subscriptions.rs @@ -0,0 +1,80 @@ +use actix_web::{HttpResponse, Responder, web}; +use actix_web::{get, post}; +use actix_web::web::Data; +use crate::DbPool; +use crate::models::session::Session; +use crate::models::subscription::SubscriptionChangesToClient; +use crate::utils::time::get_current_timestamp; + +#[derive(Deserialize, Serialize)] +pub struct SubscriptionRetrieveRequest { + pub since: i32 +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct SubscriptionUpdateRequest { + pub add: Vec, + pub remove: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct SubscriptionPostResponse { + pub timestamp: i64, + pub update_urls: Vec> +} + +#[get("/subscriptions/{username}/{deviceid}.json")] +pub async fn get_subscriptions(paths: web::Path<(String, String)>,opt_flag: + Option>, + query:web::Query, conn: Data) -> impl +Responder { + match opt_flag { + Some(flag) => { + let username = paths.clone().0; + let deviceid = paths.clone().1; + if flag.username != username.clone() { + return HttpResponse::Unauthorized().finish(); + } + + let res = SubscriptionChangesToClient::get_device_subscriptions(&deviceid, &username, query + .since, + &mut *conn.get().unwrap()).await; + + match res { + Ok(res) => { + HttpResponse::Ok().json(res) + }, + Err(_) => HttpResponse::InternalServerError().finish() + } + } + None => { + HttpResponse::Unauthorized().finish() + } + } +} + +#[post("/subscriptions/{username}/{deviceid}.json")] +pub async fn upload_subscription_changes(upload_request: web::Json, + opt_flag: Option>, + paths: web::Path<(String, String)>, conn: Data)->impl Responder { + match opt_flag { + Some(flag) => { + let username = paths.clone().0; + let deviceid = paths.clone().1; + if flag.username != username.clone() { + return HttpResponse::Unauthorized().finish(); + } + SubscriptionChangesToClient::update_subscriptions(&deviceid, &username, + upload_request, + &mut *conn.get().unwrap()).await.expect("TODO: panic message"); + + HttpResponse::Ok().json(SubscriptionPostResponse { + update_urls: vec![], + timestamp: get_current_timestamp() + }) + } + None => { + HttpResponse::Unauthorized().finish() + } + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 80fdbc1e..1e451240 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,13 +15,13 @@ use actix_web::error::ErrorUnauthorized; use actix_web::middleware::{Condition, Logger}; use actix_web::web::{redirect, Data}; use actix_web::{web, App, Error, HttpResponse, HttpServer, Responder, Scope}; -use actix_web_httpauth::extractors::basic::BasicAuth; use clokwerk::{Scheduler, TimeUnits}; use std::sync::{Mutex}; use std::time::Duration; use std::{env, thread}; -use std::env::var; +use std::env::{args, var}; use std::io::Read; +use std::process::exit; use std::str::FromStr; use actix_web_httpauth::extractors::bearer::BearerAuth; use jsonwebtoken::{Algorithm, decode, DecodingKey, Validation}; @@ -59,6 +59,7 @@ use crate::controllers::websocket_controller::{ get_rss_feed, get_rss_feed_for_podcast, start_connection, }; pub use controllers::controller_utils::*; +use crate::command_line_runner::start_command_line; use crate::controllers::user_controller::{create_invite, delete_invite, delete_user, get_invite, get_invite_link, get_invites, get_users, onboard_user, update_role}; mod constants; @@ -66,7 +67,10 @@ mod db; mod models; mod service; use crate::db::DB; +use crate::gpodder::parametrization::get_client_parametrization; +use crate::gpodder::routes::get_gpodder_api; use crate::models::oidc_model::{CustomJwk, CustomJwkSet}; +use crate::models::session::Session; use crate::models::user::User; use crate::models::web_socket_message::Lobby; use crate::service::environment_service::EnvironmentService; @@ -84,38 +88,33 @@ mod config; pub mod utils; pub mod mutex; mod exception; +mod gpodder; +mod command_line_runner; type DbPool = Pool>; async fn validator( - mut req: ServiceRequest, - _credentials: BasicAuth, db: DbPool + mut req: ServiceRequest, db: DbPool ) -> Result { - let authorization = req.headers().get("Authorization").unwrap().to_str(); + let headers = req.headers().clone(); + let authorization = headers.get("Authorization").unwrap().to_str(); match authorization { Ok(auth) => { - let auth = auth.to_string(); - let auth = auth.split(" ").collect::>(); - let auth = auth[1]; - let auth = general_purpose::STANDARD.decode(auth).unwrap(); - let auth = String::from_utf8(auth).unwrap(); - let auth = auth.split(":").collect::>(); - let username = auth[0]; - let password = auth[1]; + let (username, password) = extract_basic_auth(auth); let env = EnvironmentService::new(); // Check if user is admin if username == env.username && password == env.password { req.headers_mut().append(HeaderName::from_str(USERNAME).unwrap(), - HeaderValue::from_str(username).unwrap()); + HeaderValue::from_str(username.as_str()).unwrap()); return Ok(req); } else { - match User::find_by_username(username, &mut db.get().unwrap()) { + match User::find_by_username(username.as_str(), &mut db.get().unwrap()) { Some(user) => { if user.password.unwrap()== digest(password) { req.headers_mut().append(HeaderName::from_str(USERNAME).unwrap(), - HeaderValue::from_str(username).unwrap()); + HeaderValue::from_str(username.as_str()).unwrap()); Ok(req) } else { Err((ErrorUnauthorized(ERROR_LOGIN_MESSAGE), req)) @@ -133,6 +132,19 @@ async fn validator( } } +pub fn extract_basic_auth(auth: &str) -> (String, String) { + let auth = auth.to_string(); + let auth = auth.split(" ").collect::>(); + let auth = auth[1]; + let auth = general_purpose::STANDARD.decode(auth).unwrap(); + let auth = String::from_utf8(auth).unwrap(); + let auth = auth.split(":").collect::>(); + let username = auth[0]; + let password = auth[1]; + (username.to_string(), password.to_string()) +} + + async fn validate_oidc_token(mut rq: ServiceRequest, bearer: BearerAuth, mut jwk_service: JWKService, pool: Pool>) ->Result { @@ -250,6 +262,11 @@ pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); #[actix_web::main] async fn main() -> std::io::Result<()> { + + if args().len()>1 { + start_command_line(args()); + exit(0) + } //services let podcast_episode_service = PodcastEpisodeService::new(); let podcast_service = PodcastService::new(); @@ -259,8 +276,6 @@ async fn main() -> std::io::Result<()> { let environment_service = EnvironmentService::new(); let notification_service = NotificationService::new(); let settings_service = SettingsService::new(); - - let lobby = Lobby::default(); let pool = init_db_pool(&get_database_url()).await.expect("Failed to connect to database"); @@ -304,7 +319,10 @@ async fn main() -> std::io::Result<()> { } }); - scheduler.every(1.day()).run(|| { + scheduler.every(1.day()).run(move || { + // Clears the session ids once per day + Session::cleanup_sessions(&mut establish_connection()).expect("Error clearing old \ + sessions"); let mut db = DB::new().unwrap(); let mut podcast_episode_service = PodcastEpisodeService::new(); let settings = db.get_settings(); @@ -331,6 +349,7 @@ async fn main() -> std::io::Result<()> { App::new() .service(redirect("/", var("SUB_DIRECTORY").unwrap()+"/ui/")) + .service(get_gpodder_api(environment_service.clone())) .service(get_global_scope(pool.clone())) .app_data(Data::new(chat_server.clone())) .app_data(Data::new(Mutex::new(podcast_episode_service.clone()))) @@ -342,7 +361,7 @@ async fn main() -> std::io::Result<()> { .app_data(Data::new(Mutex::new(notification_service.clone()))) .app_data(Data::new(Mutex::new(settings_service.clone()))) .app_data(Data::new(pool.clone())) - .wrap(Condition::new(false,Logger::default())) + .wrap(Condition::new(true,Logger::default())) }) .bind(("0.0.0.0", 8000))? .run() @@ -372,6 +391,7 @@ pub fn get_global_scope(pool1: Pool>) -> Sco web::scope(&base_path) + .service(get_client_parametrization) .service(proxy_podcast) .service(get_ui_config()) .service(Files::new("/podcasts", "podcasts")) @@ -387,8 +407,8 @@ pub fn get_global_scope(pool1: Pool>) -> Sco fn get_private_api(db: Pool>) -> Scope>>, EitherBody>>>, Error = Error, InitError = ()>> { let oidc_db = db.clone(); let enable_basic_auth = var(BASIC_AUTH).is_ok(); - let auth = HttpAuthentication::basic(move |rq,serv|{ - validator(rq, serv, db.clone()) + let auth = HttpAuthentication::basic(move |rq,_|{ + validator(rq, db.clone()) }); let enable_oidc_auth = var(OIDC_AUTH).is_ok(); let jwk_service = JWKService{ @@ -511,7 +531,14 @@ pub fn check_server_config(service1: EnvironmentService) { if service1.http_basic { if service1.password.is_empty() || service1.username.is_empty() { log::error!("BASIC_AUTH activated but no username or password set. Please set username and password in the .env file."); - std::process::exit(1); + exit(1); + } + } + + if service1.gpodder_integration_enabled{ + if !(service1.http_basic || service1.oidc_configured){ + log::error!("GPODDER_INTEGRATION_ENABLED activated but no BASIC_AUTH or OIDC_AUTH set. Please set BASIC_AUTH or OIDC_AUTH in the .env file."); + exit(1); } } @@ -522,7 +549,7 @@ pub fn check_server_config(service1: EnvironmentService) { if var(TELEGRAM_API_ENABLED).is_ok(){ if !var(TELEGRAM_BOT_TOKEN).is_ok() || !var(TELEGRAM_BOT_CHAT_ID).is_ok() { log::error!("TELEGRAM_API_ENABLED activated but no TELEGRAM_API_TOKEN or TELEGRAM_API_CHAT_ID set. Please set TELEGRAM_API_TOKEN and TELEGRAM_API_CHAT_ID in the .env file."); - std::process::exit(1); + exit(1); } } } diff --git a/src/models/device.rs b/src/models/device.rs new file mode 100644 index 00000000..a702a40d --- /dev/null +++ b/src/models/device.rs @@ -0,0 +1,67 @@ +use diesel::{Queryable, QueryableByName, RunQueryDsl, Insertable, SqliteConnection}; +use utoipa::ToSchema; +use crate::gpodder::device::dto::device_post::DevicePost; +use crate::schema::devices; +use diesel::QueryDsl; +use diesel::ExpressionMethods; + +#[derive(Serialize, Deserialize, Queryable,Insertable, QueryableByName, Clone, ToSchema)] +#[diesel(table_name=devices)] +pub struct Device { + #[diesel(deserialize_as = i32)] + pub id: Option, + pub deviceid: String, + pub kind: String, + pub name: String, + pub username: String +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct DeviceResponse{ + id: String, + caption: String, + #[serde(rename = "type")] + type_: String, + subscriptions: u32 +} + +impl Device { + + pub fn new(device_post: DevicePost, device_id: String, username: String) -> Device { + Device{ + id: None, + deviceid:device_id, + kind: device_post.kind, + name: device_post.caption, + username, + } + } + + pub fn save(&self, conn: &mut SqliteConnection) -> Result { + use crate::schema::devices::dsl::*; + diesel::insert_into(devices) + .values(self) + .get_result(conn) + } + + pub fn get_devices_of_user(conn: &mut SqliteConnection, username_to_insert: String) -> + Result, diesel::result::Error> { + use crate::schema::devices::dsl::*; + devices.filter(username.eq(username_to_insert)) + .load::(conn) + } + + pub fn to_dto(&self) -> DeviceResponse { + DeviceResponse{ + id: self.deviceid.clone(), + caption: self.name.clone(), + type_: self.kind.clone(), + subscriptions: 0 + } + } + pub fn delete_by_username(username1: String, conn: &mut SqliteConnection) -> Result { + use crate::schema::devices::dsl::*; + diesel::delete(devices.filter(username.eq(username1))).execute(conn) + } +} \ No newline at end of file diff --git a/src/models/device_subscription.rs b/src/models/device_subscription.rs new file mode 100644 index 00000000..e69de29b diff --git a/src/models/episode.rs b/src/models/episode.rs new file mode 100644 index 00000000..1801d858 --- /dev/null +++ b/src/models/episode.rs @@ -0,0 +1,253 @@ +use std::collections::HashMap; +use std::io::Error; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use diesel::{Queryable, QueryableByName, Insertable, SqliteConnection, RunQueryDsl, QueryDsl, BoolExpressionMethods, OptionalExtension, sql_query}; +use crate::schema::episodes; +use utoipa::ToSchema; +use diesel::sql_types::{Integer, Text, Nullable, Timestamp}; +use diesel::ExpressionMethods; +use crate::db::DB; +use crate::models::itunes_models::{Podcast, PodcastEpisode}; +use crate::models::models::{PodcastHistoryItem, PodcastWatchedEpisodeModelWithPodcastEpisode}; + +#[derive(Serialize, Deserialize, Debug,Queryable, QueryableByName,Insertable, Clone, ToSchema)] +pub struct Episode{ + #[diesel(sql_type = Integer)] + pub id: i32, + #[diesel(sql_type = Text)] + pub username: String, + #[diesel(sql_type = Text)] + pub device: String, + #[diesel(sql_type = Text)] + pub podcast: String, + #[diesel(sql_type = Text)] + pub episode: String, + #[diesel(sql_type = Timestamp)] + pub timestamp: NaiveDateTime, + #[diesel(sql_type = Nullable)] + pub guid: Option, + #[diesel(sql_type = Text)] + pub action: String, + #[diesel(sql_type = Nullable)] + pub started:Option, + #[diesel(sql_type = Nullable)] + pub position:Option, + #[diesel(sql_type = Nullable)] + pub total:Option, +} + + +impl Episode{ + pub fn insert_episode(&self, conn: &mut SqliteConnection) -> Result { + use crate::schema::episodes::dsl::*; + + let res = episodes.filter(timestamp.eq(self.clone().timestamp) + .and(device.eq(self.clone().device)) + .and(podcast.eq(self.clone().podcast)) + .and(episode.eq(self.clone().episode)) + .and(timestamp.eq(self.clone().timestamp))) + .first::(conn) + .optional() + .expect(""); + + if res.is_some() { + return Ok(res.unwrap()) + } + + diesel::insert_into(episodes) + .values(( + username.eq(&self.username), + device.eq(&self.device), + podcast.eq(&self.podcast), + episode.eq(&self.episode), + timestamp.eq(&self.timestamp), + guid.eq(&self.guid), + action.eq(&self.action), + started.eq(&self.started), + position.eq(&self.position), + total.eq(&self.total) + )) + .get_result(conn) + } + + pub fn convert_to_episode_dto(&self) -> EpisodeDto { + EpisodeDto { + podcast: self.podcast.clone(), + episode: self.episode.clone(), + timestamp: self.timestamp.clone(), + guid: self.guid.clone(), + action: EpisodeAction::from_string(&self.action), + started: self.started, + position: self.position, + total: self.total, + device: self.clone().device, + } + } + + pub fn convert_to_episode(episode_dto: &EpisodeDto, username: String)->Episode{ + Episode { + id: 0, + username, + device: episode_dto.device.clone(), + podcast: episode_dto.podcast.clone(), + episode: episode_dto.episode.clone(), + timestamp: episode_dto.timestamp.clone(), + guid: episode_dto.guid.clone(), + action: episode_dto.action.clone().to_string(), + started: episode_dto.started, + position: episode_dto.position, + total: episode_dto.total, + } + } + pub async fn get_actions_by_username(username1: String, conn: &mut SqliteConnection, since_date: Option) ->Vec{ + use crate::schema::episodes::username; + use crate::schema::episodes::dsl::episodes; + use crate::schema::episodes::dsl::timestamp; + match since_date { + Some(e)=>{ + episodes + .filter(username.eq(username1)) + .filter(timestamp.gt(e)) + .load::(conn) + .expect("") + }, + None=>{ + episodes + .filter(username.eq(username1)) + .load::(conn) + .expect("") + } + } + } + + pub fn get_watch_log_by_username_and_episode(username1: String, conn: &mut SqliteConnection, + episode_1: String) ->Option{ + + let res = sql_query( + "SELECT * FROM (SELECT * FROM episodes,podcasts WHERE username=? AND episodes\ + .podcast=podcasts.rssfeed AND episodes.episode = ? ORDER BY timestamp DESC) GROUP BY \ + episode LIMIT 10;") + .bind::(username1.clone()) + .bind::(episode_1) + .load::(conn) + .expect(""); + return if res.len() > 0 { + Some(res[0].clone()) + } else { + None + } + } + + pub fn convert_to_podcast_history_item(&self, podcast_1: Podcast,pod_episode: PodcastEpisode) + -> + PodcastHistoryItem { + PodcastHistoryItem { + id: self.id, + podcast_id: podcast_1.id, + episode_id: pod_episode.episode_id, + watched_time: self.position.unwrap(), + date: self.timestamp, + username: self.username.clone(), + } + } + + pub fn get_last_watched_episodes(username1: String, conn: &mut SqliteConnection) + ->Vec{ + + + let mut map:HashMap = HashMap::new(); + let res = sql_query( + r"SELECT * FROM (SELECT * FROM episodes e, podcast_episodes pe WHERE + e.username=? AND pe.url=e.episode ORDER BY timestamp DESC) GROUP BY episode LIMIT + 10;") + .bind::(username1.clone()) + .load::<(Episode,PodcastEpisode)>(conn) + .expect(""); + + res.iter().map(|e|{ + let opt_podcast = map.get(&*e.clone().0.podcast); + if opt_podcast.is_none(){ + let podcast = DB::get_podcast_by_rss_feed(e.clone().0.podcast, conn); + map.insert(e.clone().0.podcast.clone(),podcast.clone()); + } + let found_podcast = map.get(&e.clone().0.podcast).cloned().unwrap(); + PodcastWatchedEpisodeModelWithPodcastEpisode{ + id: e.clone().0.id, + podcast_id: found_podcast.id, + episode_id: e.clone().1.episode_id, + url: e.clone().1.url, + name: e.clone().1.name, + image_url: e.clone().1.image_url, + watched_time: e.clone().0.position.unwrap(), + date: e.clone().0.timestamp, + total_time: e.clone().1.total_time, + podcast_episode: e.clone().1, + podcast: found_podcast.clone(), + } + }).collect() + } + + pub fn delete_by_username_and_episode(username1: String, conn: &mut SqliteConnection) ->Result<(),Error>{ + use crate::schema::episodes::username; + use crate::schema::episodes::dsl::episodes; + diesel::delete(episodes.filter(username.eq(username1))) + .execute(conn).expect(""); + Ok(()) + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct EpisodeDto { + pub podcast: String, + pub episode: String, + pub timestamp: NaiveDateTime, + pub guid: Option, + pub action: EpisodeAction, + pub started:Option, + pub position:Option, + pub total:Option, + pub device: String, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +#[derive(PartialEq)] +#[derive(Clone)] +pub enum EpisodeAction { + New, + Download, + Play, + Delete, +} + + +impl EpisodeAction{ + pub fn from_string(s: &str) -> Self { + match s { + "new" => EpisodeAction::New, + "download" => EpisodeAction::Download, + "play" => EpisodeAction::Play, + "delete" => EpisodeAction::Delete, + _ => panic!("Unknown episode action: {}", s), + } + } + + pub fn to_string(&self) -> String { + match self { + EpisodeAction::New => "new".to_string(), + EpisodeAction::Download => "download".to_string(), + EpisodeAction::Play => "play".to_string(), + EpisodeAction::Delete => "delete".to_string(), + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum EpisodeActionRaw { + New, + Download, + Play, + Delete, +} \ No newline at end of file diff --git a/src/models/favorites.rs b/src/models/favorites.rs index c4780aad..5fdc3fc9 100644 --- a/src/models/favorites.rs +++ b/src/models/favorites.rs @@ -11,4 +11,13 @@ pub struct Favorite{ pub username: String, pub podcast_id: i32, pub favored: bool +} + +impl Favorite{ + pub fn delete_by_username(username1: String, conn: &mut SqliteConnection) -> Result<(), + diesel::result::Error>{ + use crate::schema::favorites::dsl::*; + diesel::delete(favorites.filter(username.eq(username1))).execute(conn)?; + Ok(()) + } } \ No newline at end of file diff --git a/src/models/itunes_models.rs b/src/models/itunes_models.rs index e044dd71..0eb2c527 100644 --- a/src/models/itunes_models.rs +++ b/src/models/itunes_models.rs @@ -1,8 +1,11 @@ use crate::schema::*; use chrono::NaiveDateTime; use diesel::prelude::{Queryable, Identifiable, Selectable, QueryableByName}; +use diesel::{RunQueryDsl, SqliteConnection}; use utoipa::ToSchema; use diesel::sql_types::{Integer, Text, Nullable, Bool, Timestamp}; +use diesel::QueryDsl; +use diesel::ExpressionMethods; #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -51,7 +54,7 @@ pub struct ResponseModel { } #[derive(Queryable, Identifiable,QueryableByName, Selectable, Debug, PartialEq, Clone, ToSchema, -Serialize, Deserialize)] +Serialize, Deserialize,)] pub struct Podcast { #[diesel(sql_type = Integer)] pub(crate) id: i32, @@ -84,6 +87,16 @@ pub struct Podcast { pub directory_name:String } +impl Podcast{ + pub fn get_by_rss_feed(rssfeed_i: &str, conn: &mut SqliteConnection) -> Result { + use crate::schema::podcasts::dsl::*; + podcasts + .filter(rssfeed.eq(rssfeed_i)) + .first::(conn) + } +} + #[derive(Serialize, Deserialize)] pub struct PodcastDto { pub(crate) id: i32, diff --git a/src/models/mod.rs b/src/models/mod.rs index 6bd389a4..13994b6b 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,3 +10,9 @@ pub mod oidc_model; pub mod user; pub mod invite; pub mod favorites; +pub mod device; +pub mod session; +pub mod subscription; +pub mod subscription_changes_from_client; +pub mod device_subscription; +pub mod episode; diff --git a/src/models/models.rs b/src/models/models.rs index c00aadf8..66f14336 100644 --- a/src/models/models.rs +++ b/src/models/models.rs @@ -2,6 +2,8 @@ use crate::models::itunes_models::{Podcast, PodcastEpisode}; use diesel::prelude::*; use diesel::sql_types::{Integer, Text}; use utoipa::ToSchema; +use chrono::NaiveDateTime; +use diesel::sql_types::Timestamp; // decode request data #[derive(Deserialize)] @@ -48,12 +50,22 @@ pub struct PodcastHistoryItem { pub episode_id: String, #[diesel(sql_type = Integer)] pub watched_time: i32, - #[diesel(sql_type = Text)] - pub date: String, + #[diesel(sql_type = Timestamp)] + pub date: NaiveDateTime, #[diesel(sql_type = Text)] pub username: String } +impl PodcastHistoryItem{ + pub fn delete_by_username(username1: String, conn: &mut SqliteConnection) -> Result<(), + diesel::result::Error>{ + use crate::schema::podcast_history_items::dsl::*; + diesel::delete(podcast_history_items.filter(username.eq(username1))) + .execute(conn)?; + Ok(()) + } +} + #[derive(Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PodcastWatchedEpisodeModel { @@ -68,7 +80,7 @@ pub struct PodcastWatchedEpisodeModel { pub total_time: i32, } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Clone)] #[serde(rename_all = "camelCase")] pub struct PodcastWatchedEpisodeModelWithPodcastEpisode { pub id: i32, @@ -78,7 +90,7 @@ pub struct PodcastWatchedEpisodeModelWithPodcastEpisode { pub name: String, pub image_url: String, pub watched_time: i32, - pub date: String, + pub date: NaiveDateTime, pub total_time: i32, pub podcast_episode: PodcastEpisode, pub podcast: Podcast, diff --git a/src/models/session.rs b/src/models/session.rs new file mode 100644 index 00000000..06d8abae --- /dev/null +++ b/src/models/session.rs @@ -0,0 +1,51 @@ +use chrono::{NaiveDateTime, Utc}; +use diesel::{Insertable, Queryable, RunQueryDsl}; +use utoipa::ToSchema; +use uuid::Uuid; +use crate::schema::sessions; +use diesel::QueryDsl; +use diesel::ExpressionMethods; + +#[derive(Queryable, Insertable, Clone, ToSchema, PartialEq, Debug)] +pub struct Session{ + pub username: String, + pub session_id: String, + pub expires: NaiveDateTime +} + + +impl Session{ + pub fn new(username: String) -> Self{ + Self{ + username, + session_id: Uuid::new_v4().to_string(), + expires: NaiveDateTime::from_timestamp_opt(chrono::Utc::now().timestamp() + 60 * 60 * + 24, 0).unwrap() + } + } + + pub fn insert_session(&self, conn: &mut diesel::SqliteConnection) -> Result{ + diesel::insert_into(sessions::table) + .values(self) + .get_result(conn) + } + + pub fn cleanup_sessions(conn: &mut diesel::SqliteConnection) -> Result{ + diesel::delete(sessions::table. + filter(sessions::expires.lt(Utc::now().naive_utc()))) + .execute(conn) + } + + pub fn find_by_session_id(session_id: &str, conn: &mut diesel::SqliteConnection) -> Result{ + sessions::table + .filter(sessions::session_id.eq(session_id)) + .get_result(conn) + } + + pub fn delete_by_username(username1: &str, conn: &mut diesel::SqliteConnection) -> + Result{ + diesel::delete(sessions::table + .filter(sessions::username.eq(username1))) + .execute(conn) + } +} \ No newline at end of file diff --git a/src/models/subscription.rs b/src/models/subscription.rs new file mode 100644 index 00000000..5327573e --- /dev/null +++ b/src/models/subscription.rs @@ -0,0 +1,157 @@ +use std::io::Error; +use actix_web::web; +use chrono::{NaiveDateTime, Utc}; +use diesel::{BoolExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection}; +use crate::gpodder::subscription::subscriptions::SubscriptionUpdateRequest; +use diesel::ExpressionMethods; + +use utoipa::ToSchema; +use serde::{Deserialize, Serialize}; +use diesel::{Insertable, Queryable, QueryableByName, AsChangeset}; +use diesel::sql_types::{Integer, Text, Nullable, Timestamp}; +use crate::schema::subscriptions; +use crate::utils::time::get_current_timestamp; +use diesel::OptionalExtension; + +#[derive(Debug, Serialize, Deserialize,QueryableByName, Queryable,AsChangeset,Insertable, Clone, ToSchema)] +#[diesel(treat_none_as_null = true)] +pub struct Subscription{ + #[diesel(sql_type = Integer)] + pub id: i32, + #[diesel(sql_type = Text)] + pub username: String, + #[diesel(sql_type = Text)] + pub device:String, + #[diesel(sql_type = Text)] + pub podcast: String, + #[diesel(sql_type = Timestamp)] + pub created: NaiveDateTime, + #[diesel(sql_type = Nullable)] + pub deleted: Option +} + +impl Subscription{ + pub fn new(username: String, device: String, podcast: String) -> Self{ + Self{ + id:0, + username, + device, + podcast, + created: Utc::now().naive_utc(), + deleted: None + } + } + pub fn delete_by_username(username1: &str, conn: &mut SqliteConnection) -> + Result<(), Error>{ + use crate::schema::subscriptions::dsl::*; + diesel::delete(subscriptions.filter(username.eq(username1))) + .execute(conn).expect("Error deleting subscriptions of user"); + Ok(()) + } +} + + +#[derive(Debug, Serialize)] +pub struct SubscriptionChangesToClient { + pub add: Vec, + pub remove: Vec, + pub timestamp: i64, +} + + +impl SubscriptionChangesToClient { + pub async fn get_device_subscriptions(device_id: &str, username: &str, since: i32, + conn: &mut SqliteConnection) -> Result{ + let since = NaiveDateTime::from_timestamp_opt(since as i64, 0).unwrap(); + let res:Vec = subscriptions::table + .filter(subscriptions::username.eq(username)) + .filter(subscriptions::device.eq(device_id) + .and(subscriptions::created.gt(since))) + .load::(conn) + .expect("Error retrieving changed subscriptions"); + + let (deleted_subscriptions,created_subscriptions):(Vec, + Vec ) = res + .into_iter() + .partition(|c| c.deleted.is_some()); + + Ok(SubscriptionChangesToClient{ + add: created_subscriptions.into_iter().map(|c| c.podcast).collect(), + remove: deleted_subscriptions.into_iter().map(|c| c.podcast).collect(), + timestamp: get_current_timestamp() + }) + } + + pub async fn update_subscriptions(device_id: &str, username: &str, upload_request: + web::Json, conn: &mut SqliteConnection)-> Result>, + Error>{ + use crate::schema::subscriptions::dsl as dsl_types; + use crate::schema::subscriptions::dsl::subscriptions; + let mut rewritten_urls:Vec> = vec![vec![]]; + // Add subscriptions + upload_request.clone().add.iter().for_each(|c| { + if !c.starts_with("http")||!c.starts_with("https"){ + rewritten_urls.push(vec![c.to_string(), "".to_string()]); + return + } + + let opt_sub = Self::find_by_podcast(username.to_string(), device_id.to_string(), c + .to_string(), conn).expect("Error retrieving \ + subscription"); + match opt_sub { + Some(s)=>{ + diesel::update(subscriptions.filter(dsl_types::id.eq(s.id))) + .set(dsl_types::deleted.eq(None::)).execute(conn).unwrap(); + }, + None=>{ + let subscription = Subscription::new(username.to_string(), device_id.to_string(), + c.to_string()); + diesel::insert_into(subscriptions).values(( + dsl_types::username.eq(subscription.username), + dsl_types::device.eq(subscription.device), + dsl_types::podcast.eq(subscription.podcast), + dsl_types::created.eq(subscription.created), + dsl_types::deleted.eq(None::) + ) + ) + .execute(conn).unwrap(); + } + } + + + }); + upload_request.clone().remove.iter().for_each(|c|{ + if !c.starts_with("http")||!c.starts_with("https"){ + rewritten_urls.push(vec![c.to_string(), "".to_string()]); + return + } + let opt_sub = Self::find_by_podcast(username.to_string(), device_id.to_string(), c + .to_string(), conn).expect("Error retrieving \ + subscription"); + match opt_sub { + Some(s)=>{ + diesel::update(subscriptions. + filter(dsl_types::id.eq(s.id))) + .set(dsl_types::deleted.eq(Some(Utc::now().naive_utc()))) + .execute(conn).unwrap(); + }, + None=>{} + } + }); + + Ok(rewritten_urls) + } + + pub fn find_by_podcast(username_1: String, deviceid_1: String, podcast_1: String, conn: + &mut SqliteConnection) -> Result, Error>{ + use crate::schema::subscriptions::dsl::*; + + let res = subscriptions.filter(username.eq(username_1).and(device.eq + (deviceid_1)).and(podcast.eq(podcast_1))) + .first::(conn) + .optional() + .expect("Error retrieving subscription"); + + Ok(res) + } +} \ No newline at end of file diff --git a/src/models/subscription_changes_from_client.rs b/src/models/subscription_changes_from_client.rs new file mode 100644 index 00000000..e69de29b diff --git a/src/models/user.rs b/src/models/user.rs index 60517630..1c73f8ea 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -2,7 +2,7 @@ use std::io::Error; use actix_web::HttpResponse; use chrono::NaiveDateTime; use diesel::prelude::{Insertable, Queryable}; -use diesel::{OptionalExtension, RunQueryDsl, SqliteConnection}; +use diesel::{OptionalExtension, RunQueryDsl, SqliteConnection, AsChangeset}; use diesel::associations::HasTable; use utoipa::ToSchema; use crate::schema::users; @@ -11,7 +11,8 @@ use diesel::ExpressionMethods; use dotenv::var; use crate::constants::constants::{BASIC_AUTH, OIDC_AUTH, Role, USERNAME}; -#[derive(Serialize, Deserialize, Queryable, Insertable, Clone, ToSchema, PartialEq)] +#[derive(Serialize, Deserialize, Queryable, Insertable, Clone, ToSchema, PartialEq, Debug, +AsChangeset)] #[serde(rename_all = "camelCase")] pub struct User { pub id: i32, @@ -28,7 +29,8 @@ pub struct UserWithoutPassword{ pub id: i32, pub username: String, pub role: String, - pub created_at: NaiveDateTime + pub created_at: NaiveDateTime, + pub explicit_consent: bool } @@ -113,6 +115,7 @@ impl User{ pub fn map_to_dto(user: Self) -> UserWithoutPassword{ UserWithoutPassword{ id: user.id, + explicit_consent: user.explicit_consent, username: user.username.clone(), role: user.role.clone(), created_at: user.created_at @@ -141,6 +144,14 @@ impl User{ Ok(None) } + pub fn get_gpodder_req_header(req: &actix_web::HttpRequest) -> Result{ + let auth_header = req.headers().get(USERNAME); + if auth_header.is_none() { + return Err(Error::new(std::io::ErrorKind::Other, "Username not found")); + } + return Ok(auth_header.unwrap().to_str().unwrap().parse().unwrap()) + } + pub fn check_if_admin_or_uploader(username: &Option, conn: &mut SqliteConnection) -> Option { @@ -172,4 +183,19 @@ impl User{ } None } + + pub fn delete_by_username(username_to_search: String, conn: &mut SqliteConnection)->Result<(), Error>{ + use crate::schema::users::dsl::*; + diesel::delete(users.filter(username.eq(username_to_search))).execute(conn) + .expect("Error deleting user"); + Ok(()) + } + + pub fn update_user(user: User, conn: &mut SqliteConnection)->Result<(), Error>{ + use crate::schema::users::dsl::*; + diesel::update(users.filter(id.eq(user.clone().id))) + .set(user).execute(conn) + .expect("Error updating user"); + Ok(()) + } } \ No newline at end of file diff --git a/src/schema.rs b/src/schema.rs index 12f4c7b2..96e136fa 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,5 +1,31 @@ // @generated automatically by Diesel CLI. +diesel::table! { + devices (id) { + id -> Integer, + deviceid -> Text, + kind -> Text, + name -> Text, + username -> Text, + } +} + +diesel::table! { + episodes (id) { + id -> Integer, + username -> Text, + device -> Text, + podcast -> Text, + episode -> Text, + timestamp -> Timestamp, + guid -> Nullable, + action -> Text, + started -> Nullable, + position -> Nullable, + total -> Nullable, + } +} + diesel::table! { favorites (username, podcast_id) { username -> Text, @@ -53,7 +79,7 @@ diesel::table! { podcast_id -> Integer, episode_id -> Text, watched_time -> Integer, - date -> Text, + date -> Timestamp, username -> Text, } } @@ -77,6 +103,14 @@ diesel::table! { } } +diesel::table! { + sessions (username, session_id) { + username -> Text, + session_id -> Text, + expires -> Timestamp, + } +} + diesel::table! { settings (id) { id -> Integer, @@ -87,6 +121,17 @@ diesel::table! { } } +diesel::table! { + subscriptions (id) { + id -> Integer, + username -> Text, + device -> Text, + podcast -> Text, + created -> Timestamp, + deleted -> Nullable, + } +} + diesel::table! { users (id) { id -> Integer, @@ -103,12 +148,16 @@ diesel::joinable!(podcast_episodes -> podcasts (podcast_id)); diesel::joinable!(podcast_history_items -> podcasts (podcast_id)); diesel::allow_tables_to_appear_in_same_query!( + devices, + episodes, favorites, invites, notifications, podcast_episodes, podcast_history_items, podcasts, + sessions, settings, + subscriptions, users, ); diff --git a/src/service/environment_service.rs b/src/service/environment_service.rs index c58e797a..67ee7d7c 100644 --- a/src/service/environment_service.rs +++ b/src/service/environment_service.rs @@ -25,6 +25,7 @@ pub struct EnvironmentService { pub password: String, pub oidc_config: Option, pub oidc_configured: bool, + pub gpodder_integration_enabled: bool } impl EnvironmentService { @@ -68,7 +69,8 @@ impl EnvironmentService { username: var(USERNAME).unwrap_or("".to_string()), password: var(PASSWORD).unwrap_or("".to_string()), oidc_configured, - oidc_config: option_oidc_config + oidc_config: option_oidc_config, + gpodder_integration_enabled: var("GPODDER_INTEGRATION_ENABLED").is_ok() } } @@ -98,6 +100,8 @@ impl EnvironmentService { "Polling interval for new episodes: {} minutes", self.polling_interval ); + println!("GPodder integration enabled: {}", self.gpodder_integration_enabled); + println!("Database url is set to: {}", var("DATABASE_URL").unwrap_or("sqlite://./db/podcast.db".to_string())); println!( "Podindex API key&secret configured: {}", self.podindex_api_key.len() > 0 && self.podindex_api_secret.len() > 0 diff --git a/src/service/mod.rs b/src/service/mod.rs index 5a279c2b..ee887162 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -12,3 +12,4 @@ pub mod notification_service; pub mod settings_service; pub mod telegram_api; pub mod user_management_service; +pub mod subscription; diff --git a/src/service/subscription.rs b/src/service/subscription.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/service/subscription.rs @@ -0,0 +1 @@ + diff --git a/src/service/user_management_service.rs b/src/service/user_management_service.rs index c944383b..5a4a5dae 100644 --- a/src/service/user_management_service.rs +++ b/src/service/user_management_service.rs @@ -13,10 +13,6 @@ pub struct UserManagementService{ } impl UserManagementService { - pub fn may_add_podcast(user: User)->bool{ - Role::from_str(&user.role).unwrap() == Role::Uploader|| Role::from_str(&user.role).unwrap() == - Role::Admin - } pub fn may_onboard_user(user: User)->bool{ Role::from_str(&user.role).unwrap() == Role::Admin diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 528f57af..44947433 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ pub mod podcast_builder; mod rss_extension; pub mod do_retry; +pub mod time; diff --git a/src/utils/time.rs b/src/utils/time.rs new file mode 100644 index 00000000..a52612e6 --- /dev/null +++ b/src/utils/time.rs @@ -0,0 +1,12 @@ +use std::time::SystemTime; +use chrono::{NaiveDateTime, Utc}; + +pub fn get_current_timestamp()->i64{ + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64).unwrap() +} + +pub fn get_current_timestamp_str()->NaiveDateTime{ + Utc::now().naive_utc() +} \ No newline at end of file