Compare commits
	
		
			4 Commits 
		
	
	
		
			97b30b1840
			...
			69e84b4266
		
	
	| Author | SHA1 | Date | 
|---|---|---|
| 
							
							
								
									
								
								 | 
						69e84b4266 | |
| 
							
							
								
									
								
								 | 
						5017bfb710 | |
| 
							
							
								
									
								
								 | 
						4902f4d1fe | |
| 
							
							
								
									
								
								 | 
						89f8b08b5e | 
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
data_dir = "./data"
 | 
			
		||||
log_level = "debug"
 | 
			
		||||
 | 
			
		||||
allow_public_signup = true
 | 
			
		||||
 | 
			
		||||
[net]
 | 
			
		||||
type = "tcp"
 | 
			
		||||
domain = "127.0.0.1"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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());
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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 -%}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
            }
 | 
			
		||||
            _ => {}
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue