Compare commits

..

13 commits

Author SHA1 Message Date
Emi Tatsuo 4c63370a26
Refactor util to be just about serving files 2020-12-11 14:31:20 -05:00
Emi Tatsuo fb4a33685b
Don't inline the gemtext docs 2020-12-11 12:12:26 -05:00
Emi Tatsuo ffc86af284
Stuck some more documentation on gemtext -> body conversion methods 2020-12-08 19:19:23 -05:00
Emi Tatsuo 367cc17e8f
Update to the latest pending version of gemtext 2020-12-08 11:13:33 -05:00
Emi Tatsuo 1c1e2567f5
Fix the examples to work with Gemtext
I always forget the examples
2020-12-07 17:57:30 -05:00
Emi Tatsuo 485f579e4c
Add gemtext feature to docs 2020-12-07 17:32:07 -05:00
Emi Tatsuo 9ec1a5663d
Merge branch 'devel' into gemtext 2020-12-07 17:29:34 -05:00
Emii Tatsuo e95cbd70e9
Mention that the Gemtext struct is re-exported from gemtext 2020-11-30 17:42:58 -05:00
Emii Tatsuo 4ba099f947
Outsource document building to the gemtext crate
This change is still pending on some of my PRs being merged to main in the gemtext repository.  Please see:

=> https://tulpa.dev/cadey/maj/pulls/12 Add conversion traits to Builder
=> https://tulpa.dev/cadey/maj/pulls/13 Add a `blank_line()` method to `Builder`
=> https://tulpa.dev/cadey/maj/pulls/14 Accept an Option<&str> as a link name
=> https://tulpa.dev/cadey/maj/pulls/15 Add support for alt-text in preformatted blocks

Once these changes are merged, the dependency on gemtext should be moved to the crates.io version
2020-11-30 14:27:58 -05:00
Emii Tatsuo 9aa90c3e59
Fix conflicts with main branch 2020-11-30 00:31:30 -05:00
Emii Tatsuo a1d52faa9d
Fix examples 2020-11-30 00:28:26 -05:00
panicbit 0af7243517
Merge pull request #34 from Alch-Emi/improve-senddir-errors
Improve error handling for serve_dir
2020-11-28 23:30:03 +01:00
panicbit 00aa1f96f4
Merge pull request #37 from Alch-Emi/accept-string-docs
Accept strings in Document methods
2020-11-28 23:24:20 +01:00
24 changed files with 303 additions and 1316 deletions

View file

@ -1,17 +0,0 @@
kind: pipeline
name: tests
steps:
# Tests ordered from quickest to slowest so failed builds fail quickly
- name: minimum-test
image: rust:1
commands:
- cargo test --no-default-features --features scgi_srv
- name: scgi-test
image: rust:1
commands:
- cargo test --no-default-features --features scgi_srv,user_management_advanced,user_management_routes,serve_dir,ratelimiting
- name: gemini-test
image: rust:1
commands:
- cargo test --features user_management_advanced,user_management_routes,serve_dir,ratelimiting

View file

@ -15,8 +15,8 @@ include = ["src/**", "Cargo.*", "CHANGELOG.md", "LICENSE*", "README.md"]
default = ["certgen"] default = ["certgen"]
user_management = ["sled", "bincode", "serde/derive", "crc32fast", "lazy_static"] user_management = ["sled", "bincode", "serde/derive", "crc32fast", "lazy_static"]
user_management_advanced = ["rust-argon2", "user_management"] user_management_advanced = ["rust-argon2", "user_management"]
user_management_routes = ["user_management"] user_management_routes = ["user_management", "gemtext"]
serve_dir = ["mime_guess", "tokio/fs"] serve_dir = ["mime_guess", "tokio/fs", "gemtext"]
ratelimiting = ["dashmap"] ratelimiting = ["dashmap"]
certgen = ["rcgen", "gemini_srv"] certgen = ["rcgen", "gemini_srv"]
gemini_srv = ["tokio-rustls", "webpki", "rustls", "ring"] gemini_srv = ["tokio-rustls", "webpki", "rustls", "ring"]
@ -35,6 +35,7 @@ rustls = { version = "0.19", features = ["dangerous_configuration"], optional =
webpki = { version = "0.21.0", optional = true} webpki = { version = "0.21.0", optional = true}
tokio-rustls = { version = "0.21.0", optional = true} tokio-rustls = { version = "0.21.0", optional = true}
mime_guess = { version = "2.0.3", optional = true } mime_guess = { version = "2.0.3", optional = true }
gemtext = { git = "https://tulpa.dev/alch_emii/maj-prs.git", branch = "local-main", optional = true }
dashmap = { version = "3.11.10", optional = true } dashmap = { version = "3.11.10", optional = true }
sled = { version = "0.34.6", optional = true } sled = { version = "0.34.6", optional = true }
bincode = { version = "1.3.1", optional = true } bincode = { version = "1.3.1", optional = true }
@ -55,6 +56,10 @@ required-features = ["user_management_routes"]
name = "serve_dir" name = "serve_dir"
required-features = ["serve_dir"] required-features = ["serve_dir"]
[[example]]
name = "document"
required-features = ["gemtext"]
[[example]] [[example]]
name = "ratelimiting" name = "ratelimiting"
required-features = ["ratelimiting"] required-features = ["ratelimiting"]

View file

@ -10,21 +10,7 @@
``` ```
# kochab # kochab
A hybrid Raw/SCGI gemini server library to make manifesting your best ideas as painless as possible Kochab is an extension & a fork of the Gemini SDK [northstar]. Where northstar creates an efficient and flexible foundation for Gemini projects, kochab seeks to be as ergonomic and intuitive as possible, making it possible to get straight into getting your ideas into geminispace, with no worrying about needing to build the tools to get there.
*(**bold text** added for **readability**)*
Kochab is an extension & a **fork of the Gemini SDK [northstar]**. Where northstar creates an efficient and flexible foundation for Gemini projects, kochab seeks to be as **ergonomic and intuitive** as possible, making it possible to get straight into getting your ideas into geminispace, with no worrying about needing to build the tools to get there.
kochab comes with **several unique features** to make it super easy to make your project happen:
Any kochab project can be **compiled either to serve raw Gemini or SCGI** with a single feature flag. Little to no conversion of the actual code is necessary, meaning you can do all of your development work in raw Gemini mode and switch to SCGI once you're ready for production.
Additionally, kochab optionally comes with a **full user management suite** which takes all of the work out of setting up a login and registration system with client certificates. Kochab can completely handle prompting users for certificates, **storing user data**, allowing users to use passwords to **link new certificates**, and even registering a specific route as an authenticated route.
Kochab might be the only library that can **automatically generate TLS certificates**, without needing so much as a single line of code on your part. But, if you need to customize this behavior, kochab even offers several different modes to handle certificate generation, or you can turn it off completely to make a tiny and compact binary.
Despite offering all these features, if you need your library to be tiny, **kochab can be tiny**. With just the SCGI feature turned on, kochab only has 6 direct dependencies, and a total dependency tree size of just 22 dependencies.
# Usage # Usage
@ -34,24 +20,10 @@ It is currently only possible to use kochab through it's git repo, although it m
kochab = { git = "https://gitlab.com/Alch_Emi/kochab.git", branch = "stable" } kochab = { git = "https://gitlab.com/Alch_Emi/kochab.git", branch = "stable" }
``` ```
Once you've got that set up, check out some of our [examples] to jump right into things, or start learning all the amazing features at your disposal by reading our docs [not yet published, please use `cargo doc --features ratelimiting,user_management_advanced,user_management_routes,serve_dir`].
# Generating a key & certificate # Generating a key & certificate
By default, kochab enables the `certgen` feature, which will **automatically generate a certificate** for you. All you need to do is run the program once and follow the prompts printed to stdout. You can override this behavior by disabling the feature, or by using the methods in the `Builder`. By default, kochab enables the `certgen` feature, which will **automatically generate a certificate** for you. All you need to do is run the program once and follow the prompts printed to stdout. You can override this behavior by disabling the feature, or by using the methods in the `Builder`.
If you want to generate a certificate manually, it's recommended that you temporarily enable the `certgen` feature to do it, and then disable it once you're done, although you can also use the `openssl` client tool if you wish If you want to generate a certificate manually, it's recommended that you temporarily enable the `certgen` feature to do it, and then disable it once you're done, although you can also use the `openssl` client tool if you wish
# Credit where credit is due
As this is a fork of [northstar], naturally a fair amount of our code derives from our upstream, and we'd like to grant a generous thank you to panicbit, and all of their work on the original project.
Kochab also depends on the wonderful gemtext parsing/building library written by [Cadey] \[[gemini][cadey-gemini]\] as part of the [maj] ecosystem, which also includes its own gemini server library.
Lastly, we use the :milkyway: emoji from Twemoji as our logo in our docs, which is licensed under CC BY 4.0
[northstar]: https://github.com/panicbit/northstar "Northstar GitHub" [northstar]: https://github.com/panicbit/northstar "Northstar GitHub"
[examples]: ./examples "Code Examples"
[Cadey]: https://christine.website/ "Cadey (Christine Dodrill)'s Blog"
[cadey-gemini]: gemini://cetacean.club/ "Cadey's Capsule on Gemini"
[maj]: https://tulpa.dev/cadey/maj/ "The maj Git Repository"

View file

@ -1,42 +1,29 @@
use anyhow::Result; use anyhow::Result;
use log::LevelFilter; use log::LevelFilter;
use std::fmt::Write;
use kochab::{Request, Response, Server}; use kochab::{Request, Response, Server};
#[tokio::main] #[tokio::main]
/// This is a super quick demonstration of how you can check user certificates with kochab
///
/// The goal of this example is just to read the user's certificate and tell them their
/// certificate fingerprint, or if they aren't using a certificate, just tell them that
/// they didn't provide one.
///
/// You can of course require a certificate by sending a [`Response::client_certificate_required()`].
/// But, if you're interested in more advanced user management features, like letting
/// users create an account and persist data, you might want to check out the
/// user_management feature.
async fn main() -> Result<()> { async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder() env_logger::builder()
.filter_module("kochab", LevelFilter::Debug) .filter_module("kochab", LevelFilter::Debug)
.init(); .init();
Server::new() // Create a new server Server::new()
.add_route("/", handle_request) // Bind our handling function to the root path .add_route("/", handle_request)
.serve_ip("localhost:1965") // Start serving content on the default gemini port .serve_unix("kochab.sock")
.await .await
} }
/// This is the actual handler that does most of the actual work.
/// It'll be called by the server when we receive a request
async fn handle_request(request: Request) -> Result<Response> { async fn handle_request(request: Request) -> Result<Response> {
if let Some(fingerprint) = request.certificate() {
let mut message = String::from("You connected with a certificate with a fingerprint of:\n");
if let Some(fingerprint) = request.fingerprint() { for byte in fingerprint {
let message = format!( write!(&mut message, "{:x}", byte).unwrap();
"You connected with a certificate with a fingerprint of:\n{}", }
fingerprint,
);
Ok(Response::success_plain(message)) Ok(Response::success_plain(message))
} else { } else {

View file

@ -1,7 +1,6 @@
use anyhow::*; use anyhow::*;
use log::LevelFilter; use log::LevelFilter;
use kochab::{Server, Response, Document}; use kochab::{Server, Response, Gemtext};
use kochab::document::HeadingLevel::*;
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
@ -9,10 +8,11 @@ async fn main() -> Result<()> {
.filter_module("kochab", LevelFilter::Debug) .filter_module("kochab", LevelFilter::Debug)
.init(); .init();
let response: Response = Document::new() // Generate a fancy procedural response
.add_preformatted_with_alt("kochab", include_str!("kochab_logo.txt")) let response: Response = Gemtext::new()
.add_blank_line() .preformatted("kochab", include_str!("kochab_logo.txt"))
.add_text( .blank_line()
.text(
concat!( concat!(
"Kochab is an extension & a fork of the Gemini SDK [northstar]. Where", "Kochab is an extension & a fork of the Gemini SDK [northstar]. Where",
" northstar creates an efficient and flexible foundation for Gemini projects,", " northstar creates an efficient and flexible foundation for Gemini projects,",
@ -21,25 +21,27 @@ async fn main() -> Result<()> {
" worrying about needing to build the tools to get there." " worrying about needing to build the tools to get there."
) )
) )
.add_blank_line() .blank_line()
.add_link("https://github.com/Alch-Emi/kochab", "GitHub") .link("https://github.com/Alch-Emi/kochab", Some("GitLab".to_string()))
.add_blank_line() .blank_line()
.add_heading(H2, "Usage") .heading(2, "Usage")
.add_blank_line() .blank_line()
.add_text("Add the latest version of kochab to your `Cargo.toml`.") .text("Add the latest version of kochab to your `Cargo.toml`.")
.add_blank_line() .blank_line()
.add_preformatted_with_alt("toml", r#"kochab = { git = "https://github.com/Alch-Emi/kochab.git" }"#) .preformatted("toml", r#"kochab = { git = "https://github.com/Alch-Emi/kochab.git" }"#)
.add_blank_line() .blank_line()
.add_heading(H2, "Generating a key & certificate") .heading(2, "Generating a key & certificate")
.add_blank_line() .blank_line()
.add_preformatted_with_alt("sh", concat!( .preformatted("sh", concat!(
"mkdir cert && cd cert\n", "mkdir cert && cd cert\n",
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365", "openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
)) ))
.into(); .into();
Server::new() Server::new()
// You can also return the response from any one of your response handlers, but if
// you want to serve a static response, this works too
.add_route("/", response) .add_route("/", response)
.serve_ip("localhost:1965") .serve_unix("kochab.sock")
.await .await
} }

View file

@ -2,50 +2,36 @@ use std::time::Duration;
use anyhow::*; use anyhow::*;
use log::LevelFilter; use log::LevelFilter;
use kochab::{Server, Request, Response, Document}; use kochab::{Server, Request, Response, Gemtext};
#[tokio::main] #[tokio::main]
/// An ultra-simple ratelimiting example
///
/// We set up two pages:
/// * `/*` which can be accessed as much as the user wants
/// * /limit/*` which can only be accessed twice every 10 seconds
///
/// Once we tell it what needs to be ratelimited, kochab will automatically handle keeping
/// track of what users have and have not visited that page and how often. A very small
/// concurrent background task is in charge of cleaning the in-memory database every so
/// often so that a memory leak doesn't form.
async fn main() -> Result<()> { async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder() env_logger::builder()
.filter_module("kochab", LevelFilter::Debug) .filter_module("kochab", LevelFilter::Debug)
.init(); .init();
Server::new() // Create a server Server::new()
.add_route("/", handle_request) // Create a page, content doesn't matter .add_route("/", handle_request)
.ratelimit("/limit", 2, Duration::from_secs(10)) // Set the ratelimit to 2 / 10s .ratelimit("/limit", 2, Duration::from_secs(60))
.serve_ip("localhost:1965") // Start the server .serve_unix("kochab.sock")
.await .await
} }
/// Render a simple page based on the current URL
///
/// The actual content of the response, and really anything in this section, doesn't
/// actually affect the ratelimit, but it's nice to have a usable demo, so we set up a
/// couple nice pages
async fn handle_request(request: Request) -> Result<Response> { async fn handle_request(request: Request) -> Result<Response> {
let mut document = Document::new(); let mut document = if let Some("limit") = request.trailing_segments().get(0).map(String::as_str) {
Gemtext::new()
if let Some("limit") = request.trailing_segments().get(0).map(String::as_str) { .text("You're on a rate limited page!")
document.add_text("You're on a rate limited page!") .text("You can only access this page twice per minute")
.add_text("You can only access this page twice every 10 seconds");
} else { } else {
document.add_text("You're on a normal page!") Gemtext::new()
.add_text("You can access this page as much as you like."); .text("You're on a normal page!")
} .text("You can access this page as much as you like.")
document.add_blank_line() };
.add_link("/limit", "Go to rate limited page")
.add_link("/", "Go to a page that's not rate limited"); document = document
.blank_line()
.link("/limit", Some("Go to rate limited page".to_string()))
.link("/", Some("Go to a page that's not rate limited".to_string()));
Ok(document.into()) Ok(document.into())
} }

View file

@ -1,68 +1,45 @@
use anyhow::*; use anyhow::*;
use log::LevelFilter; use log::LevelFilter;
use kochab::{Document, document::HeadingLevel, Request, Response}; use kochab::{Gemtext, Request, Response};
#[tokio::main] #[tokio::main]
/// A quick demo to show off how an app can have multiple handlers on diffrent routes
///
/// We set up three different routes:
/// * `/route/long/*`
/// * `/route/*`
/// * `/*` which matches any other route
///
/// Each route generates a slightly different page, although they all use the same layout
/// through the [`generate_doc()`] method. Each page states which route was matched, and
/// all the trailing path segments.
///
/// For example, a request to `/route/trail` would be matched by the short route
/// (`/route/*`) with the trailing path segment `["trail"]`
async fn main() -> Result<()> { async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder() env_logger::builder()
.filter_module("kochab", LevelFilter::Debug) .filter_module("kochab", LevelFilter::Debug)
.init(); .init();
kochab::Server::new() // Create a new server kochab::Server::new()
.add_route("/", handle_base) // Register the base route (order irrelevant) .add_route("/", handle_base)
.add_route("/route", handle_short) // Reigster the short route .add_route("/route", handle_short)
.add_route("/route/long", handle_long) // Register the long route .add_route("/route/long", handle_long)
.serve_ip("localhost:1965") // Start the server .serve_unix("kochab.sock")
.await .await
} }
async fn handle_base(req: Request) -> Result<Response> { async fn handle_base(req: Request) -> Result<Response> {
let doc = generate_doc("base", &req); Ok(generate_resp("base", &req))
Ok(doc.into())
} }
async fn handle_short(req: Request) -> Result<Response> { async fn handle_short(req: Request) -> Result<Response> {
let doc = generate_doc("short", &req); Ok(generate_resp("short", &req))
Ok(doc.into())
} }
async fn handle_long(req: Request) -> Result<Response> { async fn handle_long(req: Request) -> Result<Response> {
let doc = generate_doc("long", &req); Ok(generate_resp("long", &req))
Ok(doc.into())
} }
fn generate_doc(route_name: &str, req: &Request) -> Document { fn generate_resp(route_name: &str, req: &Request) -> Response {
// Trailing segments comes in as a Vec of segments, so we join them together for
// display purposes
let trailing = req.trailing_segments().join("/"); let trailing = req.trailing_segments().join("/");
Gemtext::new()
let mut doc = Document::new(); .heading(1, "Routing Demo")
doc.add_heading(HeadingLevel::H1, "Routing Demo") .text(&format!("You're currently on the {} route", route_name))
.add_text(&format!("You're currently on the {} route", route_name)) .text(&format!("Trailing segments: /{}", trailing))
.add_text(&format!("Trailing segments: /{}", trailing)) .blank_line()
.add_blank_line() .text("Here's some links to try:")
.add_text("Here's some links to try:") .link("/", Option::<String>::None)
.add_link_without_label("/") .link("/route", Option::<String>::None)
.add_link_without_label("/route") .link("/route/long", Option::<String>::None)
.add_link_without_label("/route/long") .link("/route/not_real", Option::<String>::None)
.add_link_without_label("/route/not_real") .link("/rowte", Option::<String>::None)
.add_link_without_label("/rowte"); .into()
doc
} }

View file

@ -5,18 +5,7 @@ use log::LevelFilter;
use kochab::Server; use kochab::Server;
#[tokio::main] #[tokio::main]
/// Serving some static content from the filesystem is easy with Kochab
///
/// This example serves from the `./public` directory on the base route, and adds a
/// special one-page bind to `/about` that always serves `README.md`
///
/// Note, use this module with a little bit of caution. The directory serving feature is
/// currently unfinished, and the API is subject to change dramatically in future updates.
/// It should be secure, but you may need to do some refactoring in coming updates.
async fn main() -> Result<()> { async fn main() -> Result<()> {
// We set up logging so we can see what's happening. This isn't technically required,
// and you can use a simpler solution (like env_logger::init()) during production
env_logger::builder() env_logger::builder()
.filter_module("kochab", LevelFilter::Debug) .filter_module("kochab", LevelFilter::Debug)
.init(); .init();
@ -24,6 +13,6 @@ async fn main() -> Result<()> {
Server::new() Server::new()
.add_route("/", PathBuf::from("public")) // Serve directory listings & file contents .add_route("/", PathBuf::from("public")) // Serve directory listings & file contents
.add_route("/about", PathBuf::from("README.md")) // Serve a single file .add_route("/about", PathBuf::from("README.md")) // Serve a single file
.serve_ip("localhost:1965") .serve_unix("kochab.sock")
.await .await
} }

View file

@ -1,7 +1,7 @@
use anyhow::*; use anyhow::*;
use log::LevelFilter; use log::LevelFilter;
use kochab::{ use kochab::{
Document, Gemtext,
Request, Request,
Response, Response,
Server, Server,
@ -35,7 +35,7 @@ async fn main() -> Result<()> {
.add_um_routes::<String>() .add_um_routes::<String>()
// Start the server // Start the server
.serve_ip("localhost:1965") .serve_unix("kochab.sock")
.await .await
} }
@ -50,12 +50,12 @@ async fn main() -> Result<()> {
/// certificate will be prompted to add a certificate and register. /// certificate will be prompted to add a certificate and register.
async fn handle_main(_req: Request, user: RegisteredUser<String>) -> Result<Response> { async fn handle_main(_req: Request, user: RegisteredUser<String>) -> Result<Response> {
// If the user is signed in, render and return their page // If the user is signed in, render and return their page
let response = Document::new() let response = Gemtext::new()
.add_text("Your personal secret string:") .text("Your personal secret string:")
.add_text(user.as_ref()) .text(user.as_ref())
.add_blank_line() .blank_line()
.add_link("/update", "Change your string") .link("/update", Some("Change your string".to_string()))
.add_link("/account", "Update your account") .link("/account", Some("Update your account".to_string()))
.into(); .into();
Ok(response) Ok(response)
} }
@ -72,10 +72,10 @@ async fn handle_update(_request: Request, mut user: RegisteredUser<String>, inpu
*user.as_mut() = input; *user.as_mut() = input;
// Render a response // Render a response
let response = Document::new() let response = Gemtext::new()
.add_text("String updated!") .text("String updated!")
.add_blank_line() .blank_line()
.add_link("/", "Back") .link("/", Some("Back".to_string()))
.into(); .into();
Ok(response) Ok(response)

20
molly-brown.conf Normal file
View file

@ -0,0 +1,20 @@
# This is a super simple molly brown config file for the purpose of testing SCGI
# applications. Although you are welcome to use this as a base for an actual webserver,
# please find somewhere better for your production sockets.
#
# You can get a copy of molly brown and more information about configuring it from:
# https://tildegit.org/solderpunk/molly-brown
#
# Once installed, run the test server using the command
# molly-brown -c molly-brown.conf
Port = 1965
Hostname = "localhost"
CertPath = "cert/cert.pem"
KeyPath = "cert/key.pem"
AccessLog = "/dev/stdout"
ErrorLog = "/dev/stderr"
[SCGIPaths]
"/" = "kochab.sock"

View file

@ -3,19 +3,13 @@
//! ⚠️ Docs still under construction & API not yet stable ⚠️ //! ⚠️ Docs still under construction & API not yet stable ⚠️
#![allow(missing_docs)] #![allow(missing_docs)]
#[cfg(feature="serve_dir")]
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
#[cfg(feature="serve_dir")]
use tokio::{ use tokio::{
fs::{self, File}, fs::{self, File},
io, io,
}; };
#[cfg(feature="serve_dir")]
use crate::types::{Document, document::HeadingLevel::*};
#[cfg(feature="serve_dir")]
use crate::types::Response; use crate::types::Response;
#[cfg(feature="serve_dir")]
pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Response { pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Response {
let path = path.as_ref(); let path = path.as_ref();
@ -33,7 +27,6 @@ pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Response {
Response::success(mime, file) Response::success(mime, file)
} }
#[cfg(feature="serve_dir")]
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Response { pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Response {
debug!("Dir: {}", dir.as_ref().display()); debug!("Dir: {}", dir.as_ref().display());
let dir = dir.as_ref(); let dir = dir.as_ref();
@ -87,7 +80,6 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
serve_dir_listing(path, virtual_path).await serve_dir_listing(path, virtual_path).await
} }
#[cfg(feature="serve_dir")]
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Response { async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Response {
let mut dir = match fs::read_dir(path.as_ref()).await { let mut dir = match fs::read_dir(path.as_ref()).await {
Ok(dir) => dir, Ok(dir) => dir,
@ -102,13 +94,13 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
}; };
let breadcrumbs: PathBuf = virtual_path.iter().collect(); let breadcrumbs: PathBuf = virtual_path.iter().collect();
let mut document = Document::new(); let mut document = gemtext::Builder::new();
document.add_heading(H1, format!("Index of /{}", breadcrumbs.display())); document = document.heading(1, format!("Index of /{}", breadcrumbs.display()))
document.add_blank_line(); .blank_line();
if virtual_path.get(0).map(<_>::as_ref) != Some(Path::new("")) { if virtual_path.get(0).map(<_>::as_ref) != Some(Path::new("")) {
document.add_link("..", "📁 ../"); document = document.link("..", Some("📁 ../".to_string()));
} }
while let Some(entry) = dir.next_entry().await.expect("Failed to list directory") { while let Some(entry) = dir.next_entry().await.expect("Failed to list directory") {
@ -118,17 +110,16 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
let trailing_slash = if is_dir { "/" } else { "" }; let trailing_slash = if is_dir { "/" } else { "" };
let uri = format!("./{}{}", file_name, trailing_slash); let uri = format!("./{}{}", file_name, trailing_slash);
document.add_link(uri.as_str(), format!("{icon} {name}{trailing_slash}", document = document.link(uri.as_str(), Some(format!("{icon} {name}{trailing_slash}",
icon = if is_dir { '📁' } else { '📄' }, icon = if is_dir { '📁' } else { '📄' },
name = file_name, name = file_name,
trailing_slash = trailing_slash trailing_slash = trailing_slash
)); )));
} }
document.into() document.into()
} }
#[cfg(feature="serve_dir")]
pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> &'static str { pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> &'static str {
let path = path.as_ref(); let path = path.as_ref();
let extension = path.extension().and_then(|s| s.to_str()); let extension = path.extension().and_then(|s| s.to_str());
@ -144,7 +135,6 @@ pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> &'static str {
mime_guess::from_ext(extension).first_raw().unwrap_or("application/octet-stream") mime_guess::from_ext(extension).first_raw().unwrap_or("application/octet-stream")
} }
#[cfg(feature="serve_dir")]
/// Print a warning to the log asking to file an issue and respond with "Unexpected Error" /// Print a warning to the log asking to file an issue and respond with "Unexpected Error"
pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Response { pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Response {
warn!( warn!(
@ -159,19 +149,3 @@ pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32
); );
Response::temporary_failure("Unexpected error") Response::temporary_failure("Unexpected error")
} }
/// A convenience trait alias for `AsRef<T> + Into<T::Owned>`,
/// most commonly used to accept `&str` or `String`:
///
/// `Cowy<str>` ⇔ `AsRef<str> + Into<String>`
pub trait Cowy<T>
where
Self: AsRef<T> + Into<T::Owned>,
T: ToOwned + ?Sized,
{}
impl<C, T> Cowy<T> for C
where
C: AsRef<T> + Into<T::Owned>,
T: ToOwned + ?Sized,
{}

View file

@ -13,7 +13,9 @@ use std::{
#[cfg(feature = "serve_dir")] #[cfg(feature = "serve_dir")]
use std::path::PathBuf; use std::path::PathBuf;
use crate::{Document, types::{Body, Response, Request}}; use crate::{Body, Response, Request};
#[cfg(feature = "gemtext")]
use crate::Gemtext;
/// A struct representing something capable of handling a request. /// A struct representing something capable of handling a request.
/// ///
@ -48,11 +50,11 @@ pub enum Handler {
/// For serving files & directories, try looking at creating a [`FilesHandler`] by /// For serving files & directories, try looking at creating a [`FilesHandler`] by
/// [passing a directory](#impl-From<PathBuf>). /// [passing a directory](#impl-From<PathBuf>).
/// ///
/// Most often created by using [`From<Response>`] or [`From<Document>`] /// Most often created by using [`From<Response>`] or [`From<Gemtext>`]
/// ///
/// [`FilesHandler`]: Self::FilesHandler /// [`FilesHandler`]: Self::FilesHandler
/// [`From<Response>`]: #impl-From<Response> /// [`From<Response>`]: #impl-From<Response>
/// [`From<Document>`]: #impl-From<%26'_%20Document> /// [`From<Gemtext>`]: #impl-From<Gemtext>
StaticHandler(Response), StaticHandler(Response),
#[cfg(feature = "serve_dir")] #[cfg(feature = "serve_dir")]
@ -192,7 +194,8 @@ impl From<Response> for Handler {
} }
} }
impl From<&Document> for Handler { #[cfg(feature = "gemtext")]
impl From<Gemtext> for Handler {
/// Serve an unchanging response, shorthand for From<Response> /// Serve an unchanging response, shorthand for From<Response>
/// ///
/// This document will be sent in response to any requests that arrive at this /// This document will be sent in response to any requests that arrive at this
@ -202,7 +205,7 @@ impl From<&Document> for Handler {
/// This will create a [`StaticHandler`] /// This will create a [`StaticHandler`]
/// ///
/// [`StaticHandler`]: Self::StaticHandler /// [`StaticHandler`]: Self::StaticHandler
fn from(doc: &Document) -> Self { fn from(doc: Gemtext) -> Self {
Self::StaticHandler(doc.into()) Self::StaticHandler(doc.into())
} }
} }

View file

@ -1,5 +1,4 @@
#![warn(missing_docs)] #![warn(missing_docs)]
#![doc(html_logo_url = "https://raw.githubusercontent.com/twitter/twemoji/master/assets/svg/1f30c.svg")]
//! Kochab is an ergonomic and intuitive library for quickly building highly functional //! Kochab is an ergonomic and intuitive library for quickly building highly functional
//! and advanced Gemini applications on either SCGI or raw Gemini. //! and advanced Gemini applications on either SCGI or raw Gemini.
//! //!
@ -24,6 +23,10 @@
//! users can access certain areas of an application. This is primarily configured using //! users can access certain areas of an application. This is primarily configured using
//! the [`Server::ratelimit()`] method. //! the [`Server::ratelimit()`] method.
//! //!
//! * `gemtext` - Adds in integration with the `gemtext` crate, allowing easily creating
//! responses using a builder pattern. Please see the `document` example for a
//! demonstration. This is implied by `serve_dir` or `user_management_routes`
//!
//! * `serve_dir` - Adds in utilities for serving files & directories from the disk at //! * `serve_dir` - Adds in utilities for serving files & directories from the disk at
//! runtime. The easiest way to use this is to pass a [`PathBuf`] to the //! runtime. The easiest way to use this is to pass a [`PathBuf`] to the
//! [`Server::add_route()`] method, which will either serve a directory or a single file. //! [`Server::add_route()`] method, which will either serve a directory or a single file.
@ -137,19 +140,15 @@
//! To give your code a run, you'll need a server to handle Gemini requests and pass them //! To give your code a run, you'll need a server to handle Gemini requests and pass them
//! off to your SCGI server. There's a few Gemini servers out there with SCGI support, //! off to your SCGI server. There's a few Gemini servers out there with SCGI support,
//! but if you're just interested in giving your code a quick run, I'd recommend //! but if you're just interested in giving your code a quick run, I'd recommend
//! stargazer, which has very good SCGI support and is super easy to set up if you are //! mollybrown, which has very good SCGI support and is super easy to set up
//! already using cargo.
//! //!
//! You can install stargazer by running. //! You can grab a copy of molly brown from [tildegit.org/solderpunk/molly-brown][1].
//! ```sh
//! cargo install stargazer
//! ```
//! //!
//! Once you have it, you can find a super simple configuration file [here][2], and then //! Once you have it, you can find a super simple configuration file [here][2], and then
//! just run //! just run
//! //!
//! ```sh //! ```sh
//! stargazer -C stargazer.ini //! molly-brown -c molly-brown.conf
//! ``` //! ```
//! //!
//! Now, when you run your code, you can connect to `localhost`, and molly brown will //! Now, when you run your code, you can connect to `localhost`, and molly brown will
@ -177,7 +176,7 @@
//! For more information, see [`Server::set_autorewrite()`]. //! For more information, see [`Server::set_autorewrite()`].
//! //!
//! [1]: https://tildegit.org/solderpunk/molly-brown //! [1]: https://tildegit.org/solderpunk/molly-brown
//! [2]: https://gitlab.com/Alch_Emi/kochab/-/raw/devel/stargazer.ini //! [2]: https://gitlab.com/Alch_Emi/kochab/-/raw/244fd251/molly-brown.conf
//! [blobcat-pout]: https://the-apothecary.club/_matrix/media/r0/thumbnail/the-apothecary.club/10a406405a5bcd699a5328259133bfd9260320a6?height=99&width=20 ":blobcat-pout:" //! [blobcat-pout]: https://the-apothecary.club/_matrix/media/r0/thumbnail/the-apothecary.club/10a406405a5bcd699a5328259133bfd9260320a6?height=99&width=20 ":blobcat-pout:"
//! <style> //! <style>
//! img[alt=blobcat-pout] { width: 20px; vertical-align: top; } //! img[alt=blobcat-pout] { width: 20px; vertical-align: top; }
@ -185,13 +184,14 @@
#[macro_use] extern crate log; #[macro_use] extern crate log;
#[cfg(all(feature = "gemini_srv", feature = "scgi_srv", not(doc)))] #[cfg(all(feature = "gemini_srv", feature = "scgi_srv"))]
compile_error!("Please enable only one of either the `gemini_srv` or `scgi_srv` features on the kochab crate"); compile_error!("Please enable only one of either the `gemini_srv` or `scgi_srv` features on the kochab crate");
#[cfg(not(any(feature = "gemini_srv", feature = "scgi_srv")))] #[cfg(not(any(feature = "gemini_srv", feature = "scgi_srv")))]
compile_error!("Please enable at least one of either the `gemini_srv` or `scgi_srv` features on the kochab crate"); compile_error!("Please enable at least one of either the `gemini_srv` or `scgi_srv` features on the kochab crate");
use std::{ use std::{
sync::Arc,
time::Duration, time::Duration,
future::Future, future::Future,
}; };
@ -235,7 +235,8 @@ use rustls::Session;
mod types; mod types;
mod handling; mod handling;
pub mod util; #[cfg(feature = "serve_dir")]
pub mod files;
pub mod routing; pub mod routing;
#[cfg(feature = "ratelimiting")] #[cfg(feature = "ratelimiting")]
mod ratelimiting; mod ratelimiting;
@ -253,39 +254,43 @@ pub use uriparse::URIReference;
pub use types::*; pub use types::*;
pub use handling::Handler; pub use handling::Handler;
#[cfg(feature = "gemtext")]
#[doc(no_inline)]
/// A re-export of [`gemtext::Builder`], used for building `text/gemini` documents
pub use gemtext::Builder as Gemtext;
/// The maximun length of a Request URI /// The maximun length of a Request URI
pub const REQUEST_URI_MAX_LEN: usize = 1024; pub const REQUEST_URI_MAX_LEN: usize = 1024;
/// The default port for the gemini protocol /// The default port for the gemini protocol
pub const GEMINI_PORT: u16 = 1965; pub const GEMINI_PORT: u16 = 1965;
#[derive(Clone)]
struct ServerInner { struct ServerInner {
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
tls_acceptor: TlsAcceptor, tls_acceptor: TlsAcceptor,
routes: RoutingNode<Handler>, routes: Arc<RoutingNode<Handler>>,
timeout: Duration, timeout: Duration,
complex_timeout: Option<Duration>, complex_timeout: Option<Duration>,
#[cfg(feature = "scgi_srv")]
autorewrite: bool, autorewrite: bool,
#[cfg(feature="ratelimiting")] #[cfg(feature="ratelimiting")]
rate_limits: RoutingNode<RateLimiter<IpAddr>>, rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
manager: UserManager, manager: UserManager,
} }
impl ServerInner { impl ServerInner {
async fn serve_ip(self, listener: TcpListener) -> Result<()> { async fn serve_ip(self, listener: TcpListener) -> Result<()> {
let static_self: &'static Self = Box::leak(Box::new(self));
#[cfg(feature = "ratelimiting")] #[cfg(feature = "ratelimiting")]
tokio::spawn(prune_ratelimit_log(&static_self.rate_limits)); tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
loop { loop {
let (stream, _addr) = listener.accept().await let (stream, _addr) = listener.accept().await
.context("Failed to accept client")?; .context("Failed to accept client")?;
let this = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(err) = static_self.serve_client(stream).await { if let Err(err) = this.serve_client(stream).await {
error!("{:?}", err); error!("{:?}", err);
} }
}); });
@ -296,17 +301,16 @@ impl ServerInner {
// Yeah it's code duplication, but I can't find a way around it, so this is what we're // Yeah it's code duplication, but I can't find a way around it, so this is what we're
// getting for now // getting for now
async fn serve_unix(self, listener: UnixListener) -> Result<()> { async fn serve_unix(self, listener: UnixListener) -> Result<()> {
let static_self: &'static Self = Box::leak(Box::new(self));
#[cfg(feature = "ratelimiting")] #[cfg(feature = "ratelimiting")]
tokio::spawn(prune_ratelimit_log(&static_self.rate_limits)); tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
loop { loop {
let (stream, _addr) = listener.accept().await let (stream, _addr) = listener.accept().await
.context("Failed to accept client")?; .context("Failed to accept client")?;
let this = self.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(err) = static_self.serve_client(stream).await { if let Err(err) = this.serve_client(stream).await {
error!("{:?}", err); error!("{:?}", err);
} }
}); });
@ -314,10 +318,10 @@ impl ServerInner {
} }
async fn serve_client( async fn serve_client(
&'static self, &self,
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
stream: TcpStream, stream: TcpStream,
#[cfg(all(feature = "scgi_srv", not(feature = "gemini_srv")))] #[cfg(feature = "scgi_srv")]
stream: impl AsyncWrite + AsyncRead + Unpin + Send, stream: impl AsyncWrite + AsyncRead + Unpin + Send,
) -> Result<()> { ) -> Result<()> {
let fut_accept_request = async { let fut_accept_request = async {
@ -435,7 +439,6 @@ impl ServerInner {
Ok(()) Ok(())
} }
#[allow(clippy::useless_let_if_seq)]
async fn send_response(&self, response: Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> { async fn send_response(&self, response: Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
let use_complex_timeout = let use_complex_timeout =
response.body.is_some() && response.body.is_some() &&
@ -499,7 +502,7 @@ impl ServerInner {
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
async fn receive_request( async fn receive_request(
&'static self, &self,
stream: &mut (impl AsyncBufRead + Unpin + Send), stream: &mut (impl AsyncBufRead + Unpin + Send),
) -> Result<Request> { ) -> Result<Request> {
const HEADER_LIMIT: usize = REQUEST_URI_MAX_LEN + "\r\n".len(); const HEADER_LIMIT: usize = REQUEST_URI_MAX_LEN + "\r\n".len();
@ -527,13 +530,13 @@ impl ServerInner {
Request::new( Request::new(
uri, uri,
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
&self.manager, self.manager.clone(),
).context("Failed to create request from URI") ).context("Failed to create request from URI")
} }
#[cfg(feature = "scgi_srv")] #[cfg(feature = "scgi_srv")]
async fn receive_request( async fn receive_request(
&'static self, &self,
stream: &mut (impl AsyncBufRead + Unpin), stream: &mut (impl AsyncBufRead + Unpin),
) -> Result<Request> { ) -> Result<Request> {
let mut buff = Vec::with_capacity(4); let mut buff = Vec::with_capacity(4);
@ -601,7 +604,7 @@ impl ServerInner {
Request::new( Request::new(
headers, headers,
#[cfg(feature = "user_management")] #[cfg(feature = "user_management")]
&self.manager, self.manager.clone(),
)? )?
) )
} }
@ -675,7 +678,6 @@ pub struct Server {
timeout: Duration, timeout: Duration,
complex_body_timeout_override: Option<Duration>, complex_body_timeout_override: Option<Duration>,
routes: RoutingNode<Handler>, routes: RoutingNode<Handler>,
#[cfg(feature = "scgi_srv")]
autorewrite: bool, autorewrite: bool,
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
cert_path: PathBuf, cert_path: PathBuf,
@ -698,7 +700,6 @@ impl Server {
timeout: Duration::from_secs(1), timeout: Duration::from_secs(1),
complex_body_timeout_override: Some(Duration::from_secs(30)), complex_body_timeout_override: Some(Duration::from_secs(30)),
routes: RoutingNode::default(), routes: RoutingNode::default(),
#[cfg(feature = "scgi_srv")]
autorewrite: false, autorewrite: false,
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
cert_path: PathBuf::from("cert/cert.pem"), cert_path: PathBuf::from("cert/cert.pem"),
@ -884,7 +885,6 @@ impl Server {
self self
} }
#[cfg_attr(feature = "gemini_srv", allow(unused_mut), allow(unused_variables))]
/// Enable or disable autorewrite /// Enable or disable autorewrite
/// ///
/// Many times, an app will served alongside other apps all on one domain. For /// Many times, an app will served alongside other apps all on one domain. For
@ -947,9 +947,7 @@ impl Server {
/// For more information about what responses are rewritten, /// For more information about what responses are rewritten,
/// see [`Response::rewrite_all()`]. /// see [`Response::rewrite_all()`].
pub fn set_autorewrite(mut self, autorewrite: bool) -> Self { pub fn set_autorewrite(mut self, autorewrite: bool) -> Self {
#[cfg(feature = "scgi_srv")] { self.autorewrite = autorewrite;
self.autorewrite = autorewrite;
}
self self
} }
@ -968,15 +966,14 @@ impl Server {
let data_dir = self.data_dir; let data_dir = self.data_dir;
Ok(ServerInner { Ok(ServerInner {
routes: self.routes, routes: Arc::new(self.routes),
timeout: self.timeout, timeout: self.timeout,
complex_timeout: self.complex_body_timeout_override, complex_timeout: self.complex_body_timeout_override,
#[cfg(feature = "scgi_srv")]
autorewrite: self.autorewrite, autorewrite: self.autorewrite,
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
tls_acceptor: TlsAcceptor::from(config), tls_acceptor: TlsAcceptor::from(config),
#[cfg(feature="ratelimiting")] #[cfg(feature="ratelimiting")]
rate_limits: self.rate_limits, rate_limits: Arc::new(self.rate_limits),
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
manager: UserManager::new( manager: UserManager::new(
self.database.unwrap_or_else(move|| sled::open(data_dir).unwrap()) self.database.unwrap_or_else(move|| sled::open(data_dir).unwrap())
@ -988,11 +985,6 @@ impl Server {
/// ///
/// `addr` can be anything `tokio` can parse, including just a string like /// `addr` can be anything `tokio` can parse, including just a string like
/// "localhost:1965" /// "localhost:1965"
///
/// This will only ever exit with an error. It's important to note that even if the
/// function exits, the server will NOT be deallocated, since references to it in
/// concurrently running futures may still exist. As such, a loop that handles an
/// error by re-serving a new server may trigger a memory leak.
pub async fn serve_ip(self, addr: impl ToSocketAddrs + Send) -> Result<()> { pub async fn serve_ip(self, addr: impl ToSocketAddrs + Send) -> Result<()> {
let server = self.build()?; let server = self.build()?;
let socket = TcpListener::bind(addr).await?; let socket = TcpListener::bind(addr).await?;
@ -1004,8 +996,6 @@ impl Server {
/// ///
/// Requires an address in the form of a path to bind to. This is only available when /// Requires an address in the form of a path to bind to. This is only available when
/// in `scgi_srv` mode. /// in `scgi_srv` mode.
///
/// Please read the details and warnings of [`serve_ip()`] for more information
pub async fn serve_unix(self, addr: impl AsRef<std::path::Path>) -> Result<()> { pub async fn serve_unix(self, addr: impl AsRef<std::path::Path>) -> Result<()> {
let server = self.build()?; let server = self.build()?;
let socket = UnixListener::bind(addr)?; let socket = UnixListener::bind(addr)?;
@ -1056,11 +1046,12 @@ async fn send_response_body(mut body: Option<Body>, stream: &mut (impl AsyncWrit
#[cfg(feature="ratelimiting")] #[cfg(feature="ratelimiting")]
/// Every 5 minutes, remove excess keys from all ratelimiters /// Every 5 minutes, remove excess keys from all ratelimiters
async fn prune_ratelimit_log(rate_limits: &'static RoutingNode<RateLimiter<IpAddr>>) -> Never { async fn prune_ratelimit_log(rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>) -> Never {
let mut interval = interval(tokio::time::Duration::from_secs(10)); let mut interval = interval(tokio::time::Duration::from_secs(10));
let log = rate_limits.as_ref();
loop { loop {
interval.tick().await; interval.tick().await;
rate_limits.iter().for_each(RateLimiter::trim_keys_verbose); log.iter().for_each(RateLimiter::trim_keys_verbose);
} }
} }

View file

@ -6,6 +6,3 @@ pub use response::Response;
mod body; mod body;
pub use body::Body; pub use body::Body;
pub mod document;
pub use document::Document;

View file

@ -4,9 +4,8 @@ use tokio::io::AsyncReadExt;
#[cfg(feature="serve_dir")] #[cfg(feature="serve_dir")]
use tokio::fs::File; use tokio::fs::File;
use std::borrow::Borrow; #[cfg(feature = "gemtext")]
use crate::Gemtext;
use crate::types::Document;
/// The body of a response /// The body of a response
/// ///
@ -70,9 +69,27 @@ impl Body {
} }
} }
impl<D: Borrow<Document>> From<D> for Body { #[cfg(feature = "gemtext")]
fn from(document: D) -> Self { #[allow(clippy::fallible_impl_from)] // It's really not fallible but thanks
Self::from(document.borrow().to_string()) impl From<Vec<gemtext::Node>> for Body {
/// Render a series of [`gemtext`] nodes to a `text/gemini` body without [normalizing]
///
/// [normalizing]: Gemtext::normalize
fn from(document: Vec<gemtext::Node>) -> Self {
let size: usize = document.iter().map(gemtext::Node::estimate_len).sum();
let mut bytes = Vec::with_capacity(size + document.len());
gemtext::render(document, &mut bytes).unwrap(); // Safe: we're only writing to a buffer
Self::Bytes(bytes)
}
}
#[cfg(feature = "gemtext")]
impl From<Gemtext> for Body {
/// [Normalize][1] & eender a series of [`gemtext`] nodes to a `text/gemini` body
///
/// [1]: Gemtext::normalize
fn from(document: Gemtext) -> Self {
document.normalize().build().into()
} }
} }

View file

@ -1,558 +0,0 @@
//! Provides types for creating Gemini Documents.
//!
//! The module is centered around the `Document` type,
//! which provides all the necessary methods for programatically
//! creation of Gemini documents.
//!
//! # Examples
//!
//! ```
//! use kochab::document::HeadingLevel::*;
//!
//! let mut document = kochab::Document::new();
//!
//! document.add_heading(H1, "Heading 1");
//! document.add_heading(H2, "Heading 2");
//! document.add_heading(H3, "Heading 3");
//! document.add_blank_line();
//! document.add_text("text");
//! document.add_link("gemini://gemini.circumlunar.space", "Project Gemini");
//! document.add_unordered_list_item("list item");
//! document.add_quote("quote");
//! document.add_preformatted("preformatted");
//!
//! assert_eq!(document.to_string(), "\
//! ## Heading 1\n\
//! ### Heading 2\n\
//! #### Heading 3\n\
//! \n\
//! text\n\
//! => gemini://gemini.circumlunar.space/ Project Gemini\n\
//! * list item\n\
//! > quote\n\
//! ```\n\
//! preformatted\n\
//! ```\n\
//! ");
//! ```
#![warn(missing_docs)]
use std::convert::TryInto;
use std::fmt;
use crate::URIReference;
use crate::util::Cowy;
#[derive(Default)]
/// Represents a Gemini document.
///
/// Provides convenient methods for programatically
/// creation of Gemini documents.
pub struct Document {
items: Vec<Item>,
}
impl Document {
/// Creates an empty Gemini `Document`.
///
/// # Examples
///
/// ```
/// let document = kochab::Document::new();
///
/// assert_eq!(document.to_string(), "");
/// ```
pub fn new() -> Self {
Self::default()
}
/// Adds an `item` to the document.
///
/// An `item` usually corresponds to a single line,
/// except in the case of preformatted text.
///
/// # Examples
///
/// ```compile_fail
/// use kochab::document::{Document, Item, Text};
///
/// let mut document = Document::new();
/// let text = Text::new_lossy("foo");
/// let item = Item::Text(text);
///
/// document.add_item(item);
///
/// assert_eq!(document.to_string(), "foo\n");
/// ```
fn add_item(&mut self, item: Item) -> &mut Self {
self.items.push(item);
self
}
/// Adds multiple `items` to the document.
///
/// This is a convenience wrapper around `add_item`.
///
/// # Examples
///
/// ```compile_fail
/// use kochab::document::{Document, Item, Text};
///
/// let mut document = Document::new();
/// let items = vec!["foo", "bar", "baz"]
/// .into_iter()
/// .map(Text::new_lossy)
/// .map(Item::Text);
///
/// document.add_items(items);
///
/// assert_eq!(document.to_string(), "foo\nbar\nbaz\n");
/// ```
fn add_items<I>(&mut self, items: I) -> &mut Self
where
I: IntoIterator<Item = Item>,
{
self.items.extend(items);
self
}
/// Adds a blank line to the document.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_blank_line();
///
/// assert_eq!(document.to_string(), "\n");
/// ```
pub fn add_blank_line(&mut self) -> &mut Self {
self.add_item(Item::Text(Text::blank()))
}
/// Adds plain text to the document.
///
/// This function allows adding multiple lines at once.
///
/// It inserts a whitespace at the beginning of a line
/// if it starts with a character sequence that
/// would make it a non-plain text line (e.g. link, heading etc).
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_text("hello\n* world!");
///
/// assert_eq!(document.to_string(), "hello\n * world!\n");
/// ```
pub fn add_text(&mut self, text: impl AsRef<str>) -> &mut Self {
let text = text
.as_ref()
.lines()
.map(Text::new_lossy)
.map(Item::Text);
self.add_items(text);
self
}
/// Adds a link to the document.
///
/// `uri`s that fail to parse are substituted with `.`.
///
/// Consecutive newlines in `label` will be replaced
/// with a single whitespace.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_link("https://wikipedia.org", "Wiki\n\nWiki");
///
/// assert_eq!(document.to_string(), "=> https://wikipedia.org/ Wiki Wiki\n");
/// ```
pub fn add_link<'a, U>(&mut self, uri: U, label: impl Cowy<str>) -> &mut Self
where
U: TryInto<URIReference<'a>>,
{
let uri = uri
.try_into()
.map(URIReference::into_owned)
.or_else(|_| ".".try_into()).expect("Northstar BUG");
let label = LinkLabel::from_lossy(label);
let link = Link { uri: Box::new(uri), label: Some(label) };
let link = Item::Link(link);
self.add_item(link);
self
}
/// Adds a link to the document, but without a label.
///
/// See `add_link` for details.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_link_without_label("https://wikipedia.org");
///
/// assert_eq!(document.to_string(), "=> https://wikipedia.org/\n");
/// ```
pub fn add_link_without_label<'a, U>(&mut self, uri: U) -> &mut Self
where
U: TryInto<URIReference<'a>>,
{
let uri = uri
.try_into()
.map(URIReference::into_owned)
.or_else(|_| ".".try_into()).expect("Northstar BUG");
let link = Link {
uri: Box::new(uri),
label: None,
};
let link = Item::Link(link);
self.add_item(link);
self
}
/// Adds a block of preformatted text.
///
/// Lines that start with ` ``` ` will be prependend with a whitespace.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_preformatted("a\n b\n c");
///
/// assert_eq!(document.to_string(), "```\na\n b\n c\n```\n");
/// ```
pub fn add_preformatted(&mut self, preformatted_text: impl AsRef<str>) -> &mut Self {
self.add_preformatted_with_alt("", preformatted_text.as_ref())
}
/// Adds a block of preformatted text with an alt text.
///
/// Consecutive newlines in `alt` will be replaced
/// with a single whitespace.
///
/// `preformatted_text` lines that start with ` ``` `
/// will be prependend with a whitespace.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_preformatted_with_alt("rust", "fn main() {\n}\n");
///
/// assert_eq!(document.to_string(), "```rust\nfn main() {\n}\n```\n");
/// ```
pub fn add_preformatted_with_alt(&mut self, alt: impl AsRef<str>, preformatted_text: impl AsRef<str>) -> &mut Self {
let alt = AltText::new_lossy(alt.as_ref());
let lines = preformatted_text
.as_ref()
.lines()
.map(PreformattedText::new_lossy)
.collect();
let preformatted = Preformatted {
alt,
lines,
};
let preformatted = Item::Preformatted(preformatted);
self.add_item(preformatted);
self
}
/// Adds a heading.
///
/// Consecutive newlines in `text` will be replaced
/// with a single whitespace.
///
/// # Examples
///
/// ```
/// use kochab::document::HeadingLevel::H1;
///
/// let mut document = kochab::Document::new();
///
/// document.add_heading(H1, "Welcome!");
///
/// assert_eq!(document.to_string(), "# Welcome!\n");
/// ```
pub fn add_heading(&mut self, level: HeadingLevel, text: impl Cowy<str>) -> &mut Self {
let text = HeadingText::new_lossy(text);
let heading = Heading {
level,
text,
};
let heading = Item::Heading(heading);
self.add_item(heading);
self
}
/// Adds an unordered list item.
///
/// Consecutive newlines in `text` will be replaced
/// with a single whitespace.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_unordered_list_item("milk");
/// document.add_unordered_list_item("eggs");
///
/// assert_eq!(document.to_string(), "* milk\n* eggs\n");
/// ```
pub fn add_unordered_list_item(&mut self, text: impl AsRef<str>) -> &mut Self {
let item = UnorderedListItem::new_lossy(text.as_ref());
let item = Item::UnorderedListItem(item);
self.add_item(item);
self
}
/// Adds a quote.
///
/// This function allows adding multiple quote lines at once.
///
/// # Examples
///
/// ```
/// let mut document = kochab::Document::new();
///
/// document.add_quote("I think,\ntherefore I am");
///
/// assert_eq!(document.to_string(), "> I think,\n> therefore I am\n");
/// ```
pub fn add_quote(&mut self, text: impl AsRef<str>) -> &mut Self {
let quote = text
.as_ref()
.lines()
.map(Quote::new_lossy)
.map(Item::Quote);
self.add_items(quote);
self
}
}
impl fmt::Display for Document {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
for item in &self.items {
match item {
Item::Text(text) => writeln!(f, "{}", text.0)?,
Item::Link(link) => {
let separator = if link.label.is_some() {" "} else {""};
let label = link.label.as_ref().map(|label| label.0.as_str())
.unwrap_or("");
writeln!(f, "=> {}{}{}", link.uri, separator, label)?;
}
Item::Preformatted(preformatted) => {
writeln!(f, "```{}", preformatted.alt.0)?;
for line in &preformatted.lines {
writeln!(f, "{}", line.0)?;
}
writeln!(f, "```")?
}
Item::Heading(heading) => {
let level = match heading.level {
HeadingLevel::H1 => "#",
HeadingLevel::H2 => "##",
HeadingLevel::H3 => "###",
};
writeln!(f, "{} {}", level, heading.text.0)?;
}
Item::UnorderedListItem(item) => writeln!(f, "* {}", item.0)?,
Item::Quote(quote) => writeln!(f, "> {}", quote.0)?,
}
}
Ok(())
}
}
#[allow(clippy::enum_variant_names)]
enum Item {
Text(Text),
Link(Link),
Preformatted(Preformatted),
Heading(Heading),
UnorderedListItem(UnorderedListItem),
Quote(Quote),
}
#[derive(Default)]
struct Text(String);
impl Text {
fn blank() -> Self {
Self::default()
}
fn new_lossy(line: impl Cowy<str>) -> Self {
Self(lossy_escaped_line(line, SPECIAL_STARTS))
}
}
struct Link {
uri: Box<URIReference<'static>>,
label: Option<LinkLabel>,
}
struct LinkLabel(String);
impl LinkLabel {
fn from_lossy(line: impl Cowy<str>) -> Self {
let line = strip_newlines(line);
Self(line)
}
}
struct Preformatted {
alt: AltText,
lines: Vec<PreformattedText>,
}
struct PreformattedText(String);
impl PreformattedText {
fn new_lossy(line: impl Cowy<str>) -> Self {
Self(lossy_escaped_line(line, &[PREFORMATTED_TOGGLE_START]))
}
}
struct AltText(String);
impl AltText {
fn new_lossy(alt: &str) -> Self {
let alt = strip_newlines(alt);
Self(alt)
}
}
struct Heading {
level: HeadingLevel,
text: HeadingText,
}
/// The level of a heading.
pub enum HeadingLevel {
/// Heading level 1 (`#`)
H1,
/// Heading level 2 (`##`)
H2,
/// Heading level 3 (`###`)
H3,
}
struct HeadingText(String);
impl HeadingText {
fn new_lossy(line: impl Cowy<str>) -> Self {
let line = strip_newlines(line);
Self(line)
}
}
struct UnorderedListItem(String);
impl UnorderedListItem {
fn new_lossy(text: &str) -> Self {
let text = strip_newlines(text);
Self(text)
}
}
struct Quote(String);
impl Quote {
fn new_lossy(text: &str) -> Self {
Self(lossy_escaped_line(text, &[QUOTE_START]))
}
}
const LINK_START: &str = "=>";
const PREFORMATTED_TOGGLE_START: &str = "```";
const HEADING_START: &str = "#";
const UNORDERED_LIST_ITEM_START: &str = "*";
const QUOTE_START: &str = ">";
const SPECIAL_STARTS: &[&str] = &[
LINK_START,
PREFORMATTED_TOGGLE_START,
HEADING_START,
UNORDERED_LIST_ITEM_START,
QUOTE_START,
];
fn starts_with_any(s: &str, starts: &[&str]) -> bool {
for start in starts {
if s.starts_with(start) {
return true;
}
}
false
}
fn lossy_escaped_line(line: impl Cowy<str>, escape_starts: &[&str]) -> String {
let line_ref = line.as_ref();
let contains_newline = line_ref.contains('\n');
let has_special_start = starts_with_any(line_ref, escape_starts);
if !contains_newline && !has_special_start {
return line.into();
}
let mut line = String::new();
if has_special_start {
line.push(' ');
}
if let Some(line_ref) = line_ref.split('\n').next() {
line.push_str(line_ref);
}
line
}
fn strip_newlines(text: impl Cowy<str>) -> String {
if !text.as_ref().contains(&['\r', '\n'][..]) {
return text.into();
}
text.as_ref()
.lines()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join(" ")
}

View file

@ -1,7 +1,4 @@
use std::{ use std::ops;
fmt::Write,
ops,
};
#[cfg(feature = "gemini_srv")] #[cfg(feature = "gemini_srv")]
use std::convert::TryInto; use std::convert::TryInto;
#[cfg(feature = "scgi_srv")] #[cfg(feature = "scgi_srv")]
@ -42,7 +39,7 @@ pub struct Request {
certificate: Option<[u8; 32]>, certificate: Option<[u8; 32]>,
trailing_segments: Option<Vec<String>>, trailing_segments: Option<Vec<String>>,
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
manager: &'static UserManager, manager: UserManager,
#[cfg(feature = "scgi_srv")] #[cfg(feature = "scgi_srv")]
headers: HashMap<String, String>, headers: HashMap<String, String>,
#[cfg(feature = "scgi_srv")] #[cfg(feature = "scgi_srv")]
@ -83,7 +80,7 @@ impl Request {
#[cfg(feature = "scgi_srv")] #[cfg(feature = "scgi_srv")]
headers: HashMap<String, String>, headers: HashMap<String, String>,
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
manager: &'static UserManager, manager: UserManager,
) -> Result<Self> { ) -> Result<Self> {
#[cfg(feature = "scgi_srv")] #[cfg(feature = "scgi_srv")]
#[allow(clippy::or_fun_call)] // Lay off it's a macro #[allow(clippy::or_fun_call)] // Lay off it's a macro
@ -250,43 +247,10 @@ impl Request {
#[allow(clippy::missing_const_for_fn)] #[allow(clippy::missing_const_for_fn)]
/// Get the fingerprint of the certificate the user is connecting with /// Get the fingerprint of the certificate the user is connecting with
///
/// Please not that this is **not** the full certificate, just it's fingerprint
/// represented as bytes. The full certificate is not currently exposed, since some
/// SCGI servers may not receive it.
///
/// If you are planning on displaying the certificate to the user, you may want to
/// consider using [`fingerprint()`], which stringifies the output of this method.
///
/// [`fingerprint()`]: Request::fingerprint
pub fn certificate(&self) -> Option<&[u8; 32]> { pub fn certificate(&self) -> Option<&[u8; 32]> {
self.certificate.as_ref() self.certificate.as_ref()
} }
/// Get the user's certificate as a [`String`] contain the hex fingerprint
///
/// This is a convenience method for stringiying the certificate fingerprint from the
/// [`certificate()`] method. If you're using this fingerprint as a key for some user
/// data, you may want to perfer the former method. This method should be used when
/// the fingerprint is being displayed to the user.
///
/// The returned fingerprint will always be a 64 character string containing lowercase
/// hex digits, such as
/// `5e7097dc25dc62867ee4e0d3214a74b83156e613fdf92ca05e08c79efb14b90e`
///
/// [`certificate()`]: Request::certificate
pub fn fingerprint(&self) -> Option<String> {
self.certificate.as_ref().map(|c| {
let mut message = String::with_capacity(64);
for byte in c {
write!(&mut message, "{:x}", byte).unwrap();
}
message
})
}
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
/// Attempt to determine the user who sent this request /// Attempt to determine the user who sent this request
/// ///
@ -296,15 +260,15 @@ impl Request {
where where
UserData: Serialize + DeserializeOwned UserData: Serialize + DeserializeOwned
{ {
Ok(self.manager.get_user_by_cert(self.certificate())?) Ok(self.manager.get_user(self.certificate())?)
} }
#[cfg(feature="user_management")] #[cfg(feature="user_management")]
/// Expose the server's UserManager /// Expose the server's UserManager
/// ///
/// Can be used to query users, or directly access the database /// Can be used to query users, or directly access the database
pub fn user_manager(&self) -> &'static UserManager { pub fn user_manager(&self) -> &UserManager {
self.manager &self.manager
} }
/// Attempt to rewrite an absolute URL against the base path of the SCGI script /// Attempt to rewrite an absolute URL against the base path of the SCGI script

View file

@ -1,6 +1,4 @@
use std::borrow::Borrow; use crate::types::Body;
use crate::types::{Body, Document};
/// A response to a client's [`Request`] /// A response to a client's [`Request`]
/// ///
@ -180,16 +178,8 @@ impl Response {
} }
/// True if the response is a SUCCESS (10) response /// True if the response is a SUCCESS (10) response
///
/// ```
/// # use kochab::Response;
/// let redir = Response::redirect_permanent("/");
/// assert!(!redir.is_success());
/// let success = Response::success_plain("Hello gemini!");
/// assert!(success.is_success());
/// ```
pub const fn is_success(&self) -> bool { pub const fn is_success(&self) -> bool {
self.status == 20 self.status == 10
} }
#[cfg_attr(feature="gemini_srv",allow(unused_variables))] #[cfg_attr(feature="gemini_srv",allow(unused_variables))]
@ -256,8 +246,9 @@ impl AsMut<Option<Body>> for Response {
} }
} }
impl<D: Borrow<Document>> From<D> for Response { #[cfg(feature = "gemtext")]
impl<D: Into<Vec<gemtext::Node>>> From<D> for Response {
fn from(doc: D) -> Self { fn from(doc: D) -> Self {
Self::success_gemini(doc) Self::success_gemini(doc.into())
} }
} }

View file

@ -1,7 +1,5 @@
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use std::convert::TryInto;
use crate::user_management::{User, Result}; use crate::user_management::{User, Result};
use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser}; use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser};
@ -10,22 +8,9 @@ use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser}
/// ///
/// Wraps a [`sled::Db`] /// Wraps a [`sled::Db`]
pub struct UserManager { pub struct UserManager {
/// Allows access to the [`sled::Db`] used by the UserManager to store user data.
///
/// Do not try to use this database to access user information, and instead prefer
/// methods such as [`lookup_user()`] and [`lookup_certificate()`].
///
/// However, you're welcome to use the database to store you own data without needing
/// to run parallel sled databases. It's recommended that any trees you open be
/// namespaced like `tld.yourdomain.projectname.treename` in order to prevent
/// conflict.
///
/// [`lookup_user()`]: Self::lookup_user
/// [`lookup_certificate()`]: Self::lookup_certificate
pub db: sled::Db, pub db: sled::Db,
pub (crate) users: sled::Tree, // user_id:u64 maps to data:PartialUser pub (crate) users: sled::Tree, // user_id:String maps to data:UserData
pub (crate) certificates: sled::Tree, // fingerprint:[u8; 32] maps to uid:u64 pub (crate) certificates: sled::Tree, // certificate:u64 maps to data:CertificateData
pub (crate) usernames: sled::Tree, // username:String maps to uid:u64
} }
impl UserManager { impl UserManager {
@ -38,7 +23,6 @@ impl UserManager {
Ok(Self { Ok(Self {
users: db.open_tree("gay.emii.kochab.users")?, users: db.open_tree("gay.emii.kochab.users")?,
certificates: db.open_tree("gay.emii.kochab.certificates")?, certificates: db.open_tree("gay.emii.kochab.certificates")?,
usernames: db.open_tree("gay.emii.kochab.usernames")?,
db, db,
}) })
} }
@ -48,60 +32,29 @@ impl UserManager {
/// # Errors /// # Errors
/// An error is thrown if there is an error reading from the database or if data /// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt /// recieved from the database is corrupt
/// pub fn lookup_certificate(&self, cert: [u8; 32]) -> Result<Option<String>> {
/// [`None`] can be returned if their is no user with this certificate.
pub fn lookup_certificate<UserData: Serialize + DeserializeOwned>(
&self,
cert: [u8; 32]
) -> Result<Option<RegisteredUser<UserData>>> {
if let Some(bytes) = self.certificates.get(cert)? { if let Some(bytes) = self.certificates.get(cert)? {
let id = u64::from_le_bytes(bytes.as_ref().try_into()?); Ok(Some(std::str::from_utf8(bytes.as_ref())?.to_string()))
Ok(Some(
self.lookup_user(id)?
.ok_or(super::DeserializeError::InvalidReference(id))?
))
} else { } else {
Ok(None) Ok(None)
} }
} }
/// Get the user with the specified username /// Lookup information about a user by username
///
/// # Errors
/// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt
///
/// [`None`] can be returned if their is no user with this username.
pub fn lookup_username<UserData: Serialize + DeserializeOwned>(
&self,
username: impl AsRef<str>
) -> Result<Option<RegisteredUser<UserData>>> {
if let Some(bytes) = self.usernames.get(username.as_ref())? {
let id = u64::from_le_bytes(bytes.as_ref().try_into()?);
Ok(Some(
self.lookup_user(id)?
.ok_or(super::DeserializeError::InvalidReference(id))?
))
} else {
Ok(None)
}
}
/// Lookup a user by uid
/// ///
/// # Errors /// # Errors
/// An error is thrown if there is an error reading from the database or if data /// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt /// recieved from the database is corrupt
pub fn lookup_user<UserData>( pub fn lookup_user<UserData>(
&self, &self,
uid: u64, username: impl AsRef<str>
) -> Result<Option<RegisteredUser<UserData>>> ) -> Result<Option<RegisteredUser<UserData>>>
where where
UserData: Serialize + DeserializeOwned UserData: Serialize + DeserializeOwned
{ {
if let Some(bytes) = self.users.get(uid.to_le_bytes())? { if let Some(bytes) = self.users.get(username.as_ref())? {
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?; let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
Ok(Some(RegisteredUser::new(uid, None, self.clone(), inner))) Ok(Some(RegisteredUser::new(username.as_ref().to_owned(), None, self.clone(), inner)))
} else { } else {
Ok(None) Ok(None)
} }
@ -114,18 +67,20 @@ impl UserManager {
/// the database is corrupt /// the database is corrupt
pub fn all_users<UserData>( pub fn all_users<UserData>(
&self, &self,
) -> impl Iterator<Item = Result<RegisteredUser<UserData>>> ) -> Vec<RegisteredUser<UserData>>
where where
UserData: Serialize + DeserializeOwned UserData: Serialize + DeserializeOwned
{ {
let this = self.clone();
self.users.iter() self.users.iter()
.map(move|result| { .map(|result| {
let (uid, bytes) = result?; let (username, bytes) = result.expect("Failed to connect to database");
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?; let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())
let uid = u64::from_le_bytes(uid.as_ref().try_into()?); .expect("Received malformed data from database");
Ok(RegisteredUser::new(uid, None, this.clone(), inner)) let username = String::from_utf8(username.to_vec())
.expect("Malformed username in database");
RegisteredUser::new(username, None, self.clone(), inner)
}) })
.collect()
} }
/// Attempt to determine the user who sent a request based on the certificate. /// Attempt to determine the user who sent a request based on the certificate.
@ -136,7 +91,7 @@ impl UserManager {
/// ///
/// # Panics /// # Panics
/// Pancis if the database is corrupt /// Pancis if the database is corrupt
pub fn get_user_by_cert<UserData>( pub fn get_user<UserData>(
&self, &self,
cert: Option<&[u8; 32]> cert: Option<&[u8; 32]>
) -> Result<User<UserData>> ) -> Result<User<UserData>>
@ -144,7 +99,9 @@ impl UserManager {
UserData: Serialize + DeserializeOwned UserData: Serialize + DeserializeOwned
{ {
if let Some(certificate) = cert { if let Some(certificate) = cert {
if let Some(user_inner) = self.lookup_certificate(*certificate)? { if let Some(username) = self.lookup_certificate(*certificate)? {
let user_inner = self.lookup_user(&username)?
.expect("Database corruption: Certificate data refers to non-existant user");
Ok(User::SignedIn(user_inner.with_cert(*certificate))) Ok(User::SignedIn(user_inner.with_cert(*certificate)))
} else { } else {
Ok(User::NotSignedIn(NotSignedInUser { Ok(User::NotSignedIn(NotSignedInUser {

View file

@ -33,45 +33,14 @@ use user::{NotSignedInUser, RegisteredUser};
use crate::types::Request; use crate::types::Request;
#[derive(Debug)] #[derive(Debug)]
/// An error that occured in the user manager
pub enum UserManagerError { pub enum UserManagerError {
/// Tried to set a user's username to a username that already exists
///
/// Recommended handling: Explicitly catch the error, and display a custom warning to
/// the user before asking them to try another username
UsernameNotUnique, UsernameNotUnique,
/// Attempted to validate the user's password, but they haven't set one yet
///
/// Recommended handling: Inform the user that either their username or password was
/// incorrect.
PasswordNotSet, PasswordNotSet,
/// There was an error connecting to sled
///
/// Recommended handling: Log a visible error for the sysadmin to see, and exit
DatabaseError(sled::Error), DatabaseError(sled::Error),
/// There was an error running a database transaction
///
/// Recommended handling: Same as [`UserManagerError::DatabaseError`]
DatabaseTransactionError(sled::transaction::TransactionError), DatabaseTransactionError(sled::transaction::TransactionError),
DeserializeBincodeError(bincode::Error),
/// There was an error deserializing from the database DeserializeUtf8Error(std::str::Utf8Error),
///
/// This likely indicates that the database was generated with a different version of
/// the software than is curretly being used, either because the UserData struct has
/// changed, or because kochab itself has updated it's schema.
///
/// Recommended handling: Log a visible error and exit. Recommend seeking a database
/// migration script or deleting the database
DeserializeError(DeserializeError),
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")]
/// There was an error hashing or checking the user's password.
///
/// This likely indicates database corruption, and should be handled in the same way
/// as a [`UserManagerError::DeserializeBincodeError`]
Argon2Error(argon2::Error), Argon2Error(argon2::Error),
} }
@ -89,25 +58,13 @@ impl From<sled::transaction::TransactionError> for UserManagerError {
impl From<bincode::Error> for UserManagerError { impl From<bincode::Error> for UserManagerError {
fn from(error: bincode::Error) -> Self { fn from(error: bincode::Error) -> Self {
Self::DeserializeError(error.into()) Self::DeserializeBincodeError(error)
}
}
impl From<std::array::TryFromSliceError> for UserManagerError {
fn from(error: std::array::TryFromSliceError) -> Self {
Self::DeserializeError(error.into())
} }
} }
impl From<std::str::Utf8Error> for UserManagerError { impl From<std::str::Utf8Error> for UserManagerError {
fn from(error: std::str::Utf8Error) -> Self { fn from(error: std::str::Utf8Error) -> Self {
Self::DeserializeError(error.into()) Self::DeserializeUtf8Error(error)
}
}
impl From<DeserializeError> for UserManagerError {
fn from(error: DeserializeError) -> Self {
Self::DeserializeError(error)
} }
} }
@ -123,7 +80,8 @@ impl std::error::Error for UserManagerError {
match self { match self {
Self::DatabaseError(e) => Some(e), Self::DatabaseError(e) => Some(e),
Self::DatabaseTransactionError(e) => Some(e), Self::DatabaseTransactionError(e) => Some(e),
Self::DeserializeError(e) => Some(e), Self::DeserializeBincodeError(e) => Some(e),
Self::DeserializeUtf8Error(e) => Some(e),
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")]
Self::Argon2Error(e) => Some(e), Self::Argon2Error(e) => Some(e),
_ => None _ => None
@ -142,8 +100,10 @@ impl std::fmt::Display for UserManagerError {
write!(f, "Error accessing the user database: {}", e), write!(f, "Error accessing the user database: {}", e),
Self::DatabaseTransactionError(e) => Self::DatabaseTransactionError(e) =>
write!(f, "Error accessing the user database: {}", e), write!(f, "Error accessing the user database: {}", e),
Self::DeserializeError(e) => Self::DeserializeBincodeError(e) =>
write!(f, "Recieved messy data from database, possible corruption: {}", e), write!(f, "Recieved messy data from database, possible corruption: {}", e),
Self::DeserializeUtf8Error(e) =>
write!(f, "Recieved invalid UTF-8 from database, possible corruption: {}", e),
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")]
Self::Argon2Error(e) => Self::Argon2Error(e) =>
write!(f, "Argon2 Error, likely malformed password hash, possible database corruption: {}", e), write!(f, "Argon2 Error, likely malformed password hash, possible database corruption: {}", e),
@ -151,83 +111,4 @@ impl std::fmt::Display for UserManagerError {
} }
} }
#[derive(Debug)]
/// Indicates an error deserializing from the database
///
/// This likely indicates that the database was generated with
/// a different version of the software than is curretly being used, either because
/// the UserData struct has changed, or because kochab itself has updated it's schema.
pub enum DeserializeError {
/// There was an error deserializing an entire struct
///
/// Most likely indicates that the struct itself changed, i.e. between versions.
StructError(bincode::Error),
/// The was an error deserializing a userid
///
/// Likely because too many bytes were received. If you are getting this, you are
/// likely using a pre-0.1.0 version of kochab, and should update to the latest
/// version, or you're using a database that was created by a version of kochab that
/// was released after the one you're currently using. However, as of right now, no
/// such release exists or is planned.
UidError(std::array::TryFromSliceError),
/// There was an error deserializing a username
///
/// Indicates data corruption or a misaligned database version
UsernameError(std::str::Utf8Error),
/// A certificate or username was linked to a non-existant user id
///
/// This error should not occur, except in very unlikely scenarios, or if there's a
/// bug with kochab
InvalidReference(u64),
}
impl From<bincode::Error> for DeserializeError {
fn from(error: bincode::Error) -> Self {
Self::StructError(error)
}
}
impl From<std::array::TryFromSliceError> for DeserializeError {
fn from(error: std::array::TryFromSliceError) -> Self {
Self::UidError(error)
}
}
impl From<std::str::Utf8Error> for DeserializeError {
fn from(error: std::str::Utf8Error) -> Self {
Self::UsernameError(error)
}
}
impl std::error::Error for DeserializeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::StructError(e) => Some(e),
Self::UidError(e) => Some(e),
Self::UsernameError(e) => Some(e),
Self::InvalidReference(_) => None,
}
}
}
impl std::fmt::Display for DeserializeError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
match self {
Self::StructError(e) =>
write!(f, "Failed to deserialize struct: {}", e),
Self::UidError(e) =>
write!(f, "Got wrong number of bytes while deserializing user ID: {}", e),
Self::UsernameError(e) =>
write!(f, "Got invalid UTF-8 instead of username: {}", e),
Self::InvalidReference(uid) =>
write!(f, "Database refers to nonexistant user with userid {}", uid),
}
}
}
/// A result type returned by many methods within the [`user_management`] module
pub type Result<T> = std::result::Result<T, UserManagerError>; pub type Result<T> = std::result::Result<T, UserManagerError>;

View file

@ -1,5 +0,0 @@
# Account Deleted
Your account has been successfully deleted.
=> / Back to app

View file

@ -10,8 +10,7 @@ use std::sync::RwLock;
use std::future::Future; use std::future::Future;
use crate::{Document, Request, Response}; use crate::{Gemtext, Request, Response};
use crate::types::document::HeadingLevel;
use crate::user_management::{ use crate::user_management::{
User, User,
RegisteredUser, RegisteredUser,
@ -29,7 +28,6 @@ pub trait UserManagementRoutes: private::Sealed {
/// * `/account/register`, for users to register a new account /// * `/account/register`, for users to register a new account
/// * `/account/login`, for users to link their certificate to an existing account /// * `/account/login`, for users to link their certificate to an existing account
/// * `/account/password`, to change the user's password /// * `/account/password`, to change the user's password
/// * `/account/delete`, to delete an account
/// ///
/// If this method is used, no more routes should be added under `/account`. If you /// If this method is used, no more routes should be added under `/account`. If you
/// would like to direct a user to login from your application, you should send them /// would like to direct a user to login from your application, you should send them
@ -37,7 +35,7 @@ pub trait UserManagementRoutes: private::Sealed {
/// ///
/// The `redir` argument allows you to specify the point that users will be directed /// The `redir` argument allows you to specify the point that users will be directed
/// to return to once their account has been created. /// to return to once their account has been created.
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + Send + Sync + 'static>(self) -> Self; fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + 'static>(self) -> Self;
/// Add a special route that requires users to be logged in /// Add a special route that requires users to be logged in
/// ///
@ -94,15 +92,14 @@ impl UserManagementRoutes for crate::Server {
/// Add pre-configured routes to the serve to handle authentication /// Add pre-configured routes to the serve to handle authentication
/// ///
/// See [`UserManagementRoutes::add_um_routes()`] /// See [`UserManagementRoutes::add_um_routes()`]
fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + Send + Sync + 'static>(self) -> Self { fn add_um_routes<UserData: Serialize + DeserializeOwned + Default + 'static>(self) -> Self {
let clients_page = Response::success_gemini(include_str!("pages/clients.gmi")); let clients_page = Response::success_gemini(include_str!("pages/clients.gmi"));
#[allow(unused_mut)] #[allow(unused_mut)]
let mut modified_self = self.add_route("/account", handle_base::<UserData>) let mut modified_self = self.add_route("/account", handle_base::<UserData>)
.add_route("/account/askcert", handle_ask_cert::<UserData>) .add_route("/account/askcert", handle_ask_cert::<UserData>)
.add_route("/account/register", handle_register::<UserData>) .add_route("/account/register", handle_register::<UserData>)
.add_route("/account/clients", clients_page) .add_route("/account/clients", clients_page);
.add_authenticated_route("/account/delete", handle_delete::<UserData>);
#[cfg(feature = "user_management_advanced")] { #[cfg(feature = "user_management_advanced")] {
modified_self = modified_self modified_self = modified_self
@ -281,31 +278,6 @@ async fn handle_register<UserData: Serialize + DeserializeOwned + Default>(reque
}) })
} }
async fn handle_delete<UserData: Serialize + DeserializeOwned + Sync + Send>(
request: Request,
user: RegisteredUser<UserData>,
) -> Result<Response> {
const DELETE_PHRASE: &str = "I would like to delete my account";
Ok(match request.input() {
Some(DELETE_PHRASE) => {
user.delete()?;
Response::success_gemini(include_str!("pages/deleted.gmi"))
},
Some(_) => {
Response::bad_request("Phrase did not match. Your account has not been deleted.")
},
None => {
Response::input(
format!(
"Are you sure you'd like to delete your account? Please type \"{}\" to continue.",
DELETE_PHRASE,
)
)
},
})
}
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")]
async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> { async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
Ok(match request.user::<UserData>()? { Ok(match request.user::<UserData>()? {
@ -380,38 +352,47 @@ async fn handle_password<UserData: Serialize + DeserializeOwned + Default>(reque
fn render_settings_menu<UserData: Serialize + DeserializeOwned>( fn render_settings_menu<UserData: Serialize + DeserializeOwned>(
user: RegisteredUser<UserData> user: RegisteredUser<UserData>
) -> Response { ) -> Response {
let mut document = Document::new(); #[cfg_attr(not(feature = "user_management_advanced"), allow(unused_mut))]
document let mut document = Gemtext::new()
.add_heading(HeadingLevel::H1, "User Settings") .heading(1, "User Settings")
.add_blank_line() .blank_line()
.add_text(&format!("Welcome {}!", user.username())) .text(&format!("Welcome {}!", user.username()))
.add_blank_line() .blank_line()
.add_link(get_redirect(&user).as_str(), "Back to the app") .link(get_redirect(&user).as_str(), Some("Back to the app".to_string()))
.add_blank_line(); .blank_line();
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")] {
document document = document
.add_text( .text(
if user.has_password() { if user.has_password() {
concat!( concat!(
"You currently have a password set. This can be used to link any new", "You currently have a password set. This can be used to link any new",
" certificates or clients to your account. If you don't remember your", " certificates or clients to your account. If you don't remember your",
" password, or would like to change it, you may do so here.", " password, or would like to change it, you may do so here.",
)
} else {
concat!(
"You don't currently have a password set! Without a password, you cannot",
" link any new certificates to your account, and if you lose your current",
" client or certificate, you won't be able to recover your account.",
)
}
)
.blank_line()
.link(
"/account/password",
Some(
if user.has_password() {
"Change password"
} else {
"Set password"
}
.to_string()
) )
} else { );
concat!( }
"You don't currently have a password set! Without a password, you cannot",
" link any new certificates to your account, and if you lose your current",
" client or certificate, you won't be able to recover your account.",
)
}
)
.add_blank_line()
.add_link("/account/password", if user.has_password() { "Change password" } else { "Set password" });
document document.into()
.add_link("/account/delete", "Delete your account")
.into()
} }
fn render_unauth_page<'a>( fn render_unauth_page<'a>(
@ -451,7 +432,7 @@ fn get_redirect<T: Serialize + DeserializeOwned>(user: &RegisteredUser<T>) -> St
let maybe_redir = ref_to_map.get(cert).cloned(); let maybe_redir = ref_to_map.get(cert).cloned();
let redirect = maybe_redir.unwrap_or_else(||"/".to_string()); let redirect = maybe_redir.unwrap_or_else(||"/".to_string());
trace!("Accessed redirect to \"{}\" for cert {:x?}", redirect, cert); debug!("Accessed redirect to \"{}\" for cert {:x?}", redirect, cert);
redirect redirect
} }

View file

@ -16,7 +16,7 @@
use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sled::Transactional; use sled::Transactional;
#[cfg(not(feature = "ring"))] #[cfg(all(not(feature = "ring"), feature = "user_management_advanced"))]
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::user_management::UserManager; use crate::user_management::UserManager;
@ -48,7 +48,6 @@ lazy_static::lazy_static! {
pub (crate) struct PartialUser<UserData> { pub (crate) struct PartialUser<UserData> {
pub data: UserData, pub data: UserData,
pub certificates: Vec<[u8; 32]>, pub certificates: Vec<[u8; 32]>,
pub username: String,
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")]
pub pass_hash: Option<(Vec<u8>, [u8; 32])>, pub pass_hash: Option<(Vec<u8>, [u8; 32])>,
} }
@ -59,12 +58,12 @@ impl<UserData> PartialUser<UserData> {
/// ///
/// This MUST be called if the user data is modified using the AsMut trait, or else /// This MUST be called if the user data is modified using the AsMut trait, or else
/// changes will not be written to the database /// changes will not be written to the database
fn store(&self, tree: &sled::Tree, uid: u64) -> Result<()> fn store(&self, tree: &sled::Tree, username: impl AsRef<[u8]>) -> Result<()>
where where
UserData: Serialize UserData: Serialize
{ {
tree.insert( tree.insert(
uid.to_le_bytes(), &username,
bincode::serialize(&self)?, bincode::serialize(&self)?,
)?; )?;
Ok(()) Ok(())
@ -118,48 +117,26 @@ impl NotSignedInUser {
if self.manager.users.contains_key(username.as_str())? { if self.manager.users.contains_key(username.as_str())? {
Err(super::UserManagerError::UsernameNotUnique) Err(super::UserManagerError::UsernameNotUnique)
} else { } else {
info!("User {} registered!", username);
// Create the partial user that will go into the database. We can't create let mut newser = RegisteredUser::new(
// the full user yet, since the ID won't be generated until we perform the
// insert.
let partial = PartialUser {
username, username,
data: UserData::default(),
certificates: vec![self.certificate],
#[cfg(feature = "user_management_advanced")]
pass_hash: None,
};
let serialized = bincode::serialize(&partial)?;
// Insert the user into the three relevant tables, thus finalizing their
// creation. This also produces the user id.
let id = (&self.manager.users, &self.manager.certificates, &self.manager.usernames)
.transaction(|(tx_usr, tx_crt, tx_nam)| {
let id = tx_usr.generate_id()?;
let id_bytes = id.to_le_bytes();
tx_usr.insert(
&id_bytes,
serialized.as_slice(),
)?;
tx_crt.insert(
&partial.certificates[0],
&id_bytes,
)?;
tx_nam.insert(
partial.username.as_bytes(),
&id_bytes,
)?;
Ok(id)
})?;
info!("User {}#{:08X} registered!", partial.username, id);
Ok(RegisteredUser::new(
id,
Some(self.certificate), Some(self.certificate),
self.manager, self.manager,
partial, PartialUser {
)) data: UserData::default(),
certificates: Vec::with_capacity(1),
#[cfg(feature = "user_management_advanced")]
pass_hash: None,
},
);
// As a nice bonus, calling add_certificate with a user not yet in the
// database creates the user and adds the certificate in a single transaction.
// Because of this, we can delegate here ^^
newser.add_certificate(self.certificate)?;
Ok(newser)
} }
} }
@ -195,7 +172,7 @@ impl NotSignedInUser {
username: &str, username: &str,
password: Option<&[u8]>, password: Option<&[u8]>,
) -> Result<Option<RegisteredUser<UserData>>> { ) -> Result<Option<RegisteredUser<UserData>>> {
if let Some(mut user) = self.manager.lookup_username(username)? { if let Some(mut user) = self.manager.lookup_user(username)? {
// Perform password check, if caller wants // Perform password check, if caller wants
if let Some(password) = password { if let Some(password) = password {
if !user.check_password(password)? { if !user.check_password(password)? {
@ -203,6 +180,7 @@ impl NotSignedInUser {
} }
} }
info!("User {} attached certificate with fingerprint {:x?}", username, &self.certificate[..]);
user.add_certificate(self.certificate)?; user.add_certificate(self.certificate)?;
user.active_certificate = Some(self.certificate); user.active_certificate = Some(self.certificate);
Ok(Some(user)) Ok(Some(user))
@ -217,7 +195,7 @@ impl NotSignedInUser {
/// ///
/// For more information about the user lifecycle and sign-in stages, see [`User`] /// For more information about the user lifecycle and sign-in stages, see [`User`]
pub struct RegisteredUser<UserData: Serialize + DeserializeOwned> { pub struct RegisteredUser<UserData: Serialize + DeserializeOwned> {
uid: u64, username: String,
active_certificate: Option<[u8; 32]>, active_certificate: Option<[u8; 32]>,
manager: UserManager, manager: UserManager,
inner: PartialUser<UserData>, inner: PartialUser<UserData>,
@ -229,13 +207,13 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
/// Create a new user from parts /// Create a new user from parts
pub (crate) fn new( pub (crate) fn new(
uid: u64, username: String,
active_certificate: Option<[u8; 32]>, active_certificate: Option<[u8; 32]>,
manager: UserManager, manager: UserManager,
inner: PartialUser<UserData> inner: PartialUser<UserData>
) -> Self { ) -> Self {
Self { Self {
uid, username,
active_certificate, active_certificate,
manager, manager,
inner, inner,
@ -268,19 +246,9 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
/// Get the user's current username. /// Get the user's current username.
/// ///
/// NOTE: This is not guaranteed not to change. If you need an immutable reference to /// NOTE: This is not guaranteed not to change.
/// this user, prefer their [UID], which is guaranteed static.
///
/// [UID]: Self::uid()
pub fn username(&self) -> &String { pub fn username(&self) -> &String {
&self.inner.username &self.username
}
/// Get the user's id.
///
/// This is not guaranteed not to change.
pub fn uid(&self) -> u64 {
self.uid
} }
#[cfg(feature = "user_management_advanced")] #[cfg(feature = "user_management_advanced")]
@ -294,18 +262,13 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
try_password: impl AsRef<[u8]> try_password: impl AsRef<[u8]>
) -> Result<bool> { ) -> Result<bool> {
if let Some((hash, salt)) = &self.inner.pass_hash { if let Some((hash, salt)) = &self.inner.pass_hash {
let result = argon2::verify_raw( Ok(argon2::verify_raw(
try_password.as_ref(), try_password.as_ref(),
salt, salt,
hash.as_ref(), hash.as_ref(),
&ARGON2_CONFIG, &ARGON2_CONFIG,
)?; )?)
if !result {
info!("Someone failed to log in to the account of {} (wrong)", self);
}
Ok(result)
} else { } else {
info!("Someone failed to log in to the account of {} (not set)", self);
Err(super::UserManagerError::PasswordNotSet) Err(super::UserManagerError::PasswordNotSet)
} }
} }
@ -354,8 +317,6 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
salt, salt,
)); ));
self.has_changed = true; self.has_changed = true;
info!("Updated password for user {}", self);
Ok(()) Ok(())
} }
@ -367,9 +328,8 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
where where
UserData: Serialize UserData: Serialize
{ {
self.inner.store(&self.manager.users, self.uid)?; self.inner.store(&self.manager.users, &self.username)?;
self.has_changed = false; self.has_changed = false;
debug!("Changes to user {} saved", self);
Ok(()) Ok(())
} }
@ -386,67 +346,20 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
self.inner.certificates.push(certificate); self.inner.certificates.push(certificate);
let inner_serialized = bincode::serialize(&self.inner)?; let inner_serialized = bincode::serialize(&self.inner)?;
let uid_bytes = self.uid.to_le_bytes();
(&self.manager.users, &self.manager.certificates) (&self.manager.users, &self.manager.certificates)
.transaction(|(tx_usr, tx_crt)| { .transaction(|(tx_usr, tx_crt)| {
tx_usr.insert( tx_usr.insert(
&uid_bytes, self.username.as_str(),
inner_serialized.clone(), inner_serialized.clone(),
)?; )?;
tx_crt.insert( tx_crt.insert(
&certificate, &certificate,
&uid_bytes, self.username.as_bytes(),
)?; )?;
Ok(()) Ok(())
})?; })?;
info!("User {} added certificate with fingerprint {:X?}", self, certificate);
Ok(())
}
/// Permanently delete this user and all their data
///
/// Permanently remove all traces of this user from the database, including:
/// * User data associated with their account
/// * Any certificates linked to their account
/// * Their username (which is freed for other users to take)
/// * Their password hash
/// * ~~Any happy memories you have with them~~
///
/// If you're not using [`UserManagementRoutes`], it's strongly recommended that you
/// expose some way for users to delete their accounts, in order to appropriately
/// respect their privacy and their right to their data.
///
/// If you *are* using [`UserManagementRoutes`], your users already have a way of
/// deleting their accounts! Just direct them to `/account`.
///
/// # Errors
/// Can error if the a database error occurs
pub fn delete(mut self) -> Result<()> {
// Prevent re-saving on drop
self.has_changed = false;
let certificates = self.all_certificates();
(&self.manager.users, &self.manager.certificates, &self.manager.usernames).transaction(|(tx_usr, tx_crt, tx_nam)| {
tx_nam.remove(
self.inner.username.as_str(),
)?;
tx_usr.remove(
&self.uid.to_le_bytes(),
)?;
for cert in certificates {
tx_crt.remove(
cert,
)?;
}
Ok(())
})?;
info!("Deleted user {}", self);
Ok(()) Ok(())
} }
@ -480,19 +393,6 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
} }
} }
impl <UD: Serialize + DeserializeOwned> std::fmt::Display for RegisteredUser<UD> {
/// Synthesize a unique identifier for the user including their username
///
/// This is literally just the user's username postfixed with `#` and eight characters
/// representing the hex encoding of the users id. This is not guaranteed not to
/// change, but is great for logging, because it is simultaniously human-readable but
/// at the same time the last 8 characters offer a way to look up a user even with
/// username changes
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{:08X}", self.username(), self.uid)
}
}
impl<UserData: Serialize + DeserializeOwned> std::ops::Drop for RegisteredUser<UserData> { impl<UserData: Serialize + DeserializeOwned> std::ops::Drop for RegisteredUser<UserData> {
fn drop(&mut self) { fn drop(&mut self) {
if self.has_changed { if self.has_changed {
@ -515,7 +415,6 @@ impl<UserData: Serialize + DeserializeOwned> AsMut<UserData> for RegisteredUser<
} }
} }
#[cfg(all(feature = "user_management_advanced", not(feature = "ring")))] #[cfg(all(feature = "user_management_advanced", not(feature = "ring")))]
/// Inexpensive but low quality random /// Inexpensive but low quality random
fn pcg8(state: &mut u16) -> u8 { fn pcg8(state: &mut u16) -> u8 {

View file

@ -1,26 +0,0 @@
; This is a super simple configuration file for testing out SCGI apps with stargazer.
;
; To spin a demo server, first install stargazer with
; cargo install stargazer
;
; and then run this configuration with
; stargazer -c stargazer.ini
;
; Now, when you run your application bound to localhost:1312, any gemini connections to
; gemini://localhost should automatically be forwarded to your application. If you'd like
; to test out path rewriting, change the [localhost:/] header to [localhost:/app], restart
; stargazer, and connect to gemini://localhost/path.
;
; This configuration should be sufficient for any testing, but if you're interested in
; more in-depth configuration, or for using stargazer in production, please see the
; stargazer repository at https://git.sr.ht/~zethra/stargazer/
listen = 0.0.0.0:1965
[:tls]
store = cert
organization = Kochab Test Server
[localhost:/]
scgi = on
scgi-address = localhost:1312