about summary refs log tree commit diff
path: root/splorm/src
diff options
context:
space:
mode:
authorgennyble <gen@nyble.dev>2026-09-25 03:56:11 -0500
committergennyble <gen@nyble.dev>2026-09-25 03:56:11 -0500
commitba592de54cd3dce52eb2096dabbe748107f5db2b (patch)
tree4a23377e9788630e09c669e9ed18aecd27673ee7 /splorm/src
parentf4d8e8d03c1b80d5c59ee85631d5a78048892a32 (diff)
splorm auth
Diffstat (limited to 'splorm/src')
-rw-r--r--splorm/src/config.rs14
-rw-r--r--splorm/src/extractor.rs48
-rw-r--r--splorm/src/handler/api/auth.rs87
-rw-r--r--splorm/src/handler/api/mod.rs (renamed from splorm/src/handler/api.rs)10
-rw-r--r--splorm/src/main.rs22
-rw-r--r--splorm/src/query.rs293
-rw-r--r--splorm/src/runtime_error.rs72
-rw-r--r--splorm/src/state.rs18
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)]