refactor: move server to own package; set up workspace dependencies

This commit is contained in:
Jef Roosens 2025-04-05 10:19:19 +02:00
parent 279983c64c
commit 7abce21aee
Signed by: Jef Roosens
GPG key ID: 02D4C0997E74717B
34 changed files with 41 additions and 30 deletions

27
server/Cargo.toml Normal file
View file

@ -0,0 +1,27 @@
[package]
name = "otterd"
version = "0.1.0"
edition = "2021"
[dependencies]
gpodder = { path = "../gpodder" }
gpodder_sqlite = { path = "../gpodder_sqlite" }
chrono = { workspace = true, features = ["serde"] }
rand = { workspace = true }
tracing = { workspace = true }
serde = { version = "1.0.218", features = ["derive"] }
figment = { version = "0.10.19", features = ["env", "toml"] }
clap = { version = "4.5.30", features = ["derive", "env"] }
tower-http = { version = "0.6.2", features = ["set-header", "trace"] }
axum = { version = "0.8.1", features = ["macros"] }
axum-extra = { version = "0.10", features = ["cookie", "typed-header"] }
axum-range = "0.5.0"
cookie = "0.18.1"
http-body-util = "0.1.3"
tokio = { version = "1.43.0", features = ["full"] }
tracing-subscriber = "0.3.19"
tera = "1.20.0"

49
server/src/cli/db.rs Normal file
View file

@ -0,0 +1,49 @@
use clap::Subcommand;
use crate::{db::DbResult, ErrorExt};
/// Tools to view and manage the database.
#[derive(Subcommand)]
pub enum DbCommand {
#[command(subcommand)]
Add(AddCommand),
}
/// Insert a new entity into the database
#[derive(Subcommand)]
pub enum AddCommand {
User { username: String, password: String },
}
impl DbCommand {
pub fn run(&self, config: &crate::config::Config) -> u8 {
match self {
DbCommand::Add(cmd) => cmd.run(config),
}
}
}
impl AddCommand {
pub fn run(&self, config: &crate::config::Config) -> u8 {
match self.run_err(config) {
Ok(()) => 0,
Err(err) => {
eprintln!("An error occured: {}", err.stack());
1
}
}
}
pub fn run_err(&self, config: &crate::config::Config) -> DbResult<()> {
let pool = crate::db::initialize_db(config.data_dir.join(crate::DB_FILENAME), false)?;
match self {
Self::User { username, password } => {
crate::db::NewUser::new(username.clone(), password.clone()).insert(&pool)?;
}
}
Ok(())
}
}

44
server/src/cli/gpo.rs Normal file
View file

@ -0,0 +1,44 @@
use clap::Subcommand;
#[derive(Subcommand)]
pub enum Command {
/// Add devices of a specific user to the same sync group
Sync {
username: String,
devices: Vec<String>,
},
/// List the devices for the given user
Devices { username: String },
}
impl Command {
pub fn run(&self, config: &crate::config::Config) -> u8 {
let store =
gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME))
.unwrap();
let store = gpodder::GpodderRepository::new(store);
match self {
Self::Sync { username, devices } => {
let user = store.get_user(username).unwrap();
store
.update_device_sync_status(
&user,
vec![devices.iter().map(|s| s.as_ref()).collect()],
Vec::new(),
)
.unwrap();
}
Self::Devices { username } => {
let user = store.get_user(username).unwrap();
let devices = store.devices_for_user(&user).unwrap();
for device in devices {
println!("{} ({} subscriptions)", device.id, device.subscriptions);
}
}
}
0
}
}

93
server/src/cli/mod.rs Normal file
View file

@ -0,0 +1,93 @@
// mod db;
mod gpo;
mod serve;
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use serde::Serialize;
use crate::config::LogLevel;
/// Otter is a lightweight implementation of the Gpodder API, designed to be used for small
/// personal deployments.
#[derive(Parser)]
pub struct Cli {
#[command(flatten)]
pub config: ClapConfig,
#[command(subcommand)]
pub cmd: Command,
}
#[derive(Serialize, Args, Clone)]
pub struct ClapConfig {
#[arg(
short,
long = "config",
env = "OTTER_CONFIG_FILE",
value_name = "CONFIG_FILE",
global = true
)]
config_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long = "data", value_name = "DATA_DIR", global = true)]
data_dir: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long, value_name = "DOMAIN", global = true)]
domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long, value_name = "PORT", global = true)]
port: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(short, long = "log", value_name = "LOG_LEVEL", global = true)]
log_level: Option<LogLevel>,
}
#[derive(Subcommand)]
pub enum Command {
Serve,
// #[command(subcommand)]
// Db(db::DbCommand),
/// Perform operations on the database through the Gpodder abstraction, allowing operations
/// identical to the ones performed by the API.
#[command(subcommand)]
Gpo(gpo::Command),
}
impl Cli {
pub fn run(&self) -> u8 {
let mut figment = Figment::new();
if let Some(config_path) = &self.config.config_file {
figment = figment.merge(Toml::file(config_path));
}
let config: crate::config::Config = match figment
.merge(Env::prefixed("OTTER_"))
.merge(Serialized::defaults(self.config.clone()))
.extract()
{
Ok(config) => config,
Err(err) => {
eprintln!("{}", err);
return 1;
}
};
match &self.cmd {
Command::Serve => serve::serve(&config),
// Command::Db(cmd) => cmd.run(&config),
Command::Gpo(cmd) => cmd.run(&config),
}
}
}

62
server/src/cli/serve.rs Normal file
View file

@ -0,0 +1,62 @@
use std::{sync::Arc, time::Duration};
use crate::server;
pub fn serve(config: &crate::config::Config) -> u8 {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::from(config.log_level))
.init();
tracing::info!("Initializing database and running migrations");
// TODO remove unwraps
let store =
gpodder_sqlite::SqliteRepository::from_path(config.data_dir.join(crate::DB_FILENAME))
.unwrap();
let tera = crate::web::initialize_tera().unwrap();
let store = gpodder::GpodderRepository::new(store);
let ctx = server::Context {
store,
tera: Arc::new(tera),
};
let app = server::app(ctx.clone());
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let address = format!("{}:{}", config.domain, config.port);
tracing::info!("Starting server on {address}");
let session_removal_duration = Duration::from_secs(config.session_cleanup_interval);
rt.block_on(async {
tokio::task::spawn(async move {
let mut interval = tokio::time::interval(session_removal_duration);
loop {
interval.tick().await;
tracing::info!("Performing session cleanup");
match ctx.store.remove_old_sessions() {
Ok(n) => {
tracing::info!("Removed {} old sessions", n);
}
Err(err) => {
tracing::error!("Error occured during session cleanup: {}", err);
}
}
}
});
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
axum::serve(listener, app.into_make_service())
.await
.unwrap()
});
0
}

59
server/src/config.rs Normal file
View file

@ -0,0 +1,59 @@
use std::path::PathBuf;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Clone, ValueEnum, Copy)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
impl From<LogLevel> for tracing::Level {
fn from(value: LogLevel) -> Self {
match value {
LogLevel::Debug => Self::DEBUG,
LogLevel::Info => Self::INFO,
LogLevel::Warn => Self::WARN,
LogLevel::Error => Self::ERROR,
}
}
}
#[derive(Deserialize)]
pub struct Config {
#[serde(default = "default_data_dir")]
pub data_dir: PathBuf,
#[serde(default = "default_domain")]
pub domain: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_session_cleanup_interval")]
pub session_cleanup_interval: u64,
#[serde(default = "default_log_level")]
pub log_level: LogLevel,
}
fn default_data_dir() -> PathBuf {
PathBuf::from("./data")
}
fn default_domain() -> String {
"127.0.0.1".to_string()
}
fn default_port() -> u16 {
8080
}
fn default_session_cleanup_interval() -> u64 {
// Default is once a day
60 * 60 * 24
}
fn default_log_level() -> LogLevel {
LogLevel::Warn
}

34
server/src/main.rs Normal file
View file

@ -0,0 +1,34 @@
mod cli;
mod config;
mod server;
mod web;
use clap::Parser;
use std::{fmt::Write, process::ExitCode};
const DB_FILENAME: &str = "otter.sqlite3";
pub trait ErrorExt: std::error::Error {
/// Return the full chain of error messages
fn stack(&self) -> String {
let mut msg = format!("{}", self);
let mut err = self.source();
while let Some(src) = err {
write!(msg, " - {}", src).unwrap();
err = src.source();
}
msg
}
}
impl<E: std::error::Error> ErrorExt for E {}
fn main() -> ExitCode {
let args = cli::Cli::parse();
ExitCode::from(args.run())
}

View file

@ -0,0 +1,71 @@
use std::fmt;
use axum::{http::StatusCode, response::IntoResponse};
use crate::ErrorExt;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug)]
pub enum AppError {
// Db(db::DbError),
IO(std::io::Error),
Tera(tera::Error),
Other(Box<dyn std::error::Error + 'static + Send + Sync>),
BadRequest,
Unauthorized,
NotFound,
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
// Self::Db(_) => write!(f, "database error"),
Self::IO(_) => write!(f, "io error"),
Self::Tera(_) => write!(f, "tera error"),
Self::Other(_) => write!(f, "other error"),
Self::BadRequest => write!(f, "bad request"),
Self::Unauthorized => write!(f, "unauthorized"),
Self::NotFound => write!(f, "not found"),
}
}
}
impl std::error::Error for AppError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
// Self::Db(err) => Some(err),
Self::Tera(err) => Some(err),
Self::IO(err) => Some(err),
Self::Other(err) => Some(err.as_ref()),
Self::NotFound | Self::Unauthorized | Self::BadRequest => None,
}
}
}
impl From<std::io::Error> for AppError {
fn from(value: std::io::Error) -> Self {
Self::IO(value)
}
}
impl From<tera::Error> for AppError {
fn from(value: tera::Error) -> Self {
Self::Tera(value)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
match self {
Self::NotFound => StatusCode::NOT_FOUND.into_response(),
Self::Unauthorized => StatusCode::UNAUTHORIZED.into_response(),
Self::BadRequest => StatusCode::BAD_REQUEST.into_response(),
_ => {
tracing::error!("{}", self.stack());
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}

View file

@ -0,0 +1,106 @@
use axum::{
extract::{Path, State},
routing::post,
Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization, UserAgent},
TypedHeader,
};
use cookie::time::Duration;
use gpodder::AuthErr;
use crate::server::{
error::{AppError, AppResult},
gpodder::SESSION_ID_COOKIE,
Context,
};
pub fn router() -> Router<Context> {
Router::new()
.route("/{username}/login.json", post(post_login))
.route("/{_username}/logout.json", post(post_logout))
}
async fn post_login(
State(ctx): State<Context>,
Path(username): Path<String>,
jar: CookieJar,
TypedHeader(auth): TypedHeader<Authorization<Basic>>,
user_agent: Option<TypedHeader<UserAgent>>,
) -> AppResult<CookieJar> {
// These should be the same according to the spec
if username != auth.username() {
return Err(AppError::BadRequest);
}
// If a session token is present, we check if it's valid first and do nothing if it is
if let Some(session_id) = jar
.get(SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
let ctx = ctx.clone();
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
Ok(session)
})
.await
.unwrap()
{
Ok(_) => {
return Ok(jar);
}
Err(gpodder::AuthErr::UnknownSession) => {}
Err(err) => {
return Err(AppError::from(err));
}
}
}
let session = tokio::task::spawn_blocking(move || {
let user = ctx
.store
.validate_credentials(auth.username(), auth.password())?;
let user_agent = user_agent.map(|header| header.to_string());
let session = ctx.store.create_session(&user, user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()?;
Ok(jar.add(
Cookie::build((SESSION_ID_COOKIE, session.id.to_string()))
.secure(false)
.same_site(cookie::SameSite::Strict)
.http_only(true)
.path("/api")
.max_age(Duration::days(365)),
))
}
async fn post_logout(
State(ctx): State<Context>,
Path(_username): Path<String>,
jar: CookieJar,
) -> AppResult<CookieJar> {
if let Some(session_id) = jar.get(SESSION_ID_COOKIE) {
let session_id: i64 = session_id
.value()
.parse()
.map_err(|_| AppError::BadRequest)?;
// TODO reintroduce username check
tokio::task::spawn_blocking(move || ctx.store.remove_session(session_id))
.await
.unwrap()?;
Ok(jar.remove(SESSION_ID_COOKIE))
} else {
Ok(jar)
}
}

View file

@ -0,0 +1,64 @@
use axum::{
extract::{Path, State},
middleware,
routing::{get, post},
Extension, Json, Router,
};
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models,
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route("/{username}", get(get_devices))
.route("/{username}/{id}", post(post_device))
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_api_middleware,
))
}
async fn get_devices(
State(ctx): State<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<gpodder::User>,
) -> AppResult<Json<Vec<models::Device>>> {
if username.format != Format::Json {
return Err(AppError::NotFound);
}
if *username != user.username {
return Err(AppError::BadRequest);
}
Ok(
tokio::task::spawn_blocking(move || ctx.store.devices_for_user(&user))
.await
.unwrap()
.map(|devices| Json(devices.into_iter().map(models::Device::from).collect()))?,
)
}
async fn post_device(
State(ctx): State<Context>,
Path((_username, id)): Path<(String, StringWithFormat)>,
Extension(user): Extension<gpodder::User>,
Json(patch): Json<models::DevicePatch>,
) -> AppResult<()> {
if id.format != Format::Json {
return Err(AppError::NotFound);
}
tokio::task::spawn_blocking(move || ctx.store.update_device_info(&user, &id, patch.into()))
.await
.unwrap()?;
Ok(())
}

View file

@ -0,0 +1,109 @@
use axum::{
extract::{Path, Query, State},
middleware,
routing::post,
Extension, Json, Router,
};
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models,
models::UpdatedUrlsResponse,
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route(
"/{username}",
post(post_episode_actions).get(get_episode_actions),
)
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_api_middleware,
))
}
async fn post_episode_actions(
State(ctx): State<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<gpodder::User>,
Json(actions): Json<Vec<models::EpisodeAction>>,
) -> AppResult<Json<UpdatedUrlsResponse>> {
if username.format != Format::Json {
return Err(AppError::NotFound);
}
if *username != user.username {
return Err(AppError::BadRequest);
}
Ok(tokio::task::spawn_blocking(move || {
ctx.store
.add_episode_actions(&user, actions.into_iter().map(Into::into).collect())
})
.await
.unwrap()
.map(|time_changed| {
Json(UpdatedUrlsResponse {
timestamp: time_changed.timestamp(),
update_urls: Vec::new(),
})
})?)
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct FilterQuery {
podcast: Option<String>,
device: Option<String>,
since: Option<i64>,
aggregated: bool,
}
#[derive(Serialize)]
struct EpisodeActionsResponse {
timestamp: i64,
actions: Vec<models::EpisodeAction>,
}
async fn get_episode_actions(
State(ctx): State<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<gpodder::User>,
Query(filter): Query<FilterQuery>,
) -> AppResult<Json<EpisodeActionsResponse>> {
if username.format != Format::Json {
return Err(AppError::NotFound);
}
if *username != user.username {
return Err(AppError::BadRequest);
}
let since = filter.since.and_then(|ts| DateTime::from_timestamp(ts, 0));
Ok(tokio::task::spawn_blocking(move || {
ctx.store.episode_actions_for_user(
&user,
since,
filter.podcast,
filter.device,
filter.aggregated,
)
})
.await
.unwrap()
.map(|(ts, actions)| {
Json(EpisodeActionsResponse {
timestamp: ts.timestamp(),
actions: actions.into_iter().map(Into::into).collect(),
})
})?)
}

View file

@ -0,0 +1,18 @@
mod auth;
mod devices;
mod episodes;
mod subscriptions;
mod sync;
use axum::Router;
use crate::server::Context;
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.nest("/auth", auth::router())
.nest("/devices", devices::router(ctx.clone()))
.nest("/subscriptions", subscriptions::router(ctx.clone()))
.nest("/episodes", episodes::router(ctx.clone()))
.nest("/sync-devices", sync::router(ctx.clone()))
}

View file

@ -0,0 +1,93 @@
use axum::{
extract::{Path, Query, State},
middleware,
routing::post,
Extension, Json, Router,
};
use serde::Deserialize;
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse},
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route(
"/{username}/{id}",
post(post_subscription_changes).get(get_subscription_changes),
)
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_api_middleware,
))
}
pub async fn post_subscription_changes(
State(ctx): State<Context>,
Path((username, id)): Path<(String, StringWithFormat)>,
Extension(user): Extension<gpodder::User>,
Json(delta): Json<SubscriptionDelta>,
) -> AppResult<Json<UpdatedUrlsResponse>> {
if id.format != Format::Json {
return Err(AppError::NotFound);
}
if username != user.username {
return Err(AppError::BadRequest);
}
Ok(tokio::task::spawn_blocking(move || {
ctx.store
.update_subscriptions_for_device(&user, &id, delta.add, delta.remove)
})
.await
.unwrap()
.map(|time_changed| {
Json(UpdatedUrlsResponse {
timestamp: time_changed.timestamp(),
update_urls: Vec::new(),
})
})?)
}
#[derive(Deserialize)]
pub struct SinceQuery {
#[serde(default)]
since: i64,
}
pub async fn get_subscription_changes(
State(ctx): State<Context>,
Path((username, id)): Path<(String, StringWithFormat)>,
Extension(user): Extension<gpodder::User>,
Query(query): Query<SinceQuery>,
) -> AppResult<Json<SubscriptionDeltaResponse>> {
if id.format != Format::Json {
return Err(AppError::NotFound);
}
if username != user.username {
return Err(AppError::BadRequest);
}
let since = chrono::DateTime::from_timestamp(query.since, 0).ok_or(AppError::BadRequest)?;
Ok(tokio::task::spawn_blocking(move || {
ctx.store.subscription_updates_for_device(&user, &id, since)
})
.await
.unwrap()
.map(|(next_time_changed, add, remove)| {
Json(SubscriptionDeltaResponse {
add: add.into_iter().map(|s| s.url).collect(),
remove: remove.into_iter().map(|s| s.url).collect(),
timestamp: next_time_changed.timestamp(),
})
})?)
}

View file

@ -0,0 +1,91 @@
use axum::{
extract::{Path, State},
middleware,
routing::get,
Extension, Json, Router,
};
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SyncStatus, SyncStatusDelta},
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route(
"/{username}",
get(get_sync_status).post(post_sync_status_changes),
)
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_api_middleware,
))
}
pub async fn get_sync_status(
State(ctx): State<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<gpodder::User>,
) -> AppResult<Json<SyncStatus>> {
if username.format != Format::Json {
return Err(AppError::NotFound);
}
if *username != user.username {
return Err(AppError::BadRequest);
}
Ok(
tokio::task::spawn_blocking(move || ctx.store.devices_by_sync_group(&user))
.await
.unwrap()
.map(|(not_synchronized, synchronized)| {
Json(SyncStatus {
synchronized,
not_synchronized,
})
})?,
)
}
pub async fn post_sync_status_changes(
State(ctx): State<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<gpodder::User>,
Json(delta): Json<SyncStatusDelta>,
) -> AppResult<Json<SyncStatus>> {
if username.format != Format::Json {
return Err(AppError::NotFound);
}
if *username != user.username {
return Err(AppError::BadRequest);
}
Ok(tokio::task::spawn_blocking(move || {
ctx.store.update_device_sync_status(
&user,
delta
.synchronize
.iter()
.map(|v| v.iter().map(|s| s.as_ref()).collect())
.collect(),
delta.stop_synchronize.iter().map(|s| s.as_ref()).collect(),
)?;
ctx.store.devices_by_sync_group(&user)
})
.await
.unwrap()
.map(|(not_synchronized, synchronized)| {
Json(SyncStatus {
synchronized,
not_synchronized,
})
})?)
}

View file

@ -0,0 +1,65 @@
use std::ops::Deref;
use serde::{
de::{value::StrDeserializer, Visitor},
Deserialize,
};
#[derive(Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Format {
Json,
// OPML,
// Plaintext,
}
#[derive(Debug)]
pub struct StringWithFormat {
pub s: String,
pub format: Format,
}
impl Deref for StringWithFormat {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.s
}
}
impl<'de> Deserialize<'de> for StringWithFormat {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct StrVisitor;
impl Visitor<'_> for StrVisitor {
type Value = StringWithFormat;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str(
"`text.ext` format, with `ext` being one of `json`, `opml` or `plaintext`",
)
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if let Some((text, ext)) = v.rsplit_once('.') {
let format = Format::deserialize(StrDeserializer::new(ext))?;
Ok(StringWithFormat {
s: text.to_string(),
format,
})
} else {
Err(E::custom(format!("invalid format '{}'", v)))
}
}
}
deserializer.deserialize_str(StrVisitor)
}
}

View file

@ -0,0 +1,130 @@
mod advanced;
mod format;
mod models;
mod simple;
use axum::{
extract::{Request, State},
http::{header::WWW_AUTHENTICATE, HeaderName, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
RequestExt, Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization},
TypedHeader,
};
use tower_http::set_header::SetResponseHeaderLayer;
use crate::server::error::AppError;
use super::Context;
const SESSION_ID_COOKIE: &str = "sessionid";
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.merge(simple::router(ctx.clone()))
.nest("/api/2", advanced::router(ctx))
// https://gpoddernet.readthedocs.io/en/latest/api/reference/general.html#cors
// All endpoints should send this CORS header value so the endpoints can be used from web
// applications
.layer(SetResponseHeaderLayer::overriding(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
))
}
/// Middleware that can authenticate both with session cookies and basic auth. If basic auth is
/// used, no session is created. If authentication fails, the server returns a 401.
pub async fn auth_api_middleware(
State(ctx): State<Context>,
mut req: Request,
next: Next,
) -> Response {
// SAFETY: this extractor's error type is Infallible
let mut jar: CookieJar = req.extract_parts().await.unwrap();
let mut auth_user = None;
// First try to validate the session
if let Some(session_id) = jar
.get(SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
let ctx = ctx.clone();
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
Ok(session)
})
.await
.unwrap()
{
Ok(session) => {
auth_user = Some(session.user);
}
Err(gpodder::AuthErr::UnknownSession) => {
jar = jar.add(
Cookie::build((SESSION_ID_COOKIE, String::new()))
.max_age(cookie::time::Duration::ZERO),
);
}
Err(err) => {
return AppError::from(err).into_response();
}
};
}
// Only if the sessionid wasn't present or valid do we check the credentials.
if auth_user.is_none() {
if let Ok(auth) = req
.extract_parts::<TypedHeader<Authorization<Basic>>>()
.await
{
match tokio::task::spawn_blocking(move || {
ctx.store
.validate_credentials(auth.username(), auth.password())
})
.await
.unwrap()
.map_err(AppError::from)
{
Ok(user) => {
auth_user = Some(user);
}
Err(err) => {
return err.into_response();
}
}
}
}
if let Some(user) = auth_user {
req.extensions_mut().insert(user);
(jar, next.run(req).await).into_response()
} else {
let mut res = (jar, StatusCode::UNAUTHORIZED).into_response();
// This is what the gpodder.net service returns, and some clients seem to depend on it
res.headers_mut().insert(
WWW_AUTHENTICATE,
HeaderValue::from_static("Basic realm=\"\""),
);
res
}
}
impl From<gpodder::AuthErr> for AppError {
fn from(value: gpodder::AuthErr) -> Self {
match value {
gpodder::AuthErr::UnknownUser
| gpodder::AuthErr::UnknownSession
| gpodder::AuthErr::InvalidPassword => Self::Unauthorized,
gpodder::AuthErr::Other(err) => Self::Other(err),
}
}
}

View file

@ -0,0 +1,194 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Debug)]
pub struct SubscriptionDelta {
pub add: Vec<String>,
pub remove: Vec<String>,
}
#[derive(Serialize, Default)]
pub struct SubscriptionDeltaResponse {
pub add: Vec<String>,
pub remove: Vec<String>,
pub timestamp: i64,
}
#[derive(Serialize)]
pub struct UpdatedUrlsResponse {
pub timestamp: i64,
pub update_urls: Vec<(String, String)>,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DeviceType {
Desktop,
Laptop,
Mobile,
Server,
Other,
}
#[derive(Serialize)]
pub struct Device {
pub id: String,
pub caption: String,
pub r#type: DeviceType,
pub subscriptions: i64,
}
#[derive(Deserialize)]
pub struct DevicePatch {
pub caption: Option<String>,
pub r#type: Option<DeviceType>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "lowercase")]
#[serde(tag = "action")]
pub enum EpisodeActionType {
Download,
Play {
#[serde(default)]
started: Option<i32>,
position: i32,
#[serde(default)]
total: Option<i32>,
},
Delete,
New,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct EpisodeAction {
pub podcast: String,
pub episode: String,
pub timestamp: Option<NaiveDateTime>,
#[serde(default)]
pub device: Option<String>,
#[serde(flatten)]
pub action: EpisodeActionType,
}
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SyncStatus {
pub synchronized: Vec<Vec<String>>,
pub not_synchronized: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub struct SyncStatusDelta {
pub synchronize: Vec<Vec<String>>,
pub stop_synchronize: Vec<String>,
}
impl From<gpodder::DeviceType> for DeviceType {
fn from(value: gpodder::DeviceType) -> Self {
match value {
gpodder::DeviceType::Other => Self::Other,
gpodder::DeviceType::Laptop => Self::Laptop,
gpodder::DeviceType::Mobile => Self::Mobile,
gpodder::DeviceType::Server => Self::Server,
gpodder::DeviceType::Desktop => Self::Desktop,
}
}
}
impl From<DeviceType> for gpodder::DeviceType {
fn from(value: DeviceType) -> Self {
match value {
DeviceType::Other => gpodder::DeviceType::Other,
DeviceType::Laptop => gpodder::DeviceType::Laptop,
DeviceType::Mobile => gpodder::DeviceType::Mobile,
DeviceType::Server => gpodder::DeviceType::Server,
DeviceType::Desktop => gpodder::DeviceType::Desktop,
}
}
}
impl From<gpodder::Device> for Device {
fn from(value: gpodder::Device) -> Self {
Self {
id: value.id,
caption: value.caption,
r#type: value.r#type.into(),
subscriptions: value.subscriptions,
}
}
}
impl From<DevicePatch> for gpodder::DevicePatch {
fn from(value: DevicePatch) -> Self {
Self {
caption: value.caption,
r#type: value.r#type.map(Into::into),
}
}
}
impl From<gpodder::EpisodeActionType> for EpisodeActionType {
fn from(value: gpodder::EpisodeActionType) -> Self {
match value {
gpodder::EpisodeActionType::New => Self::New,
gpodder::EpisodeActionType::Delete => Self::Delete,
gpodder::EpisodeActionType::Download => Self::Download,
gpodder::EpisodeActionType::Play {
started,
position,
total,
} => Self::Play {
started,
position,
total,
},
}
}
}
impl From<EpisodeActionType> for gpodder::EpisodeActionType {
fn from(value: EpisodeActionType) -> Self {
match value {
EpisodeActionType::New => gpodder::EpisodeActionType::New,
EpisodeActionType::Delete => gpodder::EpisodeActionType::Delete,
EpisodeActionType::Download => gpodder::EpisodeActionType::Download,
EpisodeActionType::Play {
started,
position,
total,
} => gpodder::EpisodeActionType::Play {
started,
position,
total,
},
}
}
}
impl From<gpodder::EpisodeAction> for EpisodeAction {
fn from(value: gpodder::EpisodeAction) -> Self {
Self {
podcast: value.podcast,
episode: value.episode,
timestamp: value.timestamp.map(|ts| ts.naive_utc()),
device: value.device,
action: value.action.into(),
}
}
}
impl From<EpisodeAction> for gpodder::EpisodeAction {
fn from(value: EpisodeAction) -> Self {
Self {
podcast: value.podcast,
episode: value.episode,
// TODO remove this unwrap
timestamp: value.timestamp.map(|ts| ts.and_utc()),
device: value.device,
action: value.action.into(),
time_changed: DateTime::<Utc>::MIN_UTC,
}
}
}

View file

@ -0,0 +1,9 @@
mod subscriptions;
use axum::Router;
use crate::server::Context;
pub fn router(ctx: Context) -> Router<Context> {
Router::new().nest("/subscriptions", subscriptions::router(ctx))
}

View file

@ -0,0 +1,77 @@
use axum::{
extract::{Path, State},
middleware,
routing::get,
Extension, Json, Router,
};
use crate::server::{
error::{AppError, AppResult},
gpodder::{auth_api_middleware, format::StringWithFormat},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route(
"/{username}/{id}",
get(get_device_subscriptions).put(put_device_subscriptions),
)
.route("/{username}", get(get_user_subscriptions))
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_api_middleware,
))
}
pub async fn get_device_subscriptions(
State(ctx): State<Context>,
Path((username, id)): Path<(String, StringWithFormat)>,
Extension(user): Extension<gpodder::User>,
) -> AppResult<Json<Vec<String>>> {
if username != user.username {
return Err(AppError::BadRequest);
}
Ok(
tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_device(&user, &id))
.await
.unwrap()
.map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?,
)
}
pub async fn get_user_subscriptions(
State(ctx): State<Context>,
Path(username): Path<StringWithFormat>,
Extension(user): Extension<gpodder::User>,
) -> AppResult<Json<Vec<String>>> {
if *username != user.username {
return Err(AppError::BadRequest);
}
Ok(
tokio::task::spawn_blocking(move || ctx.store.subscriptions_for_user(&user))
.await
.unwrap()
.map(|subs| Json(subs.into_iter().map(|s| s.url).collect()))?,
)
}
pub async fn put_device_subscriptions(
State(ctx): State<Context>,
Path((username, id)): Path<(String, StringWithFormat)>,
Extension(user): Extension<gpodder::User>,
Json(urls): Json<Vec<String>>,
) -> AppResult<()> {
if *username != user.username {
return Err(AppError::BadRequest);
}
Ok(tokio::task::spawn_blocking(move || {
ctx.store.set_subscriptions_for_device(&user, &id, urls)
})
.await
.unwrap()
.map(|_| ())?)
}

82
server/src/server/mod.rs Normal file
View file

@ -0,0 +1,82 @@
mod error;
mod gpodder;
mod r#static;
mod web;
use std::sync::Arc;
use axum::{
body::Body,
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Router,
};
use http_body_util::BodyExt;
use tower_http::trace::TraceLayer;
#[derive(Clone)]
pub struct Context {
pub store: ::gpodder::GpodderRepository,
pub tera: Arc<tera::Tera>,
}
pub fn app(ctx: Context) -> Router {
Router::new()
.merge(gpodder::router(ctx.clone()))
.merge(web::router(ctx.clone()))
.nest("/static", r#static::router())
.layer(axum::middleware::from_fn(header_logger))
// .layer(axum::middleware::from_fn(body_logger))
.layer(TraceLayer::new_for_http())
.with_state(ctx)
}
async fn header_logger(request: Request, next: Next) -> Response {
tracing::debug!("request headers = {:?}", request.headers());
let res = next.run(request).await;
tracing::debug!("response headers = {:?}", res.headers());
res
}
async fn _body_logger(request: Request, next: Next) -> Response {
let (parts, body) = request.into_parts();
let bytes = match body
.collect()
.await
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())
{
Ok(res) => res.to_bytes(),
Err(err) => {
return err;
}
};
tracing::debug!("request body = {:?}", String::from_utf8(bytes.to_vec()));
let res = next
.run(Request::from_parts(parts, Body::from(bytes)))
.await;
let (parts, body) = res.into_parts();
let bytes = match body
.collect()
.await
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response())
{
Ok(res) => res.to_bytes(),
Err(err) => {
return err;
}
};
tracing::debug!("response body = {:?}", String::from_utf8(bytes.to_vec()));
Response::from_parts(parts, Body::from(bytes))
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,34 @@
use std::io::Cursor;
use axum::{routing::get, Router};
use axum_extra::{headers::Range, TypedHeader};
use axum_range::{KnownSize, Ranged};
use super::Context;
const HTMX: &str = include_str!("./htmx_2.0.4.min.js");
const PICOCSS: &str = include_str!("./pico_2.1.1.classless.jade.min.css");
type RangedResponse = Ranged<KnownSize<Cursor<&'static str>>>;
pub fn router() -> Router<Context> {
Router::new()
.route("/htmx_2.0.4.min.js", get(get_htmx))
.route("/pico_2.1.1.classless.jade.min.css", get(get_picocss))
}
#[inline(always)]
fn serve_static(data: &'static str, range: Option<Range>) -> RangedResponse {
let cursor = Cursor::new(data);
let body = KnownSize::sized(cursor, data.len() as u64);
Ranged::new(range, body)
}
async fn get_htmx(range: Option<TypedHeader<Range>>) -> RangedResponse {
serve_static(HTMX, range.map(|TypedHeader(range)| range))
}
async fn get_picocss(range: Option<TypedHeader<Range>>) -> RangedResponse {
serve_static(PICOCSS, range.map(|TypedHeader(range)| range))
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,142 @@
use axum::{
extract::{Request, State},
http::HeaderMap,
middleware::{self, Next},
response::{IntoResponse, Redirect, Response},
routing::get,
Form, RequestExt, Router,
};
use axum_extra::{extract::CookieJar, headers::UserAgent, TypedHeader};
use cookie::{time::Duration, Cookie};
use gpodder::{AuthErr, Session};
use serde::Deserialize;
use crate::web::{Page, TemplateExt, TemplateResponse, View};
use super::{
error::{AppError, AppResult},
Context,
};
const SESSION_ID_COOKIE: &str = "sessionid";
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route("/", get(get_index))
.layer(middleware::from_fn_with_state(
ctx.clone(),
auth_web_middleware,
))
// Login route needs to be handled differently, as the middleware turns it into a redirect
// loop
.route("/login", get(get_login).post(post_login))
}
async fn get_index(State(ctx): State<Context>, headers: HeaderMap) -> TemplateResponse<Page<View>> {
View::Index.page(&headers).response(&ctx.tera)
}
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
if extract_session(ctx.clone(), &jar)
.await
.ok()
.flatten()
.is_some()
{
Redirect::to("/").into_response()
} else {
View::Login
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
#[derive(Deserialize)]
struct LoginForm {
username: String,
password: String,
}
async fn post_login(
State(ctx): State<Context>,
user_agent: Option<TypedHeader<UserAgent>>,
jar: CookieJar,
Form(login): Form<LoginForm>,
) -> AppResult<Response> {
match tokio::task::spawn_blocking(move || {
let user = ctx
.store
.validate_credentials(&login.username, &login.password)?;
let user_agent = user_agent.map(|header| header.to_string());
let session = ctx.store.create_session(&user, user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()
{
Ok(session) => Ok((
jar.add(
Cookie::build((SESSION_ID_COOKIE, session.id.to_string()))
.secure(true)
.same_site(cookie::SameSite::Lax)
.http_only(true)
.path("/")
.max_age(Duration::days(365)),
),
Redirect::to("/"),
)
.into_response()),
Err(AuthErr::UnknownUser | AuthErr::InvalidPassword) => {
todo!("serve login form with error messages")
}
Err(err) => Err(AppError::from(err)),
}
}
async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(SESSION_ID_COOKIE)
.and_then(|c| c.value().parse::<i64>().ok())
{
match tokio::task::spawn_blocking(move || {
let session = ctx.store.get_session(session_id)?;
ctx.store.refresh_session(&session)?;
Ok(session)
})
.await
.unwrap()
{
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Err(err) => Err(AppError::from(err)),
}
} else {
Ok(None)
}
}
/// Middleware that authenticates the current user via the session token. If the credentials are
/// invalid, the user is redirected to the login page.
pub async fn auth_web_middleware(
State(ctx): State<Context>,
mut req: Request,
next: Next,
) -> Response {
// SAFETY: this extractor's error type is Infallible
let jar: CookieJar = req.extract_parts().await.unwrap();
let redirect = Redirect::to("/login");
match extract_session(ctx, &jar).await {
Ok(Some(session)) => {
req.extensions_mut().insert(session.user);
next.run(req).await
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
}
}

85
server/src/web/mod.rs Normal file
View file

@ -0,0 +1,85 @@
mod page;
mod view;
use std::sync::Arc;
use axum::{
body::Body,
http::{HeaderMap, Response, StatusCode},
response::{Html, IntoResponse},
};
pub use page::Page;
pub use view::View;
const BASE_TEMPLATE: &str = "base.html";
/// Trait defining shared methods for working with typed Tera templates
pub trait Template {
/// Returns the name or path used to identify the template in the Tera struct
fn template(&self) -> &'static str;
/// Render the template using the given Tera instance.
///
/// Templates are expected to manage their own context requirements if needed.
fn render(&self, tera: &tera::Tera) -> tera::Result<String>;
}
/// Useful additional functions on sized Template implementors
pub trait TemplateExt: Sized + Template {
fn response(self, tera: &Arc<tera::Tera>) -> TemplateResponse<Self> {
TemplateResponse::new(tera, self)
}
fn page(self, headers: &HeaderMap) -> Page<Self> {
Page::new(self).headers(headers)
}
}
impl<T: Sized + Template> TemplateExt for T {}
/// A specific instance of a template. This type can be used as a return type from Axum handlers.
pub struct TemplateResponse<T> {
tera: Arc<tera::Tera>,
template: T,
}
impl<T> TemplateResponse<T> {
pub fn new(tera: &Arc<tera::Tera>, template: T) -> Self {
Self {
tera: Arc::clone(tera),
template,
}
}
}
impl<T: Template> IntoResponse for TemplateResponse<T> {
fn into_response(self) -> Response<Body> {
match self.template.render(&self.tera) {
Ok(s) => Html(s).into_response(),
Err(err) => {
tracing::error!("tera template failed: {err}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
}
pub fn initialize_tera() -> tera::Result<tera::Tera> {
let mut tera = tera::Tera::default();
tera.add_raw_templates([
(BASE_TEMPLATE, include_str!("templates/base.html")),
(
View::Index.template(),
include_str!("templates/views/index.html"),
),
(
View::Login.template(),
include_str!("templates/views/login.html"),
),
])?;
Ok(tera)
}

53
server/src/web/page.rs Normal file
View file

@ -0,0 +1,53 @@
use axum::http::{HeaderMap, HeaderValue};
use super::Template;
const HX_REQUEST_HEADER: &str = "HX-Request";
const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
/// Overarching template type that conditionally wraps its inner template with the base template if
/// required, as derived from the request headers
pub struct Page<T> {
template: T,
wrap_with_base: bool,
}
impl<T: Template> Template for Page<T> {
fn template(&self) -> &'static str {
self.template.template()
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
let inner = self.template.render(tera)?;
if self.wrap_with_base {
let mut ctx = tera::Context::new();
ctx.insert("inner", &inner);
tera.render(super::BASE_TEMPLATE, &ctx)
} else {
Ok(inner)
}
}
}
impl<T> Page<T> {
pub fn new(template: T) -> Self {
Self {
template,
wrap_with_base: false,
}
}
pub fn headers(mut self, headers: &HeaderMap) -> Self {
let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some();
let is_hist_restore_req = headers
.get(HX_HISTORY_RESTORE_HEADER)
.map(|val| val == HeaderValue::from_static("true"))
.unwrap_or(false);
self.wrap_with_base = !is_htmx_req || is_hist_restore_req;
self
}
}

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script src="/static/htmx_2.0.4.min.js" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
<link rel="stylesheet" href="/static/pico_2.1.1.classless.jade.min.css" />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<style type="text/css">
a:hover {
cursor:pointer;
}
</style>
</head>
<body>
<main>
<nav>
</nav>
<article id="inner">
{{ inner | safe }}
</article>
</main>
</body>
</html>

View file

@ -0,0 +1,5 @@
<h1>Otter</h1>
Otter is a self-hostable Gpodder implementation.
If you're seeing this, you're logged in.

View file

@ -0,0 +1,9 @@
<article>
<form hx-post="/login" hx-target="#inner">
<label for="username">Username:</label>
<input type="text" id="username" name="username">
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<input type="submit" value="Login">
</form>
</article>

19
server/src/web/view.rs Normal file
View file

@ -0,0 +1,19 @@
use super::Template;
pub enum View {
Index,
Login,
}
impl Template for View {
fn template(&self) -> &'static str {
match self {
Self::Index => "views/index.html",
Self::Login => "views/login.html",
}
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
tera.render(self.template(), &tera::Context::new())
}
}