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) | ## [Unreleased](https://git.rustybever.be/Chewing_Bever/otter) | ||||||
| 
 | 
 | ||||||
| * CLI command to add new users | ## [0.3.0](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.3.0) | ||||||
| * Added public sign-up page (disabled by default) | 
 | ||||||
|  | * 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) | ## [0.2.1](https://git.rustybever.be/Chewing_Bever/otter/src/tag/0.2.1) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -821,7 +821,7 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "gpodder" | name = "gpodder" | ||||||
| version = "0.2.1" | version = "0.3.0" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "argon2", |  "argon2", | ||||||
|  "chrono", |  "chrono", | ||||||
|  | @ -830,7 +830,7 @@ dependencies = [ | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "gpodder_sqlite" | name = "gpodder_sqlite" | ||||||
| version = "0.2.1" | version = "0.3.0" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "chrono", |  "chrono", | ||||||
|  "criterion", |  "criterion", | ||||||
|  | @ -1246,7 +1246,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" | ||||||
| 
 | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "otter" | name = "otter" | ||||||
| version = "0.2.1" | version = "0.3.0" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "axum", |  "axum", | ||||||
|  "axum-extra", |  "axum-extra", | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ members = [ | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| [workspace.package] | [workspace.package] | ||||||
| version = "0.2.1" | version = "0.3.0" | ||||||
| edition = "2024" | edition = "2024" | ||||||
| 
 | 
 | ||||||
| [workspace.dependencies] | [workspace.dependencies] | ||||||
|  |  | ||||||
|  | @ -20,6 +20,10 @@ the TOML file. | ||||||
| * `log_level` (`OTTER_LOG_LEVEL`): how verbose the logging should be; one of | * `log_level` (`OTTER_LOG_LEVEL`): how verbose the logging should be; one of | ||||||
|   `debug`, `info`, `warn` or `error` |   `debug`, `info`, `warn` or `error` | ||||||
|     * Default: `warn` |     * 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`) | ## Network (`net`) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,6 +1,3 @@ | ||||||
| use chrono::Utc; |  | ||||||
| use rand::Rng; |  | ||||||
| 
 |  | ||||||
| use crate::{AuthErr, Page, models}; | use crate::{AuthErr, Page, models}; | ||||||
| 
 | 
 | ||||||
| /// Admin view of the repository, providing methods only allowed by admins
 | /// Admin view of the repository, providing methods only allowed by admins
 | ||||||
|  | @ -17,21 +14,4 @@ impl<'a> AdminRepository<'a> { | ||||||
|     ) -> Result<Vec<models::User>, AuthErr> { |     ) -> Result<Vec<models::User>, AuthErr> { | ||||||
|         self.store.paginated_users(page, filter) |         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 argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; | ||||||
| use chrono::{TimeDelta, Utc}; | use chrono::{TimeDelta, Utc}; | ||||||
| use rand::rngs::OsRng; | use rand::{Rng, rngs::OsRng}; | ||||||
| 
 | 
 | ||||||
| use crate::{ | use crate::{ | ||||||
|     models, |     SignupLink, models, | ||||||
|     store::{AuthErr, GpodderStore}, |     store::{AuthErr, GpodderStore}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | @ -117,4 +117,25 @@ impl GpodderRepository { | ||||||
| 
 | 
 | ||||||
|         self.store.remove_old_sessions(min_last_seen) |         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)] | #[derive(Subcommand)] | ||||||
| pub enum UserCommand { | pub enum UserCommand { | ||||||
|  |     /// Add a new user
 | ||||||
|     Add { username: String, password: String }, |     Add { username: String, password: String }, | ||||||
|  |     /// Generate a signup link ID
 | ||||||
|  |     GenerateSignupLink, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl Command { | impl Command { | ||||||
|  | @ -59,6 +62,11 @@ impl UserCommand { | ||||||
|             Self::Add { username, password } => { |             Self::Add { username, password } => { | ||||||
|                 store.create_user(username, password)?; |                 store.create_user(username, password)?; | ||||||
|             } |             } | ||||||
|  |             Self::GenerateSignupLink => { | ||||||
|  |                 let link = store.generate_signup_link()?; | ||||||
|  | 
 | ||||||
|  |                 println!("/signup/{}", link.id); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         Ok(()) |         Ok(()) | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| use axum::{ | use axum::{ | ||||||
|     Form, RequestExt, Router, |     Form, RequestExt, Router, | ||||||
|     extract::{Request, State}, |     extract::{Path, Request, State}, | ||||||
|     http::HeaderMap, |     http::{HeaderMap, StatusCode}, | ||||||
|     middleware::Next, |     middleware::Next, | ||||||
|     response::{IntoResponse, Redirect, Response}, |     response::{IntoResponse, Redirect, Response}, | ||||||
|     routing::{get, post}, |     routing::{get, post}, | ||||||
|  | @ -35,6 +35,8 @@ pub fn router(ctx: Context) -> Router<Context> { | ||||||
|     // security mistakes
 |     // security mistakes
 | ||||||
|     if ctx.config.allow_public_signup { |     if ctx.config.allow_public_signup { | ||||||
|         router = router.route("/signup", get(get_signup).post(post_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 |     router | ||||||
|  | @ -261,3 +263,63 @@ async fn post_signup( | ||||||
|         .into_response()) |         .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