Compare commits

...

4 Commits

11 changed files with 242 additions and 44 deletions

View File

@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter)
* CLI command to add new users
* Added public sign-up page (disabled by default)
## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1)

View File

@ -1,6 +1,8 @@
data_dir = "./data"
log_level = "debug"
allow_public_signup = true
[net]
type = "tcp"
domain = "127.0.0.1"

View File

@ -23,6 +23,7 @@ pub fn serve(config: &crate::config::Config) -> Result<(), CliError> {
let ctx = server::Context {
store,
tera: Arc::new(tera),
config: config.clone(),
};
let app = server::app(ctx.clone());

View File

@ -23,7 +23,7 @@ impl From<LogLevel> for tracing::Level {
}
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
#[serde(tag = "type")]
pub enum NetConfig {
@ -31,12 +31,13 @@ pub enum NetConfig {
Unix { path: PathBuf },
}
#[derive(Serialize, Deserialize)]
#[derive(Serialize, Deserialize, Clone)]
pub struct Config {
pub net: NetConfig,
pub data_dir: PathBuf,
pub session_cleanup_interval: u64,
pub log_level: LogLevel,
pub allow_public_signup: bool,
}
impl Default for Config {
@ -50,6 +51,7 @@ impl Default for Config {
// Once per day
session_cleanup_interval: 60 * 60 * 24,
log_level: LogLevel::Warn,
allow_public_signup: false,
}
}
}

View File

@ -20,6 +20,7 @@ use tower_http::trace::TraceLayer;
pub struct Context {
pub store: ::gpodder::GpodderRepository,
pub tera: Arc<tera::Tera>,
pub config: crate::config::Config,
}
pub fn app(ctx: Context) -> Router {

View File

@ -21,7 +21,7 @@ use crate::{
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
let mut router = Router::new()
// .layer(middleware::from_fn_with_state(
// ctx.clone(),
// auth_web_middleware,
@ -29,7 +29,60 @@ pub fn router(ctx: Context) -> Router<Context> {
// Login route needs to be handled differently, as the middleware turns it into a redirect
// loop
.route("/login", get(get_login).post(post_login))
.route("/logout", post(post_logout))
.route("/logout", post(post_logout));
// If public signups aren't allowed, we don't even register the route to prevent any dumb
// security mistakes
if ctx.config.allow_public_signup {
router = router.route("/signup", get(get_signup).post(post_signup))
}
router
}
/// 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);
next.run(req).await
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
}
}
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(super::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)
}
}
async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJar) -> Response {
@ -41,10 +94,12 @@ async fn get_login(State(ctx): State<Context>, headers: HeaderMap, jar: CookieJa
{
Redirect::to("/").into_response()
} else {
View::Login
.page(&headers)
.response(&ctx.tera)
.into_response()
View::Login {
signup_note: ctx.config.allow_public_signup,
}
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
@ -103,47 +158,106 @@ async fn post_logout(State(ctx): State<Context>, jar: CookieJar) -> AppResult<im
Ok(([("HX-Redirect", "/")], jar.remove(super::SESSION_ID_COOKIE)))
}
pub async fn extract_session(ctx: Context, jar: &CookieJar) -> AppResult<Option<Session>> {
if let Some(session_id) = jar
.get(super::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)?;
#[derive(Deserialize)]
struct SignupForm {
username: String,
password: String,
password_confirm: String,
}
Ok(session)
struct SignupValidation {
username_available: bool,
passwords_match: bool,
}
impl SignupForm {
fn validate(&self, ctx: &Context) -> AppResult<SignupValidation> {
let username_available = match ctx.store.get_user(&self.username) {
Ok(_) => false,
Err(AuthErr::UnknownUser) => true,
Err(err) => {
return Err(err.into());
}
};
let passwords_match = self.password == self.password_confirm;
Ok(SignupValidation {
username_available,
passwords_match,
})
}
}
impl SignupValidation {
pub fn valid(&self) -> bool {
self.username_available && self.passwords_match
}
}
async fn get_signup(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::Signup {
username: None,
username_available: true,
passwords_match: true,
}
.page(&headers)
.response(&ctx.tera)
.into_response()
}
}
async fn post_signup(
State(ctx): State<Context>,
jar: CookieJar,
headers: HeaderMap,
user_agent: Option<TypedHeader<UserAgent>>,
Form(signup): Form<SignupForm>,
) -> AppResult<Response> {
let validation = signup.validate(&ctx)?;
if validation.valid() {
// Create the user and log them in
match tokio::task::spawn_blocking(move || {
let user = ctx.store.create_user(&signup.username, &signup.password)?;
let user_agent = user_agent.map(|header| header.to_string());
let session = ctx.store.user(&user).create_session(user_agent)?;
Ok::<_, AuthErr>(session)
})
.await
.unwrap()
{
Ok(session) => Ok(Some(session)),
Err(gpodder::AuthErr::UnknownSession) => Ok(None),
Ok(session) => Ok((
// Redirect forces htmx to reload the full page, refreshing the navbar
[("HX-Redirect", "/")],
(jar.add(
Cookie::build((super::SESSION_ID_COOKIE, session.id.to_string()))
.secure(true)
.same_site(cookie::SameSite::Lax)
.http_only(true)
.path("/")
.max_age(Duration::days(365)),
)),
)
.into_response()),
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);
next.run(req).await
Ok(View::Signup {
username: Some(signup.username),
username_available: validation.username_available,
passwords_match: validation.passwords_match,
}
Ok(None) => redirect.into_response(),
Err(err) => err.into_response(),
.page(&headers)
.response(&ctx.tera)
.into_response())
}
}

View File

@ -78,7 +78,7 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
include_str!("templates/views/index.html"),
),
(
View::Login.template(),
View::Login { signup_note: false }.template(),
include_str!("templates/views/login.html"),
),
(
@ -89,6 +89,15 @@ pub fn initialize_tera() -> tera::Result<tera::Tera> {
View::Users(Vec::new(), 0, None).template(),
include_str!("templates/views/users.html"),
),
(
View::Signup {
username: None,
username_available: true,
passwords_match: true,
}
.template(),
include_str!("templates/views/signup.html"),
),
])?;
Ok(tera)

View File

@ -6,4 +6,8 @@
<input type="password" id="password" name="password">
<input type="submit" value="Login">
</form>
{% if signup_note %}
<p>Don't have an account yet? <a hx-get="/signup" hx-target="#inner" hx-push-url="/signup">Create one here</a>!</p>
{% endif %}
</article>

View File

@ -0,0 +1,34 @@
<article>
<form hx-post hx-target="#inner">
<label for="username">Username:</label>
<input
type="text"
id="username"
name="username"
value="{{ username }}"
{% if not username_available %}
aria-invalid="true"
aria-describedby="username-helper"
{% endif %}
>
{% if not username_available %}
<small id="username-helper">Username not available</small>
{% endif %}
<label for="password">Password:</label>
<input type="password" id="password" name="password">
<label for="password_confirm">Confirm password:</label>
<input
type="password"
id="password_confirm"
name="password_confirm"
{% if not passwords_match %}
aria-invalid="true"
aria-describedby="password-helper"
{% endif %}
>
{% if not passwords_match %}
<small id="password-helper">Passwords don't match</small>
{% endif %}
<input type="submit" value="Sign Up">
</form>
</article>

View File

@ -12,11 +12,21 @@
<table id="users">
<thead>
<th>Username</th>
<th>Action</th>
</thead>
<tbody>
{%- for user in users %}
<tr>
<th>{{ user.username }}</th>
<th>
{%- if user.id != current_user_id -%}
<a hx-delete="/users/{{ user.id }}"
hx-target="closest tr"
>Remove</a>
{%- else -%}
Current user
{%- endif -%}
</th>
</tr>
{% endfor -%}

View File

@ -5,9 +5,16 @@ use super::{Query, Template};
pub enum View {
Index,
Login,
Login {
signup_note: bool,
},
Sessions(Vec<gpodder::Session>, i64, Option<Query>),
Users(Vec<gpodder::User>, i64, Option<Query>),
Signup {
username: Option<String>,
username_available: bool,
passwords_match: bool,
},
}
#[derive(Serialize)]
@ -27,9 +34,10 @@ impl Template for View {
fn template(&self) -> &'static str {
match self {
Self::Index => "views/index.html",
Self::Login => "views/login.html",
Self::Login { .. } => "views/login.html",
Self::Sessions(..) => "views/sessions.html",
Self::Users(..) => "views/users.html",
Self::Signup { .. } => "views/signup.html",
}
}
@ -61,6 +69,18 @@ impl Template for View {
ctx.insert("next_page_query", &query.encode());
}
}
Self::Signup {
username,
username_available,
passwords_match,
} => {
ctx.insert("username", &username);
ctx.insert("username_available", &username_available);
ctx.insert("passwords_match", &passwords_match);
}
Self::Login { signup_note } => {
ctx.insert("signup_note", &signup_note);
}
_ => {}
};