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