Initial implementation

This commit is contained in:
2025-09-27 00:29:57 -05:00
commit 957bf1b549
13 changed files with 4671 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
podcasts.sqlite
.env

3728
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

15
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
}
}