Compare commits

...

4 Commits

8 changed files with 110 additions and 30 deletions

View File

@ -7,8 +7,13 @@ 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.3.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.3.0)
* Public sign-up page (disabled by default)
* Private sign-up links
* New CLI commands
* Add users
* Generate signup links
## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1)

6
Cargo.lock generated
View File

@ -821,7 +821,7 @@ dependencies = [
[[package]]
name = "gpodder"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"argon2",
"chrono",
@ -830,7 +830,7 @@ dependencies = [
[[package]]
name = "gpodder_sqlite"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"chrono",
"criterion",
@ -1246,7 +1246,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "otter"
version = "0.2.1"
version = "0.3.0"
dependencies = [
"axum",
"axum-extra",

View File

@ -7,7 +7,7 @@ members = [
]
[workspace.package]
version = "0.2.1"
version = "0.3.0"
edition = "2024"
[workspace.dependencies]

View File

@ -20,6 +20,10 @@ the TOML file.
* `log_level` (`OTTER_LOG_LEVEL`): how verbose the logging should be; one of
`debug`, `info`, `warn` or `error`
* Default: `warn`
* `allow_public_signups` (`OTTER_ALLOW_PUBLIC_SIGNUPS`): whether public signups
are allowed. This enables the `/signup` route and adds a notice to sign up at
the bottom of the login page.
* Default: `false`
## Network (`net`)

View File

@ -1,6 +1,3 @@
use chrono::Utc;
use rand::Rng;
use crate::{AuthErr, Page, models};
/// Admin view of the repository, providing methods only allowed by admins
@ -17,21 +14,4 @@ impl<'a> AdminRepository<'a> {
) -> Result<Vec<models::User>, AuthErr> {
self.store.paginated_users(page, filter)
}
/// Generate a new unique signup link ID
pub fn generate_signup_link(&self) -> Result<models::SignupLink, AuthErr> {
let link = models::SignupLink {
id: rand::thread_rng().r#gen(),
time_created: Utc::now(),
};
self.store.insert_signup_link(&link)?;
Ok(link)
}
/// Remove the signup link with the given ID, if it exists
pub fn remove_signup_link(&self, id: i64) -> Result<bool, AuthErr> {
self.store.remove_signup_link(id)
}
}

View File

@ -5,10 +5,10 @@ use std::sync::Arc;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString};
use chrono::{TimeDelta, Utc};
use rand::rngs::OsRng;
use rand::{Rng, rngs::OsRng};
use crate::{
models,
SignupLink, models,
store::{AuthErr, GpodderStore},
};
@ -117,4 +117,25 @@ impl GpodderRepository {
self.store.remove_old_sessions(min_last_seen)
}
pub fn get_signup_link(&self, id: i64) -> Result<Option<SignupLink>, AuthErr> {
self.store.get_signup_link(id)
}
/// Remove the signup link with the given ID, if it exists
pub fn remove_signup_link(&self, id: i64) -> Result<bool, AuthErr> {
self.store.remove_signup_link(id)
}
/// Generate a new unique signup link ID
pub fn generate_signup_link(&self) -> Result<models::SignupLink, AuthErr> {
let link = models::SignupLink {
id: rand::thread_rng().r#gen(),
time_created: Utc::now(),
};
self.store.insert_signup_link(&link)?;
Ok(link)
}
}

View File

@ -18,7 +18,10 @@ pub enum Command {
#[derive(Subcommand)]
pub enum UserCommand {
/// Add a new user
Add { username: String, password: String },
/// Generate a signup link ID
GenerateSignupLink,
}
impl Command {
@ -59,6 +62,11 @@ impl UserCommand {
Self::Add { username, password } => {
store.create_user(username, password)?;
}
Self::GenerateSignupLink => {
let link = store.generate_signup_link()?;
println!("/signup/{}", link.id);
}
}
Ok(())

View File

@ -1,7 +1,7 @@
use axum::{
Form, RequestExt, Router,
extract::{Request, State},
http::HeaderMap,
extract::{Path, Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
@ -35,6 +35,8 @@ pub fn router(ctx: Context) -> Router<Context> {
// security mistakes
if ctx.config.allow_public_signup {
router = router.route("/signup", get(get_signup).post(post_signup))
} else {
router = router.route("/signup/{id}", get(get_signup_link).post(post_signup_link));
}
router
@ -261,3 +263,63 @@ async fn post_signup(
.into_response())
}
}
async fn get_signup_link(
State(ctx): State<Context>,
Path(id): Path<i64>,
headers: HeaderMap,
jar: CookieJar,
) -> AppResult<Response> {
let ctx_clone = ctx.clone();
let signup_link = tokio::task::spawn_blocking(move || ctx_clone.store.get_signup_link(id))
.await
.unwrap()?;
// Just redirect to / if it's an invalid sign-up link
if signup_link.is_none()
|| extract_session(ctx.clone(), &jar)
.await
.ok()
.flatten()
.is_some()
{
Ok(Redirect::to("/").into_response())
} else {
Ok(View::Signup {
username: None,
username_available: true,
passwords_match: true,
}
.page(&headers)
.response(&ctx.tera)
.into_response())
}
}
async fn post_signup_link(
State(ctx): State<Context>,
Path(id): Path<i64>,
jar: CookieJar,
headers: HeaderMap,
user_agent: Option<TypedHeader<UserAgent>>,
signup: Form<SignupForm>,
) -> AppResult<Response> {
let ctx_clone = ctx.clone();
if tokio::task::spawn_blocking(move || ctx_clone.store.get_signup_link(id))
.await
.unwrap()?
.is_some()
{
let response = post_signup(State(ctx.clone()), jar, headers, user_agent, signup).await?;
// Signup flow was successful, so remove the signup link
tokio::task::spawn_blocking(move || ctx.store.remove_signup_link(id))
.await
.unwrap()?;
Ok(response)
} else {
Ok(StatusCode::NOT_FOUND.into_response())
}
}