Compare commits
	
		
			4 Commits 
		
	
	
		
			69e84b4266
			...
			ee9db5ae36
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | ee9db5ae36 | |
|  | a296d0fafe | |
|  | 4d44216e17 | |
|  | 722317603d | 
|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -7,7 +7,7 @@ members = [ | |||
| ] | ||||
| 
 | ||||
| [workspace.package] | ||||
| version = "0.2.1" | ||||
| version = "0.3.0" | ||||
| edition = "2024" | ||||
| 
 | ||||
| [workspace.dependencies] | ||||
|  |  | |||
|  | @ -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`) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -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(()) | ||||
|  |  | |||
|  | @ -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()) | ||||
|     } | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue