refactor: move server to own package; set up workspace dependencies
This commit is contained in:
parent
279983c64c
commit
7abce21aee
34 changed files with 41 additions and 30 deletions
27
server/Cargo.toml
Normal file
27
server/Cargo.toml
Normal 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
49
server/src/cli/db.rs
Normal 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
44
server/src/cli/gpo.rs
Normal 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
93
server/src/cli/mod.rs
Normal 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
62
server/src/cli/serve.rs
Normal 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
59
server/src/config.rs
Normal 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
34
server/src/main.rs
Normal 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())
|
||||
}
|
||||
71
server/src/server/error.rs
Normal file
71
server/src/server/error.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
server/src/server/gpodder/advanced/auth.rs
Normal file
106
server/src/server/gpodder/advanced/auth.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
64
server/src/server/gpodder/advanced/devices.rs
Normal file
64
server/src/server/gpodder/advanced/devices.rs
Normal 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(())
|
||||
}
|
||||
109
server/src/server/gpodder/advanced/episodes.rs
Normal file
109
server/src/server/gpodder/advanced/episodes.rs
Normal 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(),
|
||||
})
|
||||
})?)
|
||||
}
|
||||
18
server/src/server/gpodder/advanced/mod.rs
Normal file
18
server/src/server/gpodder/advanced/mod.rs
Normal 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()))
|
||||
}
|
||||
93
server/src/server/gpodder/advanced/subscriptions.rs
Normal file
93
server/src/server/gpodder/advanced/subscriptions.rs
Normal 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(),
|
||||
})
|
||||
})?)
|
||||
}
|
||||
91
server/src/server/gpodder/advanced/sync.rs
Normal file
91
server/src/server/gpodder/advanced/sync.rs
Normal 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,
|
||||
})
|
||||
})?)
|
||||
}
|
||||
65
server/src/server/gpodder/format.rs
Normal file
65
server/src/server/gpodder/format.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
130
server/src/server/gpodder/mod.rs
Normal file
130
server/src/server/gpodder/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
194
server/src/server/gpodder/models.rs
Normal file
194
server/src/server/gpodder/models.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
9
server/src/server/gpodder/simple/mod.rs
Normal file
9
server/src/server/gpodder/simple/mod.rs
Normal 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))
|
||||
}
|
||||
77
server/src/server/gpodder/simple/subscriptions.rs
Normal file
77
server/src/server/gpodder/simple/subscriptions.rs
Normal 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
82
server/src/server/mod.rs
Normal 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))
|
||||
}
|
||||
1
server/src/server/static/htmx_2.0.4.min.js
vendored
Normal file
1
server/src/server/static/htmx_2.0.4.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
server/src/server/static/mod.rs
Normal file
34
server/src/server/static/mod.rs
Normal 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))
|
||||
}
|
||||
4
server/src/server/static/pico_2.1.1.classless.jade.min.css
vendored
Normal file
4
server/src/server/static/pico_2.1.1.classless.jade.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
142
server/src/server/web/mod.rs
Normal file
142
server/src/server/web/mod.rs
Normal 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
85
server/src/web/mod.rs
Normal 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
53
server/src/web/page.rs
Normal 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
|
||||
}
|
||||
}
|
||||
24
server/src/web/templates/base.html
Normal file
24
server/src/web/templates/base.html
Normal 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>
|
||||
5
server/src/web/templates/views/index.html
Normal file
5
server/src/web/templates/views/index.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<h1>Otter</h1>
|
||||
|
||||
Otter is a self-hostable Gpodder implementation.
|
||||
|
||||
If you're seeing this, you're logged in.
|
||||
9
server/src/web/templates/views/login.html
Normal file
9
server/src/web/templates/views/login.html
Normal 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
19
server/src/web/view.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue