diff options
| author | gennyble <gen@nyble.dev> | 2026-09-25 03:56:11 -0500 |
|---|---|---|
| committer | gennyble <gen@nyble.dev> | 2026-09-25 03:56:11 -0500 |
| commit | ba592de54cd3dce52eb2096dabbe748107f5db2b (patch) | |
| tree | 4a23377e9788630e09c669e9ed18aecd27673ee7 /splorm/src | |
| parent | f4d8e8d03c1b80d5c59ee85631d5a78048892a32 (diff) | |
splorm auth
Diffstat (limited to 'splorm/src')
| -rw-r--r-- | splorm/src/config.rs | 14 | ||||
| -rw-r--r-- | splorm/src/extractor.rs | 48 | ||||
| -rw-r--r-- | splorm/src/handler/api/auth.rs | 87 | ||||
| -rw-r--r-- | splorm/src/handler/api/mod.rs (renamed from splorm/src/handler/api.rs) | 10 | ||||
| -rw-r--r-- | splorm/src/main.rs | 22 | ||||
| -rw-r--r-- | splorm/src/query.rs | 293 | ||||
| -rw-r--r-- | splorm/src/runtime_error.rs | 72 | ||||
| -rw-r--r-- | splorm/src/state.rs | 18 |
8 files changed, 558 insertions, 6 deletions
diff --git a/splorm/src/config.rs b/splorm/src/config.rs index a6cc81d..95fa28b 100644 --- a/splorm/src/config.rs +++ b/splorm/src/config.rs @@ -44,11 +44,14 @@ impl Config { let serving = confi.child("Serving").unwrap(); let webroot: Utf8PathBuf = serving.parse().unwrap(); let hostname = serving.child_owned("Hostname").unwrap(); + let uploads = serving.child_parse("Uploads").unwrap(); let templates_sec = serving.child("Templates").unwrap(); let templates: Utf8PathBuf = templates_sec.parse().unwrap(); let default_template = templates_sec.child_owned("Default").unwrap(); + let database_path = confi.child_parse("Database").unwrap(); + let connectors_sec = confi.child("Connectors").unwrap(); let gatherer_path: Utf8PathBuf = connectors_sec.child_parse("Gatherer").unwrap(); @@ -60,6 +63,10 @@ impl Config { hostname, webroot, + + database_path, + + uploads, connectors: Connectors { gatherer_path }, }, } @@ -88,6 +95,9 @@ pub struct Settings { default_template: String, template_dir: Utf8PathBuf, + database_path: Utf8PathBuf, + + uploads: Utf8PathBuf, connectors: Connectors, } @@ -118,6 +128,10 @@ impl Settings { .to_owned() } + pub fn database_path(&self) -> &Utf8Path { + &self.database_path + } + pub fn connectors(&self) -> &Connectors { &self.connectors } diff --git a/splorm/src/extractor.rs b/splorm/src/extractor.rs new file mode 100644 index 0000000..99c6b49 --- /dev/null +++ b/splorm/src/extractor.rs @@ -0,0 +1,48 @@ +use std::ops::Deref; + +use axum::{ + extract::FromRequestParts, + http::{HeaderValue, header, request::Parts}, +}; +use somethings::auth::Session; + +use crate::{RuntimeError, state::SplormState}; + +pub struct Wrap<T>(pub T); + +impl<T> Deref for Wrap<T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromRequestParts<SplormState> for Wrap<Session> { + type Rejection = RuntimeError; + + async fn from_request_parts( + parts: &mut Parts, + state: &SplormState, + ) -> Result<Self, Self::Rejection> { + let sid_str = sid_from_cookie_header(parts.headers.get(header::COOKIE))?; + let session = knowing::get_session(state.database(), sid_str) + .await + .map_err(|_| RuntimeError::InvalidSession)?; + + Ok(Wrap(session)) + } +} + +fn sid_from_cookie_header(cookie_header: Option<&HeaderValue>) -> Result<&str, RuntimeError> { + let cookie = gny::web::cookie::parse_header( + cookie_header + .ok_or(RuntimeError::InvalidSession)? + .to_str() + .map_err(|_| RuntimeError::InvalidSession)?, + ) + .map_err(|_| RuntimeError::InvalidSession)?; + + tracing::warn!("sessionid = {}", cookie.get("sid").unwrap_or(&"")); + Ok(cookie.get("sid").ok_or(RuntimeError::InvalidSession)?) +} diff --git a/splorm/src/handler/api/auth.rs b/splorm/src/handler/api/auth.rs new file mode 100644 index 0000000..ff1e990 --- /dev/null +++ b/splorm/src/handler/api/auth.rs @@ -0,0 +1,87 @@ +use axum::{body::Body, extract::State, response::Response}; +use gny::web::cookie::{self, SetCookie}; +use sessionhash::RequestContext; + +use crate::{Query, RuntimeError, state::SplormState}; + +/// POST /auth/register +pub async fn register( + State(state): State<SplormState>, + ctx: RequestContext, + body: String, +) -> Result<Response, RuntimeError> { + tracing::trace!("{ctx} GET /api/register"); + + macro_rules! get { + ($query:ident $field:literal) => { + $query + .get_first_value($field) + .ok_or(RuntimeError::incomplete_form($field)) + }; + } + + let query: Query = body.parse()?; + let name = get!(query "name")?; + let plaintext_password = get!(query "password")?.to_owned(); + + let result = knowing::user_register(state.database(), name, plaintext_password).await; + let _user = match result { + Ok(user) => user, + Err(knowing::Error::DatabaseError(dbe)) => return Err(dbe.into()), + _ => unreachable!(), + }; + + tracing::info!("registered user {name}"); + + Response::builder() + .status(302) + .header("Location", "/") + .body(Body::from(format!("registered user {name}"))) + .map_err(|_| RuntimeError::Unknown) +} + +/// POST /auth/login +pub async fn login( + State(state): State<SplormState>, + ctx: RequestContext, + body: String, +) -> Result<Response, RuntimeError> { + tracing::trace!("{ctx} GET /api/login"); + + macro_rules! get { + ($query:ident $field:literal) => { + $query + .get_first_value($field) + .ok_or(RuntimeError::incomplete_form($field)) + }; + } + + let query: Query = body.parse()?; + let name = get!(query "name")?; + let plaintext_password = get!(query "password")?.to_owned(); + + let result = knowing::user_login(state.database(), name, plaintext_password).await; + let session = match result { + Ok(user) => user, + Err(knowing::Error::DatabaseError(dbe)) => return Err(dbe.into()), + Err(knowing::Error::AuthenticationFailed) => { + return Err(RuntimeError::authentication_failed(name.to_owned())); + } + _ => unreachable!(), + }; + + let session_cookie = SetCookie::new("sid", session.sessionid().as_str()) + .max_age(Some(cookie::WEEK)) + .secure(true) + .httponly(true) + .path(Some("/")); + + tracing::info!("user {name} logged in"); + + Response::builder() + .status(302) + .header("Location", "/") + .header("Set-Cookie", session_cookie.as_string()) + .body(Body::from(format!("registered user {name}"))) + .map_err(|_| RuntimeError::Unknown) +} diff --git a/splorm/src/handler/api.rs b/splorm/src/handler/api/mod.rs index f360b87..a7bfd4b 100644 --- a/splorm/src/handler/api.rs +++ b/splorm/src/handler/api/mod.rs @@ -1,16 +1,22 @@ +mod auth; + use axum::{ Router, body::Body, extract::{Path, State}, response::Response, - routing::get, + routing::{get, post}, }; use sessionhash::RequestContext; use crate::{RuntimeError, fs, state::SplormState}; +/// Routes nested under /api pub fn routes() -> Router<SplormState> { - Router::new().route("/stats/now/{name}", get(stats)) + Router::new() + .route("/auth/register", post(auth::register)) + .route("/auth/login", post(auth::login)) + .route("/stats/now/{name}", get(stats)) } async fn stats( diff --git a/splorm/src/main.rs b/splorm/src/main.rs index 25d6cd3..f4cff19 100644 --- a/splorm/src/main.rs +++ b/splorm/src/main.rs @@ -1,22 +1,27 @@ use std::sync::Arc; -use axum::{Extension, Router, middleware::from_fn, routing::get}; +use axum::{Extension, Router, body::Body, middleware::from_fn, response::Response, routing::get}; +use somethings::auth::Session; use tokio::{net::TcpListener, runtime::Builder}; use crate::{ config::{CommandLine, Config}, connector::GathererThread, + extractor::Wrap, state::{ConnectorsState, SplormState}, }; mod config; pub mod connector; +mod extractor; pub mod fs; mod handler; +mod query; mod runtime_error; mod state; pub mod templated; +pub use query::{Parameter, Query, QueryParseError}; pub use runtime_error::{InvalidPathReason, RuntimeError}; fn main() { @@ -28,7 +33,10 @@ fn main() { let gatherer = GathererThread::spawn(conf.settings().connectors().gatherer_path()).unwrap(); - let state = SplormState::new(ConnectorsState::new(gatherer)); + let state = SplormState::new( + ConnectorsState::new(gatherer), + conf.settings().database_path(), + ); // Create and spawn the tokio multithreaded runtime. Execution stays // here until async_main() returns. @@ -37,9 +45,12 @@ fn main() { } async fn async_main(conf: Config, state: SplormState) { + state.init_database().await; + // Setup the routing let bare_routes = Router::new() .route("/", get(handler::index)) + .route("/auth_test", get(authtest)) .nest("/api", handler::api::routes()) .route("/{*path}", get(handler::static_path)); @@ -56,3 +67,10 @@ async fn async_main(conf: Config, state: SplormState) { let listener = TcpListener::bind(addr).await.unwrap(); axum::serve(listener, routes).await.unwrap(); } + +async fn authtest(Wrap(session): Wrap<Session>) -> Result<Response, RuntimeError> { + Response::builder() + .status(200) + .body(Body::new(format!("welcome {}", session.user().name()))) + .map_err(|_| RuntimeError::Unknown) +} diff --git a/splorm/src/query.rs b/splorm/src/query.rs new file mode 100644 index 0000000..97c4b3d --- /dev/null +++ b/splorm/src/query.rs @@ -0,0 +1,293 @@ +#![allow(dead_code)] +use std::{str::FromStr, vec::IntoIter}; + +///blah +#[derive(Debug)] +pub struct Query { + parameters: Vec<Parameter>, +} + +impl Query { + /// Returns true if the query has a parameter with the given key, whether it's a bool or key-value pair. + /// + /// # Examples + /// + ///```rust + ///use mavourings::query::Query; + ///let query: Query = "key=value&boolean".parse().unwrap(); + /// + ///assert!(query.has("key")); + ///assert!(query.has("boolean")); + ///assert!(!query.has("notakey")); + ///``` + pub fn has<S: AsRef<str>>(&self, key: S) -> bool { + for param in &self.parameters { + match param { + Parameter::Value(param_key, _) if param_key == key.as_ref() => return true, + Parameter::Bool(param_key) if param_key == key.as_ref() => return true, + _ => continue, + } + } + + false + } + + /// Returns true if the query has a key-value pair with the given key. + /// + /// # Examples + /// + ///```rust + /// use mavourings::query::Query; + /// + /// let query: Query = "key=value&boolean".parse().unwrap(); + /// + /// assert!(query.has_value("key")); + /// assert!(!query.has_value("boolean")); + ///``` + pub fn has_value<S: AsRef<str>>(&self, key: S) -> bool { + for param in &self.parameters { + match param { + Parameter::Value(param_key, _) if param_key == key.as_ref() => return true, + + _ => continue, + } + } + + false + } + + /// Returns true if the query has a bool with the given name. + /// + /// # Examples + /// + ///```rust + /// use mavourings::query::Query; + /// + /// let query: Query = "key=value&boolean".parse().unwrap(); + /// + /// assert!(query.has_bool("boolean")); + /// assert!(!query.has_bool("key")); + ///``` + pub fn has_bool<S: AsRef<str>>(&self, name: S) -> bool { + for param in &self.parameters { + match param { + Parameter::Bool(param_key) if param_key == name.as_ref() => return true, + _ => continue, + } + } + + false + } + + /// Returns the first value from a key-value pair if one is found. If none + /// is found, None is returned. + /// + /// # Examples + /// + ///```rust + /// use mavourings::query::Query; + /// + /// let query: Query = "key=value&boolean".parse().unwrap(); + /// + /// assert_eq!(query.get_first_value("key"), Some("value")); + /// assert_eq!(query.get_first_value("boolean"), None); + ///``` + pub fn get_first_value<S: AsRef<str>>(&self, search: S) -> Option<&str> { + for param in &self.parameters { + match param { + Parameter::Value(key, value) if key == search.as_ref() => return Some(value), + _ => continue, + } + } + + None + } + + /// Parses the first value from a key-value pair, if one is found, and returns + /// the result, or None if it can't be found. + /// + /// # Example + /// + /// ```rust + /// use mavourings::query::Query; + /// + /// let query: Query = "id=256".parse().unwrap(); + /// + /// assert_eq!(query.parse_first_value("id"), Some(Ok(256))) + /// ``` + pub fn parse_first_value<S: AsRef<str>, T: FromStr>( + &self, + search: S, + ) -> Option<Result<T, <T as FromStr>::Err>> { + self.get_first_value(search).map(|v| v.parse()) + } + + /// Processes a string, converting any percent encoded characteres into + /// their proper representations. + /// + /// If the second parameter is true, this function will also turn any '+' + /// into spaces as most browsers replace spaces with plus. This will not be + /// done if the plus is percent encoded (%2B) + /// + /// # Returns + /// + /// The decoded String on success or a QueryParseError if the decode resulted + /// in invalid UTF8 + /// + /// # Examples + /// + ///```rust + ///use mavourings::query::{Query, QueryParseError}; + /// + ///assert_eq!(Query::url_decode("a+space+two%20ways%21", true), Ok(String::from("a space two ways!"))); + ///assert_eq!(Query::url_decode("invalid%1Z", true), Ok(String::from("invalid%1Z"))); + ///assert_eq!(Query::url_decode("a%20plus+sign", false), Ok(String::from("a plus+sign"))); + ///``` + pub fn url_decode<S: AsRef<str>>( + urlencoded: S, + plus_as_space: bool, + ) -> Result<String, QueryParseError> { + let mut uncoded: Vec<u8> = vec![]; + + let mut chars = urlencoded.as_ref().chars().peekable(); + loop { + match chars.next() { + Some('+') => match plus_as_space { + true => uncoded.push(b' '), + false => uncoded.push(b'+'), + }, + Some('%') => match chars.peek() { + Some(c) if c.is_ascii_hexdigit() => { + let upper = chars.next().unwrap(); + + if let Some(lower) = chars.peek() { + if lower.is_ascii_hexdigit() { + let upper = upper.to_digit(16).unwrap(); + let lower = chars.next().unwrap().to_digit(16).unwrap(); + + uncoded.push(upper as u8 * 16 + lower as u8); + continue; + } + } + + uncoded.push(b'%'); + uncoded.extend_from_slice(&Self::char_bytes(upper)); + } + _ => { + uncoded.push(b'%'); + } + }, + Some(c) => { + uncoded.extend_from_slice(&Self::char_bytes(c)); + } + None => { + return Ok(String::from_utf8(uncoded).map_err(|_| QueryParseError::InvalidUtf8)?) + } + } + } + } + + /// Process a string, encoding the reserved URL characters below into their + /// percent equivalent. Any character outside of the ASCII printables are + /// also percent encoded. + /// + /// The following characters are reserved and will be encoded as %xx where + /// x is a lowercase hex digit: + /// `! # $ % ' ( ) * + , / : ; = ? @ [ ]` + /// + /// # Returns + /// + /// The decoded String on success or a QueryParseError if the decode resulted + /// in invalid UTF8 + /// + /// # Examples + /// + ///```rust + ///use mavourings::query::{Query, QueryParseError}; + /// + ///assert_eq!(Query::url_encode("encode me spaces!"), String::from("encode%20me%20spaces%21")); + ///assert_eq!(Query::url_encode("🥺"), String::from("%f0%9f%a5%ba")); + ///assert_eq!(Query::url_encode("one+two"), String::from("one%2btwo")); + ///``` + pub fn url_encode<S: AsRef<str>>(raw: S) -> String { + let mut encoded = String::new(); + let mut chars = raw.as_ref().chars().peekable(); + let should_encode = |c: char| !c.is_ascii_graphic() || "!#$%'()*+,/:;=?@[]".contains(c); + + loop { + match chars.next() { + Some(c) if should_encode(c) => { + let bytes = Self::char_bytes(c); + for byte in bytes { + encoded.push_str(&format!("%{:02x}", byte)); + } + } + Some(c) => { + encoded.push(c); + } + None => return encoded, + } + } + } + + fn char_bytes(c: char) -> Vec<u8> { + let mut utf8 = vec![0; c.len_utf8()]; + c.encode_utf8(&mut utf8); + utf8 + } +} + +impl std::str::FromStr for Query { + type Err = QueryParseError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let mut parameters: Vec<Parameter> = vec![]; + let splits = s.split('&'); + + for split in splits { + let splits: Vec<&str> = split.splitn(2, '=').collect(); + + match splits.len() { + 1 => parameters.push(Parameter::Bool(splits[0].into())), + 2 => parameters.push(Parameter::Value( + splits[0].into(), + Self::url_decode(splits[1], true)?, + )), + _ => unreachable!(), + } + } + + Ok(Self { parameters }) + } +} + +impl IntoIterator for Query { + type Item = Parameter; + + type IntoIter = IntoIter<Parameter>; + + fn into_iter(self) -> Self::IntoIter { + self.parameters.into_iter() + } +} + +#[derive(Debug)] +pub enum Parameter { + Bool(String), + Value(String, String), +} + +#[derive(Debug, PartialEq)] +pub enum QueryParseError { + InvalidUtf8, +} + +impl std::fmt::Display for QueryParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUtf8 => { + write!(f, "Query did not parse as valid utf8") + } + } + } +} diff --git a/splorm/src/runtime_error.rs b/splorm/src/runtime_error.rs index 225c2d4..bdcb472 100644 --- a/splorm/src/runtime_error.rs +++ b/splorm/src/runtime_error.rs @@ -6,6 +6,9 @@ use axum::{ }; use camino::Utf8PathBuf; use gny::sternum::Sternum; +use somethings::DatabaseError; + +use crate::QueryParseError; #[derive(Copy, Clone, Debug, PartialEq, Sternum)] pub enum InvalidPathReason { @@ -43,6 +46,19 @@ pub enum RuntimeError { source: Option<bempline::ParseError>, system_path: Utf8PathBuf, }, + QueryParseError { + source: QueryParseError, + }, + IncompleteForm { + missing_field: &'static str, + }, + DatabaseError { + source: DatabaseError, + }, + AuthenticationFailed { + name: String, + }, + InvalidSession, Generic404, Unknown, } @@ -94,6 +110,16 @@ impl RuntimeError { } } + pub fn incomplete_form(field: &'static str) -> Self { + Self::IncompleteForm { + missing_field: field, + } + } + + pub fn authentication_failed(name: String) -> Self { + Self::AuthenticationFailed { name } + } + pub fn generic_404() -> Self { RuntimeError::Generic404 } @@ -111,6 +137,11 @@ impl RuntimeError { FileErrorReason::Unknown => 500, }, Self::TemplateError { .. } => 500, + Self::QueryParseError { .. } => 400, + Self::IncompleteForm { .. } => 400, + Self::DatabaseError { .. } => 500, + Self::AuthenticationFailed { .. } => 403, + Self::InvalidSession => 403, Self::Generic404 => 404, Self::Unknown => 500, } @@ -143,6 +174,21 @@ impl fmt::Display for RuntimeError { Some(pe) => write!(f, "template error: {pe}. system={system_path}"), None => write!(f, "template error. reason={reason}, system={system_path}"), }, + RuntimeError::QueryParseError { source } => { + write!(f, "failed to parse query. {source}") + } + RuntimeError::IncompleteForm { missing_field } => { + write!(f, "missing field {missing_field} in form") + } + RuntimeError::DatabaseError { source } => { + write!(f, "database error: {source}") + } + RuntimeError::AuthenticationFailed { name } => { + write!(f, "authentication failed for '{name}'") + } + RuntimeError::InvalidSession => { + write!(f, "the session id was invalid") + } RuntimeError::Generic404 => write!(f, "generic 404"), RuntimeError::Unknown => write!(f, "unknown error!"), } @@ -153,6 +199,15 @@ struct PresentableRuntimeError<'re>(&'re RuntimeError); impl<'re> fmt::Display for PresentableRuntimeError<'re> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { macro_rules! estr { + (400) => { + write!(f, "bad request") + }; + (400 incomplete $missing:ident) => { + write!(f, "incomplete form. field {} is required", $missing) + }; + (403) => { + write!(f, "authenticated failed") + }; (404) => { write!(f, "the requested file could not be found") }; @@ -173,6 +228,11 @@ impl<'re> fmt::Display for PresentableRuntimeError<'re> { FileErrorReason::Unknown => estr!(500), }, RuntimeError::TemplateError { .. } => estr!(500), + RuntimeError::QueryParseError { .. } => estr!(400), + RuntimeError::IncompleteForm { missing_field } => estr!(400 incomplete missing_field), + RuntimeError::DatabaseError { .. } => estr!(500), + RuntimeError::AuthenticationFailed { .. } => estr!(403), + RuntimeError::InvalidSession => estr!(403), RuntimeError::Generic404 => estr!(404), RuntimeError::Unknown => estr!(500), } @@ -208,3 +268,15 @@ fn make_basic_error<S: std::fmt::Display>(status: u16, msg: S) -> Response { .unwrap() .into_response() } + +impl From<QueryParseError> for RuntimeError { + fn from(qpe: QueryParseError) -> Self { + RuntimeError::QueryParseError { source: qpe } + } +} + +impl From<DatabaseError> for RuntimeError { + fn from(dbe: DatabaseError) -> Self { + RuntimeError::DatabaseError { source: dbe } + } +} diff --git a/splorm/src/state.rs b/splorm/src/state.rs index 59e8629..964960b 100644 --- a/splorm/src/state.rs +++ b/splorm/src/state.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use camino::Utf8Path; +use somethings::DatabaseHandle; use tokio::sync::{Mutex, MutexGuard}; use crate::connector::GathererThread; @@ -7,16 +9,28 @@ use crate::connector::GathererThread; #[derive(Clone)] pub struct SplormState { connectors: ConnectorsState, + dbh: DatabaseHandle, } impl SplormState { - pub fn new(connectors: ConnectorsState) -> Self { - Self { connectors } + pub fn new(connectors: ConnectorsState, database_path: &Utf8Path) -> Self { + Self { + connectors, + dbh: DatabaseHandle::open(database_path.as_std_path()).unwrap(), + } } pub async fn gatherer_thread<'s>(&'s self) -> MutexGuard<'s, GathererThread> { self.connectors.gatherer.lock().await } + + pub fn database(&self) -> &DatabaseHandle { + &self.dbh + } + + pub async fn init_database(&self) { + knowing::create_table(self.database()).await.unwrap(); + } } #[derive(Clone)] |
