Initial implementation
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
podcasts.sqlite
|
||||||
|
.env
|
||||||
3728
Cargo.lock
generated
Normal file
3728
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "gpodder-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
base64 = "0.22.1"
|
||||||
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
|
rocket = { path = "../rocket/core/lib", features = ["json", "tls", "secrets"] }
|
||||||
|
rocket_db_pools = { path = "../rocket/contrib/db_pools/lib", features = ["sqlx_sqlite", "sqlx_macros"] }
|
||||||
|
sqlx = "*"
|
||||||
|
semver = { version = "1.0.26", features = ["serde"] }
|
||||||
|
password-hash = { version = "0.5.0", features = ["std"] }
|
||||||
|
argon2 = { version = "0.5.3", features = ["std"] }
|
||||||
|
lazy_static = "1.5.0"
|
||||||
14
Rocket.toml
Normal file
14
Rocket.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[default]
|
||||||
|
log_level = "debug"
|
||||||
|
address = "0.0.0.0"
|
||||||
|
port = 8000
|
||||||
|
|
||||||
|
[default.tls]
|
||||||
|
key = "certs/key.pem"
|
||||||
|
certs = "certs/cert.pem"
|
||||||
|
|
||||||
|
[default.databases.podcast_db]
|
||||||
|
url = "podcasts.sqlite"
|
||||||
|
|
||||||
|
[default.limits]
|
||||||
|
json = "100MiB"
|
||||||
173
src/auth.rs
Normal file
173
src/auth.rs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
#![allow(private_interfaces)]
|
||||||
|
use std::str::Utf8Error;
|
||||||
|
|
||||||
|
use argon2::{Argon2, PasswordVerifier};
|
||||||
|
use base64::{DecodeError, Engine, alphabet::STANDARD, engine::GeneralPurposeConfig};
|
||||||
|
use password_hash::{PasswordHash, PasswordHasher, SaltString, rand_core::OsRng};
|
||||||
|
use rocket::{
|
||||||
|
Request, Route, TypedError, async_trait,
|
||||||
|
http::{CookieJar, Status},
|
||||||
|
post,
|
||||||
|
request::{FromRequest, Outcome},
|
||||||
|
routes,
|
||||||
|
serde::{Deserialize, Serialize, json::Json},
|
||||||
|
trace::debug,
|
||||||
|
};
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
|
||||||
|
use crate::{Db, SqlError};
|
||||||
|
|
||||||
|
pub struct BasicAuth {
|
||||||
|
username: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, TypedError)]
|
||||||
|
pub enum Unauthorized {
|
||||||
|
#[error(status = 401)]
|
||||||
|
MissingHeader,
|
||||||
|
#[error(status = 401)]
|
||||||
|
MalformedHeader(&'static str),
|
||||||
|
#[error(status = 401)]
|
||||||
|
DecodeError(DecodeError),
|
||||||
|
#[error(status = 401)]
|
||||||
|
Utf8Error(Utf8Error),
|
||||||
|
#[error(status = 401)]
|
||||||
|
UserNotFound,
|
||||||
|
#[error(status = 401)]
|
||||||
|
PasswordIncorrect,
|
||||||
|
#[error(status = 500)]
|
||||||
|
InternalError,
|
||||||
|
#[error(status = 500)]
|
||||||
|
DbError(#[error(source)] SqlError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SqlError> for Unauthorized {
|
||||||
|
fn from(value: SqlError) -> Self {
|
||||||
|
Self::DbError(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<sqlx::Error> for Unauthorized {
|
||||||
|
fn from(value: sqlx::Error) -> Self {
|
||||||
|
Self::DbError(value.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasicAuth {
|
||||||
|
async fn from_req<'r>(req: &'r Request<'_>) -> Result<Self, Unauthorized> {
|
||||||
|
// TODO: actual sessions
|
||||||
|
if let Some(cookie) = req.cookies().get_private("SESSION") {
|
||||||
|
return Ok(Self {
|
||||||
|
username: cookie.value().into(),
|
||||||
|
});
|
||||||
|
// } else if let Some(username) = req.headers().get_one("test") {
|
||||||
|
// return Ok(Self {
|
||||||
|
// username: username.into(),
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
let auth = req
|
||||||
|
.headers()
|
||||||
|
.get_one("Authorization")
|
||||||
|
.ok_or(Unauthorized::MissingHeader)?;
|
||||||
|
let params = auth
|
||||||
|
.strip_prefix("Basic ")
|
||||||
|
.ok_or(Unauthorized::MalformedHeader("Expected `Basic `"))?;
|
||||||
|
|
||||||
|
let engine =
|
||||||
|
base64::engine::GeneralPurpose::new(&STANDARD, GeneralPurposeConfig::default());
|
||||||
|
let raw = engine.decode(params).map_err(Unauthorized::DecodeError)?;
|
||||||
|
let s = std::str::from_utf8(&raw).map_err(Unauthorized::Utf8Error)?;
|
||||||
|
let (username, pass) = s.split_once(":").ok_or(Unauthorized::MalformedHeader(
|
||||||
|
"Expected token to include `:`",
|
||||||
|
))?;
|
||||||
|
debug!("Attempting to login with {username}/{pass}");
|
||||||
|
let mut db = req
|
||||||
|
.guard::<Connection<Db>>()
|
||||||
|
.await
|
||||||
|
.success_or(Unauthorized::InternalError)?;
|
||||||
|
let user = sqlx::query!("SELECT * from users where name = ?", username)
|
||||||
|
.fetch_optional(&mut **db)
|
||||||
|
.await?
|
||||||
|
.ok_or(Unauthorized::UserNotFound)?;
|
||||||
|
let hashed =
|
||||||
|
PasswordHash::new(&user.password).expect("Invalid password hash stored in the db");
|
||||||
|
// hashed
|
||||||
|
// .verify_password(&[&Argon2::default()], pass)
|
||||||
|
Argon2::default().verify_password(pass.as_bytes(), &hashed)
|
||||||
|
.map_err(|_| Unauthorized::PasswordIncorrect)
|
||||||
|
.map(|()| {
|
||||||
|
req.cookies().add_private(("SESSION", user.name.clone()));
|
||||||
|
Self {
|
||||||
|
username: user.name,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'r> FromRequest<'r> for BasicAuth {
|
||||||
|
type Forward = std::convert::Infallible;
|
||||||
|
type Error = Unauthorized;
|
||||||
|
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error, Self::Forward> {
|
||||||
|
match Self::from_req(req).await {
|
||||||
|
Ok(v) => rocket::outcome::Outcome::Success(v),
|
||||||
|
Err(v) => rocket::outcome::Outcome::Error(v),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BasicAuth {
|
||||||
|
pub fn username(&self) -> &str {
|
||||||
|
&self.username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/auth/<username>/login.json")]
|
||||||
|
pub fn login(username: &str, auth: BasicAuth) -> Result<&'static str, Status> {
|
||||||
|
if username != auth.username() {
|
||||||
|
Err(Status::BadRequest)
|
||||||
|
} else {
|
||||||
|
Ok("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct PasswordChange<'a> {
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/auth/update_password", data = "<pw>")]
|
||||||
|
pub async fn update_password(
|
||||||
|
auth: BasicAuth,
|
||||||
|
pw: Json<PasswordChange<'_>>,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
) -> Result<&'static str, SqlError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let pw_hash = Argon2::default()
|
||||||
|
.hash_password(pw.password.as_bytes(), salt.as_salt())
|
||||||
|
.expect("Failed to hash password");
|
||||||
|
// pw_hash.to_string()
|
||||||
|
sqlx::query("INSERT INTO users (name, password) VALUES (?1, ?2) ON CONFLICT DO UPDATE SET password = ?2")
|
||||||
|
.bind(auth.username)
|
||||||
|
.bind(pw_hash.to_string())
|
||||||
|
.execute(&mut **db)
|
||||||
|
.await?;
|
||||||
|
Ok("")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/auth/<username>/logout.json")]
|
||||||
|
pub fn logout(
|
||||||
|
username: &str,
|
||||||
|
auth: BasicAuth,
|
||||||
|
cookies: &CookieJar,
|
||||||
|
) -> Result<&'static str, Status> {
|
||||||
|
cookies.remove_private("SESSION");
|
||||||
|
if username != auth.username() {
|
||||||
|
Err(Status::BadRequest)
|
||||||
|
} else {
|
||||||
|
Ok("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![login, logout, update_password]
|
||||||
|
}
|
||||||
133
src/devices.rs
Normal file
133
src/devices.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Db, DotJson, SqlError, Timestamp,
|
||||||
|
auth::BasicAuth,
|
||||||
|
directory::{Episode, Podcast},
|
||||||
|
};
|
||||||
|
use rocket::{
|
||||||
|
Route, TypedError, get, post, routes,
|
||||||
|
serde::{Deserialize, Serialize, json::Json},
|
||||||
|
trace::debug,
|
||||||
|
};
|
||||||
|
use rocket_db_pools::{
|
||||||
|
Connection,
|
||||||
|
sqlx::{self, Connection as _},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct Device {
|
||||||
|
id: String,
|
||||||
|
caption: String,
|
||||||
|
r#type: String,
|
||||||
|
// subscriptions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/devices/<_user>.json")]
|
||||||
|
pub async fn get(
|
||||||
|
_user: &str,
|
||||||
|
auth: BasicAuth,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
) -> Result<Json<Vec<Device>>, SqlError> {
|
||||||
|
let username = auth.username();
|
||||||
|
debug!("Attempting to get devices for {username}");
|
||||||
|
let res: Vec<Device> = sqlx::query_as!(
|
||||||
|
Device,
|
||||||
|
"SELECT id, caption, type FROM devices WHERE user = ?",
|
||||||
|
username
|
||||||
|
)
|
||||||
|
.fetch_all(&mut **db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(res))
|
||||||
|
// todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct DeviceUpdate<'a> {
|
||||||
|
caption: Option<&'a str>,
|
||||||
|
r#type: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/devices/<_user>/<device>.json", data = "<dev>")]
|
||||||
|
pub async fn update(
|
||||||
|
_user: &str,
|
||||||
|
device: &str,
|
||||||
|
dev: Json<DeviceUpdate<'_>>,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
auth: BasicAuth,
|
||||||
|
) -> Result<Json<Device>, SqlError> {
|
||||||
|
let username = auth.username();
|
||||||
|
debug!("Attempting to update {device} for {username}");
|
||||||
|
let mut trans = db.begin().await?;
|
||||||
|
let existing = sqlx::query_as!(
|
||||||
|
Device,
|
||||||
|
"SELECT id, caption, type FROM devices WHERE user = ? AND id = ?",
|
||||||
|
username,
|
||||||
|
device
|
||||||
|
)
|
||||||
|
.fetch_optional(&mut *trans)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// debug!("Users: {:?}", sqlx::query!("SELECT * FROM users").fetch_all(&mut **db).await);
|
||||||
|
let res = sqlx::query("INSERT INTO devices (id, user, caption, type) VALUES (?, ?, ?, ?)")
|
||||||
|
.bind(device)
|
||||||
|
.bind(username)
|
||||||
|
.bind(
|
||||||
|
dev.caption
|
||||||
|
.or(existing.as_ref().map(|d| d.caption.as_str()))
|
||||||
|
.unwrap_or(""),
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
dev.r#type
|
||||||
|
.or(existing.as_ref().map(|d| d.r#type.as_str()))
|
||||||
|
.unwrap_or(""),
|
||||||
|
)
|
||||||
|
.execute(&mut *trans)
|
||||||
|
.await?;
|
||||||
|
debug!(
|
||||||
|
"Attempting to update {} for {username}, {dev:?}, {res:?}",
|
||||||
|
device
|
||||||
|
);
|
||||||
|
let res = sqlx::query_as!(
|
||||||
|
Device,
|
||||||
|
"SELECT id, caption, type FROM devices WHERE user = ? AND id = ?",
|
||||||
|
username,
|
||||||
|
device
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *trans)
|
||||||
|
.await?;
|
||||||
|
trans.commit().await?;
|
||||||
|
Ok(Json(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct DeviceUpdates<'a> {
|
||||||
|
add: Vec<Podcast<'a>>,
|
||||||
|
remove: Vec<Cow<'a, str>>,
|
||||||
|
updates: Vec<Episode<'a>>,
|
||||||
|
timestamp: Timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/updates/<username>/<device>?<since>&<include_actions>")]
|
||||||
|
pub fn updates(
|
||||||
|
username: &str,
|
||||||
|
device: DotJson<&str>,
|
||||||
|
since: Option<Timestamp>,
|
||||||
|
include_actions: Option<bool>,
|
||||||
|
_auth: BasicAuth,
|
||||||
|
) -> Json<DeviceUpdates<'static>> {
|
||||||
|
debug!("Attempting to update {} for {username}, {since:?}", *device);
|
||||||
|
Json(DeviceUpdates {
|
||||||
|
add: vec![],
|
||||||
|
remove: vec![],
|
||||||
|
updates: vec![],
|
||||||
|
timestamp: Timestamp::now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![get, update, updates]
|
||||||
|
}
|
||||||
83
src/directory.rs
Normal file
83
src/directory.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
|
||||||
|
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use rocket::{get, post, routes, serde::{json::Json, Deserialize, Serialize}, trace::debug, Route};
|
||||||
|
use crate::{auth::BasicAuth, DotJson, Format, FormatType, Timestamp};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct Tag<'a> {
|
||||||
|
title: Cow<'a, str>,
|
||||||
|
tag: Cow<'a, str>,
|
||||||
|
usage: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/tags/<count>")]
|
||||||
|
pub fn top_tags(count: DotJson<usize>) -> Json<Vec<Tag<'static>>> {
|
||||||
|
debug!("Attempting to get top tags {}", *count);
|
||||||
|
Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct Podcast<'a> {
|
||||||
|
website: Cow<'a, str>,
|
||||||
|
mygpo_link: Cow<'a, str>,
|
||||||
|
description: Cow<'a, str>,
|
||||||
|
subscribers: usize,
|
||||||
|
title: Cow<'a, str>,
|
||||||
|
author: Cow<'a, str>,
|
||||||
|
url: Cow<'a, str>,
|
||||||
|
logo_url: Cow<'a, str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/tag/<tag>/<count>")]
|
||||||
|
pub fn podcasts_for_tag(tag: &str, count: DotJson<usize>) -> Json<Vec<Podcast<'static>>> {
|
||||||
|
debug!("Attempting to get {} podcasts for {tag}", *count);
|
||||||
|
Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/data/podcast.json?<url>")]
|
||||||
|
pub fn podcast_data(url: &str) -> Json<Podcast<'static>> {
|
||||||
|
debug!("Attempting to get {url}");
|
||||||
|
todo!()
|
||||||
|
// Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct Episode<'a> {
|
||||||
|
title: Cow<'a, str>,
|
||||||
|
url: Cow<'a, str>,
|
||||||
|
podcast_title: Cow<'a, str>,
|
||||||
|
podcast_url: Cow<'a, str>,
|
||||||
|
description: Cow<'a, str>,
|
||||||
|
website: Cow<'a, str>,
|
||||||
|
released: Timestamp,
|
||||||
|
mygpo_link: Cow<'a, str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/data/episode.json?<url>")]
|
||||||
|
pub fn episode_data(url: &str) -> Json<Episode<'static>> {
|
||||||
|
debug!("Attempting to get {url}");
|
||||||
|
todo!()
|
||||||
|
// Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/search.json?<q>")]
|
||||||
|
pub fn search(q: &str) -> Json<Vec<Podcast<'static>>> {
|
||||||
|
debug!("Attempting to search for {q}");
|
||||||
|
Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![
|
||||||
|
top_tags,
|
||||||
|
podcasts_for_tag,
|
||||||
|
podcast_data,
|
||||||
|
episode_data,
|
||||||
|
search,
|
||||||
|
]
|
||||||
|
}
|
||||||
108
src/episodes.rs
Normal file
108
src/episodes.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
|
||||||
|
use crate::{auth::BasicAuth, subscriptions::SubscriptionChangesResult, Db, SqlError, Timestamp};
|
||||||
|
use chrono::Utc;
|
||||||
|
use rocket::{
|
||||||
|
Route, get, post, routes,
|
||||||
|
serde::{Deserialize, Serialize, json::Json},
|
||||||
|
trace::debug,
|
||||||
|
};
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
use sqlx::Acquire;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct EpisodeAction {
|
||||||
|
podcast: String,
|
||||||
|
episode: String,
|
||||||
|
guid: Option<String>,
|
||||||
|
device: Option<String>,
|
||||||
|
action: String,
|
||||||
|
timestamp: String,
|
||||||
|
started: Option<i64>,
|
||||||
|
position: Option<i64>,
|
||||||
|
total: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct EpisodeActions {
|
||||||
|
actions: Vec<EpisodeAction>,
|
||||||
|
timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/episodes/<_user>.json?<since>")]
|
||||||
|
pub async fn list_json(
|
||||||
|
_user: &str,
|
||||||
|
since: Option<i64>,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
auth: BasicAuth,
|
||||||
|
) -> Result<Json<EpisodeActions>, SqlError> {
|
||||||
|
debug!(
|
||||||
|
"Attempting to list {}, since {since:?}",
|
||||||
|
auth.username()
|
||||||
|
);
|
||||||
|
let time = Utc::now().timestamp();
|
||||||
|
let username = auth.username();
|
||||||
|
let since = since.unwrap_or(0);
|
||||||
|
let actions = sqlx::query_as!(
|
||||||
|
EpisodeAction,
|
||||||
|
"SELECT podcast, episode, device, guid, action, timestamp, started, position, total FROM episodes WHERE user = ? AND updated >= ?",
|
||||||
|
username,
|
||||||
|
since,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut **db)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(EpisodeActions { actions, timestamp: time }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/episodes/<_user>.json", data = "<updates>")]
|
||||||
|
pub async fn update_json(
|
||||||
|
_user: &str,
|
||||||
|
updates: Json<Vec<EpisodeAction>>,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
auth: BasicAuth,
|
||||||
|
) -> Result<Json<SubscriptionChangesResult>, SqlError> {
|
||||||
|
let time = Timestamp::now();
|
||||||
|
let updates = updates.into_inner();
|
||||||
|
debug!(
|
||||||
|
"Attempting to update {}, new: {:?}",
|
||||||
|
auth.username(),
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
let mut trans = db.begin().await?;
|
||||||
|
for action in &updates {
|
||||||
|
sqlx::query("INSERT INTO episodes (
|
||||||
|
user,
|
||||||
|
podcast,
|
||||||
|
episode,
|
||||||
|
device,
|
||||||
|
action,
|
||||||
|
timestamp,
|
||||||
|
started,
|
||||||
|
position,
|
||||||
|
total,
|
||||||
|
updated
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
||||||
|
.bind(auth.username())
|
||||||
|
.bind(&action.podcast)
|
||||||
|
.bind(&action.episode)
|
||||||
|
.bind(&action.device)
|
||||||
|
.bind(&action.action)
|
||||||
|
.bind(&action.timestamp)
|
||||||
|
.bind(action.started)
|
||||||
|
.bind(action.position)
|
||||||
|
.bind(action.total)
|
||||||
|
.bind(time.timestamp())
|
||||||
|
.execute(&mut *trans)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
trans.commit().await?;
|
||||||
|
Ok(Json(SubscriptionChangesResult {
|
||||||
|
update_urls: vec![],
|
||||||
|
timestamp: time.timestamp(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![list_json, update_json]
|
||||||
|
}
|
||||||
80
src/format.rs
Normal file
80
src/format.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use rocket::{request::FromParam, TypedError, catcher::TypedError};
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct DotJson<T>(T);
|
||||||
|
|
||||||
|
impl<T> std::ops::Deref for DotJson<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, TypedError)]
|
||||||
|
pub enum FormatError<T> {
|
||||||
|
MissingFormat(&'static [&'static str]),
|
||||||
|
Parse(T),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T: FromParam<'a>> FromParam<'a> for DotJson<T>
|
||||||
|
where
|
||||||
|
T::Error: TypedError<'a> + fmt::Debug + 'static,
|
||||||
|
{
|
||||||
|
type Error = FormatError<T::Error>;
|
||||||
|
|
||||||
|
fn from_param(param: &'a str) -> Result<Self, Self::Error> {
|
||||||
|
param
|
||||||
|
.strip_suffix(".json")
|
||||||
|
.ok_or(FormatError::MissingFormat(&["json"]))
|
||||||
|
.and_then(|s| T::from_param(s).map_err(FormatError::Parse))
|
||||||
|
.map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum FormatType {
|
||||||
|
Json,
|
||||||
|
Opml,
|
||||||
|
Jsonp,
|
||||||
|
Text,
|
||||||
|
Xml,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Format<T>(T, FormatType);
|
||||||
|
|
||||||
|
impl<T> Format<T> {
|
||||||
|
pub fn format(&self) -> FormatType {
|
||||||
|
self.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> std::ops::Deref for Format<T> {
|
||||||
|
type Target = T;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, T: FromParam<'a>> FromParam<'a> for Format<T>
|
||||||
|
where
|
||||||
|
T::Error: TypedError<'a> + fmt::Debug + 'static,
|
||||||
|
{
|
||||||
|
type Error = FormatError<T::Error>;
|
||||||
|
|
||||||
|
fn from_param(param: &'a str) -> Result<Self, Self::Error> {
|
||||||
|
// TODO: Support other formats
|
||||||
|
if let Some(s) = param
|
||||||
|
.strip_suffix(".json") {
|
||||||
|
T::from_param(s).map_err(FormatError::Parse)
|
||||||
|
.map(|v| Self(v, FormatType::Json))
|
||||||
|
} else {
|
||||||
|
Err(FormatError::MissingFormat(&["json"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/main.rs
Normal file
102
src/main.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
use rocket::{catch, catchers, fairing::AdHoc, launch, TypedError};
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod devices;
|
||||||
|
mod directory;
|
||||||
|
mod subscriptions;
|
||||||
|
mod suggestions;
|
||||||
|
mod time;
|
||||||
|
mod episodes;
|
||||||
|
use rocket_db_pools::{
|
||||||
|
Database,
|
||||||
|
sqlx,
|
||||||
|
};
|
||||||
|
pub use time::Timestamp;
|
||||||
|
mod format;
|
||||||
|
pub use format::*;
|
||||||
|
|
||||||
|
#[derive(Debug, TypedError)]
|
||||||
|
pub struct SqlError(sqlx::Error);
|
||||||
|
|
||||||
|
impl From<sqlx::Error> for SqlError {
|
||||||
|
fn from(e: sqlx::Error) -> Self {
|
||||||
|
Self(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[catch(default, error = "<error>")]
|
||||||
|
fn catch_sql(error: &SqlError) -> String {
|
||||||
|
format!("Db Error: {}", error.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Database)]
|
||||||
|
#[database("podcast_db")]
|
||||||
|
struct Db(sqlx::SqlitePool);
|
||||||
|
|
||||||
|
const SQL_INIT: &[&str] = &[
|
||||||
|
"CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY NOT NULL, password TEXT NOT NULL);",
|
||||||
|
"REPLACE INTO users (name, password) VALUES ('matt', 'pass')",
|
||||||
|
"CREATE TABLE IF NOT EXISTS devices (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
caption TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(id, user) ON CONFLICT REPLACE,
|
||||||
|
FOREIGN KEY(user) REFERENCES users(name)
|
||||||
|
);",
|
||||||
|
// "DROP TABLE IF NOT EXISTS devices",
|
||||||
|
"CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
device TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
current INTEGER NOT NULL,
|
||||||
|
updated INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(url, user, device) ON CONFLICT REPLACE,
|
||||||
|
FOREIGN KEY(user) REFERENCES users(name),
|
||||||
|
FOREIGN KEY(device) REFERENCES devices(id)
|
||||||
|
);",
|
||||||
|
"CREATE TABLE IF NOT EXISTS episodes (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
podcast TEXT NOT NULL,
|
||||||
|
episode TEXT NOT NULL,
|
||||||
|
device TEXT,
|
||||||
|
guid TEXT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
started INTEGER,
|
||||||
|
position INTEGER,
|
||||||
|
total INTEGER,
|
||||||
|
updated INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY(user) REFERENCES users(name),
|
||||||
|
FOREIGN KEY(device) REFERENCES devices(id)
|
||||||
|
);",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[launch]
|
||||||
|
fn launch() -> _ {
|
||||||
|
rocket::build()
|
||||||
|
.attach(Db::init())
|
||||||
|
.attach(AdHoc::on_liftoff("Init db", |r| {
|
||||||
|
Box::pin(async {
|
||||||
|
if let Some(db) = Db::fetch(r) {
|
||||||
|
let mut con = db.acquire().await.unwrap();
|
||||||
|
for stmt in SQL_INIT {
|
||||||
|
sqlx::query(stmt).execute(&mut *con).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
// .mount("/", auth::routes())
|
||||||
|
.mount("/api/2", auth::routes())
|
||||||
|
// .mount("/", devices::routes())
|
||||||
|
.mount("/api/2", devices::routes())
|
||||||
|
// .mount("/", directory::routes())
|
||||||
|
// .mount("/api/2", directory::routes())
|
||||||
|
// .mount("/", subscriptions::routes())
|
||||||
|
.mount("/api/2", subscriptions::routes())
|
||||||
|
// .mount("/", suggestions::routes())
|
||||||
|
// .mount("/api/2", suggestions::routes())
|
||||||
|
.mount("/api/2", episodes::routes())
|
||||||
|
.register("/", catchers![catch_sql])
|
||||||
|
}
|
||||||
137
src/subscriptions.rs
Normal file
137
src/subscriptions.rs
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
use crate::{Db, SqlError, Timestamp, auth::BasicAuth};
|
||||||
|
use chrono::Utc;
|
||||||
|
use rocket::{
|
||||||
|
Route, get, post, put, routes,
|
||||||
|
serde::{Deserialize, Serialize, json::Json},
|
||||||
|
trace::debug,
|
||||||
|
};
|
||||||
|
use rocket_db_pools::Connection;
|
||||||
|
use sqlx::{Acquire, Execute};
|
||||||
|
|
||||||
|
#[put("/subscriptions/<_user>/<device>")]
|
||||||
|
pub fn set(_user: &str, device: &str, auth: BasicAuth) -> &'static str {
|
||||||
|
debug!("Attempting to set {}/{device}", auth.username());
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/subscriptions/<_user>/<device>.opml")]
|
||||||
|
pub fn list_opml(_user: &str, device: &str, auth: BasicAuth) -> &'static str {
|
||||||
|
debug!("Attempting to list {}/{device}", auth.username());
|
||||||
|
""
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct Subscriptions {
|
||||||
|
add: Vec<String>,
|
||||||
|
remove: Vec<String>,
|
||||||
|
timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/subscriptions/<_user>/<device>.json?<since>")]
|
||||||
|
pub async fn list_json(
|
||||||
|
_user: &str,
|
||||||
|
device: &str,
|
||||||
|
since: Option<i64>,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
auth: BasicAuth,
|
||||||
|
) -> Result<Json<Subscriptions>, SqlError> {
|
||||||
|
debug!(
|
||||||
|
"Attempting to list {}/{device}, since {since:?}",
|
||||||
|
auth.username()
|
||||||
|
);
|
||||||
|
let time = Utc::now().timestamp();
|
||||||
|
let username = auth.username();
|
||||||
|
let since = since.unwrap_or(0);
|
||||||
|
let results = sqlx::query!(
|
||||||
|
"SELECT * FROM subscriptions WHERE device = ? AND user = ? AND updated >= ?",
|
||||||
|
device,
|
||||||
|
username,
|
||||||
|
since,
|
||||||
|
)
|
||||||
|
.fetch_all(&mut **db)
|
||||||
|
.await?;
|
||||||
|
let mut res = Subscriptions {
|
||||||
|
add: vec![],
|
||||||
|
remove: vec![],
|
||||||
|
timestamp: time,
|
||||||
|
};
|
||||||
|
for record in results {
|
||||||
|
if record.current == 1 {
|
||||||
|
res.add.push(record.url);
|
||||||
|
} else if record.current == 0 {
|
||||||
|
res.remove.push(record.url);
|
||||||
|
} else {
|
||||||
|
debug!("Found current value of `{}`", record.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct SubscriptionChanges {
|
||||||
|
add: Vec<String>,
|
||||||
|
remove: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
pub struct SubscriptionChangesResult {
|
||||||
|
pub update_urls: Vec<(String, String)>,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/subscriptions/<_user>/<device>.json", data = "<updates>")]
|
||||||
|
pub async fn update_json(
|
||||||
|
_user: &str,
|
||||||
|
device: &str,
|
||||||
|
updates: Json<SubscriptionChanges>,
|
||||||
|
mut db: Connection<Db>,
|
||||||
|
auth: BasicAuth,
|
||||||
|
) -> Result<Json<SubscriptionChangesResult>, SqlError> {
|
||||||
|
let time = Utc::now().timestamp();
|
||||||
|
let updates = updates.into_inner();
|
||||||
|
debug!(
|
||||||
|
"Attempting to update {}/{device}, new: {:?}",
|
||||||
|
auth.username(),
|
||||||
|
updates
|
||||||
|
);
|
||||||
|
let mut trans = db.begin().await?;
|
||||||
|
for removed in &updates.remove {
|
||||||
|
sqlx::query("INSERT INTO subscriptions (url, device, user, current, updated) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.bind(removed)
|
||||||
|
.bind(device)
|
||||||
|
.bind(auth.username())
|
||||||
|
.bind(0)
|
||||||
|
.bind(time)
|
||||||
|
.execute(&mut *trans)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for added in &updates.add {
|
||||||
|
sqlx::query("INSERT INTO subscriptions (url, device, user, current, updated) VALUES (?, ?, ?, ?, ?)")
|
||||||
|
.bind(added)
|
||||||
|
.bind(device)
|
||||||
|
.bind(auth.username())
|
||||||
|
.bind(1)
|
||||||
|
.bind(time)
|
||||||
|
.execute(&mut *trans)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
trans.commit().await?;
|
||||||
|
// sqlx::qu
|
||||||
|
Ok(Json(SubscriptionChangesResult {
|
||||||
|
update_urls: vec![],
|
||||||
|
timestamp: time,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/subscriptions/<_user>.json")]
|
||||||
|
pub fn list_all_json(_user: &str, auth: BasicAuth) -> Json<Vec<String>> {
|
||||||
|
debug!("Attempting to list {}", auth.username());
|
||||||
|
Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![set, list_json, list_opml, list_all_json, update_json]
|
||||||
|
}
|
||||||
16
src/suggestions.rs
Normal file
16
src/suggestions.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use rocket::{get, routes, serde::json::Json, trace::debug, Route};
|
||||||
|
|
||||||
|
#[get("/suggestions/<count>.json")]
|
||||||
|
pub fn get_json(count: usize) -> Json<Vec<()>> {
|
||||||
|
debug!("Getting {count} suggestions");
|
||||||
|
Json(vec![])
|
||||||
|
}
|
||||||
|
#[get("/suggestions/<count>.opml")]
|
||||||
|
pub fn get_opml(count: usize) -> Json<Vec<()>> {
|
||||||
|
debug!("Getting {count} suggestions");
|
||||||
|
Json(vec![])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Vec<Route> {
|
||||||
|
routes![get_json, get_opml]
|
||||||
|
}
|
||||||
79
src/time.rs
Normal file
79
src/time.rs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
use chrono::{format::ParseErrorKind, DateTime, FixedOffset, NaiveDateTime, ParseError, Utc};
|
||||||
|
use rocket::{form::{self, error::ErrorKind, FromFormField, ValueField}, serde::{Deserialize, Serialize}, trace::debug};
|
||||||
|
use sqlx::{Encode, Type};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
#[serde(crate = "rocket::serde")]
|
||||||
|
#[serde(try_from = "&str", into = "String")]
|
||||||
|
pub struct Timestamp(DateTime<FixedOffset>);
|
||||||
|
|
||||||
|
impl std::ops::Deref for Timestamp {
|
||||||
|
type Target = DateTime<FixedOffset>;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Timestamp {
|
||||||
|
pub fn now() -> Self {
|
||||||
|
Self(Utc::now().into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<&str> for Timestamp {
|
||||||
|
type Error = chrono::ParseError;
|
||||||
|
|
||||||
|
fn try_from(value: &str) -> Result<Self, Self::Error> {
|
||||||
|
match DateTime::parse_from_rfc3339(value) {
|
||||||
|
Ok(v) => Ok(Self(v)),
|
||||||
|
// Err(e) if e.kind() == ParseErrorKind::TooShort => {
|
||||||
|
// NaiveDateTime::parse_from_str(value, "").map(|v| Self(v.and_utc().into()))
|
||||||
|
// }
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
// .map(Self).inspect_err(|e| {
|
||||||
|
// debug!("Timestamp parse error: {e:?}");
|
||||||
|
// })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Timestamp {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self::try_from(value.as_str()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Into<String> for Timestamp {
|
||||||
|
fn into(self) -> String {
|
||||||
|
self.0.to_rfc3339()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'v> FromFormField<'v> for Timestamp {
|
||||||
|
fn from_value(field: ValueField<'v>) -> form::Result<'v, Self> {
|
||||||
|
Self::try_from(field.value)
|
||||||
|
.map_err(|_e| ErrorKind::Validation("Invalid format".into()).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'q, Db: sqlx::Database> Encode<'q, Db> for Timestamp
|
||||||
|
where String: Encode<'q, Db>
|
||||||
|
{
|
||||||
|
fn encode_by_ref(
|
||||||
|
&self,
|
||||||
|
buf: &mut <Db as sqlx::Database>::ArgumentBuffer<'q>,
|
||||||
|
) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
|
||||||
|
self.0.to_rfc3339().encode_by_ref(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Db: sqlx::Database> Type<Db> for Timestamp
|
||||||
|
where String: Type<Db>
|
||||||
|
{
|
||||||
|
fn type_info() -> Db::TypeInfo {
|
||||||
|
String::type_info()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user