Compare commits

...

5 Commits

30 changed files with 834 additions and 20 deletions

388
Cargo.lock generated
View File

@ -213,6 +213,21 @@ dependencies = [
"syn",
]
[[package]]
name = "axum-range"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b09d24c2cfcf6596afc4b9d139ad62c53637c7e0f791ef8a25ce1cc431f73a"
dependencies = [
"axum",
"axum-extra",
"bytes",
"futures",
"http-body",
"pin-project",
"tokio",
]
[[package]]
name = "backtrace"
version = "0.3.74"
@ -264,6 +279,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.17.0"
@ -324,6 +349,28 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf",
"phf_codegen",
]
[[package]]
name = "ciborium"
version = "0.2.2"
@ -544,6 +591,12 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "deunicode"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d"
[[package]]
name = "diesel"
version = "2.2.7"
@ -655,6 +708,20 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@ -662,6 +729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -670,6 +738,18 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
@ -682,10 +762,15 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
@ -715,6 +800,30 @@ version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
name = "globset"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags",
"ignore",
"walkdir",
]
[[package]]
name = "gpodder"
version = "0.1.0"
@ -836,6 +945,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.6.0"
@ -900,6 +1018,22 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "ignore"
version = "0.4.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "2.7.1"
@ -970,6 +1104,12 @@ version = "0.2.170"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828"
[[package]]
name = "libm"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
[[package]]
name = "libsqlite3-sys"
version = "0.31.0"
@ -1108,6 +1248,7 @@ version = "0.1.0"
dependencies = [
"axum",
"axum-extra",
"axum-range",
"chrono",
"clap",
"cookie",
@ -1117,6 +1258,7 @@ dependencies = [
"http-body-util",
"rand",
"serde",
"tera",
"tokio",
"tower-http",
"tracing",
@ -1152,6 +1294,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "password-hash"
version = "0.5.0"
@ -1192,6 +1343,109 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "pest"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6"
dependencies = [
"memchr",
"thiserror",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_shared",
]
[[package]]
name = "phf_codegen"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pin-project-lite"
version = "0.2.16"
@ -1499,6 +1753,17 @@ dependencies = [
"digest",
]
[[package]]
name = "sha2"
version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@ -1523,6 +1788,31 @@ dependencies = [
"libc",
]
[[package]]
name = "siphasher"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
[[package]]
name = "slab"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]]
name = "smallvec"
version = "1.14.0"
@ -1568,6 +1858,48 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tera"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab9d851b45e865f178319da0abdbfe6acbc4328759ff18dafc3a41c16b4cd2ee"
dependencies = [
"chrono",
"chrono-tz",
"globwalk",
"humansize",
"lazy_static",
"percent-encoding",
"pest",
"pest_derive",
"rand",
"regex",
"serde",
"serde_json",
"slug",
"unic-segment",
]
[[package]]
name = "thiserror"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.8"
@ -1790,6 +2122,12 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uncased"
version = "0.9.10"
@ -1799,6 +2137,56 @@ dependencies = [
"version_check",
]
[[package]]
name = "unic-char-property"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221"
dependencies = [
"unic-char-range",
]
[[package]]
name = "unic-char-range"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc"
[[package]]
name = "unic-common"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc"
[[package]]
name = "unic-segment"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23"
dependencies = [
"unic-ucd-segment",
]
[[package]]
name = "unic-ucd-segment"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700"
dependencies = [
"unic-char-property",
"unic-char-range",
"unic-ucd-version",
]
[[package]]
name = "unic-ucd-version"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4"
dependencies = [
"unic-common",
]
[[package]]
name = "unicode-ident"
version = "1.0.17"

View File

@ -26,3 +26,5 @@ tokio = { version = "1.43.0", features = ["full"] }
tower-http = { version = "0.6.2", features = ["set-header", "trace"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
tera = "1.20.0"
axum-range = "0.5.0"

View File

@ -56,6 +56,7 @@ pub struct EpisodeAction {
pub struct Session {
pub id: i64,
pub last_seen: DateTime<Utc>,
pub user_agent: Option<String>,
pub user: User,
}

View File

@ -71,11 +71,16 @@ impl GpodderRepository {
}
}
pub fn create_session(&self, user: &models::User) -> Result<models::Session, AuthErr> {
pub fn create_session(
&self,
user: &models::User,
user_agent: Option<String>,
) -> Result<models::Session, AuthErr> {
let session = models::Session {
id: rand::thread_rng().gen(),
last_seen: Utc::now(),
user: user.clone(),
user_agent,
};
self.store.insert_session(&session)?;

View File

@ -0,0 +1,2 @@
alter table sessions
drop column user_agent;

View File

@ -0,0 +1,2 @@
alter table sessions
add column user_agent text;

View File

@ -10,6 +10,7 @@ pub struct Session {
pub id: i64,
pub user_id: i64,
pub last_seen: i64,
pub user_agent: Option<String>,
}
impl Session {

View File

@ -59,6 +59,7 @@ impl gpodder::AuthStore for SqliteRepository {
id: session.id,
last_seen: DateTime::from_timestamp(session.last_seen, 0).unwrap(),
user: user.into(),
user_agent: session.user_agent.clone(),
})),
Ok(None) => Ok(None),
Err(err) => Err(DbError::from(err).into()),
@ -79,6 +80,7 @@ impl gpodder::AuthStore for SqliteRepository {
id: session.id,
user_id: session.user.id,
last_seen: session.last_seen.timestamp(),
user_agent: session.user_agent.clone(),
}
.insert_into(sessions::table)
.execute(&mut self.pool.get().map_err(DbError::from)?)

View File

@ -43,6 +43,7 @@ diesel::table! {
id -> BigInt,
user_id -> BigInt,
last_seen -> BigInt,
user_agent -> Nullable<Text>,
}
}

View File

@ -1,6 +1,4 @@
use std::time::Duration;
use tracing_subscriber::util::SubscriberInitExt;
use std::{sync::Arc, time::Duration};
use crate::server;
@ -11,12 +9,17 @@ pub fn serve(config: &crate::config::Config) -> u8 {
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 };
let ctx = server::Context {
store,
tera: Arc::new(tera),
};
let app = server::app(ctx.clone());
let rt = tokio::runtime::Builder::new_multi_thread()

View File

@ -1,6 +1,7 @@
mod cli;
mod config;
mod server;
mod web;
use clap::Parser;

View File

@ -10,6 +10,7 @@ pub type AppResult<T> = Result<T, AppError>;
pub enum AppError {
// Db(db::DbError),
IO(std::io::Error),
Tera(tera::Error),
Other(Box<dyn std::error::Error + 'static + Send + Sync>),
BadRequest,
Unauthorized,
@ -21,6 +22,7 @@ impl fmt::Display for AppError {
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"),
@ -33,6 +35,7 @@ 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,
@ -46,6 +49,12 @@ impl From<std::io::Error> for AppError {
}
}
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 {

View File

@ -5,10 +5,11 @@ use axum::{
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization},
headers::{authorization::Basic, Authorization, UserAgent},
TypedHeader,
};
use cookie::time::Duration;
use gpodder::AuthErr;
use crate::server::{
error::{AppError, AppResult},
@ -27,6 +28,7 @@ async fn post_login(
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() {
@ -62,7 +64,11 @@ async fn post_login(
let user = ctx
.store
.validate_credentials(auth.username(), auth.password())?;
ctx.store.create_session(&user)
let user_agent = user_agent.map(|header| header.to_string());
let session = ctx.store.create_session(&user, user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()?;

View File

@ -8,7 +8,7 @@ use axum::{
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_middleware,
auth_api_middleware,
format::{Format, StringWithFormat},
models,
},
@ -19,7 +19,7 @@ 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_middleware))
.layer(middleware::from_fn_with_state(ctx.clone(), auth_api_middleware))
}
async fn get_devices(

View File

@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_middleware,
auth_api_middleware,
format::{Format, StringWithFormat},
models,
models::UpdatedUrlsResponse,
@ -24,7 +24,7 @@ pub fn router(ctx: Context) -> Router<Context> {
"/{username}",
post(post_episode_actions).get(get_episode_actions),
)
.layer(middleware::from_fn_with_state(ctx.clone(), auth_middleware))
.layer(middleware::from_fn_with_state(ctx.clone(), auth_api_middleware))
}
async fn post_episode_actions(

View File

@ -9,7 +9,7 @@ use serde::Deserialize;
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_middleware,
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse},
},
@ -22,7 +22,7 @@ pub fn router(ctx: Context) -> Router<Context> {
"/{username}/{id}",
post(post_subscription_changes).get(get_subscription_changes),
)
.layer(middleware::from_fn_with_state(ctx.clone(), auth_middleware))
.layer(middleware::from_fn_with_state(ctx.clone(), auth_api_middleware))
}
pub async fn post_subscription_changes(

View File

@ -8,7 +8,7 @@ use axum::{
use crate::server::{
error::{AppError, AppResult},
gpodder::{
auth_middleware,
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SyncStatus, SyncStatusDelta},
},
@ -21,7 +21,7 @@ pub fn router(ctx: Context) -> Router<Context> {
"/{username}",
get(get_sync_status).post(post_sync_status_changes),
)
.layer(middleware::from_fn_with_state(ctx.clone(), auth_middleware))
.layer(middleware::from_fn_with_state(ctx.clone(), auth_api_middleware))
}
pub async fn get_sync_status(

View File

@ -36,8 +36,13 @@ pub fn router(ctx: Context) -> Router<Context> {
))
}
/// This middleware accepts
pub async fn auth_middleware(State(ctx): State<Context>, mut req: Request, next: Next) -> Response {
/// 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;

View File

@ -7,7 +7,7 @@ use axum::{
use crate::server::{
error::{AppError, AppResult},
gpodder::{auth_middleware, format::StringWithFormat},
gpodder::{auth_api_middleware, format::StringWithFormat},
Context,
};
@ -18,7 +18,7 @@ pub fn router(ctx: Context) -> Router<Context> {
get(get_device_subscriptions).put(put_device_subscriptions),
)
.route("/{username}", get(get_user_subscriptions))
.layer(middleware::from_fn_with_state(ctx.clone(), auth_middleware))
.layer(middleware::from_fn_with_state(ctx.clone(), auth_api_middleware))
}
pub async fn get_device_subscriptions(

View File

@ -1,12 +1,17 @@
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},
response::{IntoResponse, Redirect, Response},
routing::get,
Router,
};
use http_body_util::BodyExt;
@ -15,11 +20,15 @@ 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()))
.nest("/static", r#static::router())
.nest("/_", web::router(ctx.clone()))
.route("/", get(|| async { Redirect::to("/_") }))
.layer(axum::middleware::from_fn(header_logger))
.layer(axum::middleware::from_fn(body_logger))
.layer(TraceLayer::new_for_http())

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,143 @@
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>>,
_headers: HeaderMap,
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
src/web/mod.rs 100644
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
src/web/page.rs 100644
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
src/web/view.rs 100644
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())
}
}