Compare commits
13 commits
Author | SHA1 | Date | |
---|---|---|---|
Emi Tatsuo | 4c63370a26 | ||
Emi Tatsuo | fb4a33685b | ||
Emi Tatsuo | ffc86af284 | ||
Emi Tatsuo | 367cc17e8f | ||
Emi Tatsuo | 1c1e2567f5 | ||
Emi Tatsuo | 485f579e4c | ||
Emi Tatsuo | 9ec1a5663d | ||
Emii Tatsuo | e95cbd70e9 | ||
Emii Tatsuo | 4ba099f947 | ||
Emii Tatsuo | 9aa90c3e59 | ||
Emii Tatsuo | a1d52faa9d | ||
0af7243517 | |||
00aa1f96f4 |
17
.drone.yml
17
.drone.yml
|
@ -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
|
|
@ -15,8 +15,8 @@ include = ["src/**", "Cargo.*", "CHANGELOG.md", "LICENSE*", "README.md"]
|
|||
default = ["certgen"]
|
||||
user_management = ["sled", "bincode", "serde/derive", "crc32fast", "lazy_static"]
|
||||
user_management_advanced = ["rust-argon2", "user_management"]
|
||||
user_management_routes = ["user_management"]
|
||||
serve_dir = ["mime_guess", "tokio/fs"]
|
||||
user_management_routes = ["user_management", "gemtext"]
|
||||
serve_dir = ["mime_guess", "tokio/fs", "gemtext"]
|
||||
ratelimiting = ["dashmap"]
|
||||
certgen = ["rcgen", "gemini_srv"]
|
||||
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}
|
||||
tokio-rustls = { version = "0.21.0", 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 }
|
||||
sled = { version = "0.34.6", optional = true }
|
||||
bincode = { version = "1.3.1", optional = true }
|
||||
|
@ -55,6 +56,10 @@ required-features = ["user_management_routes"]
|
|||
name = "serve_dir"
|
||||
required-features = ["serve_dir"]
|
||||
|
||||
[[example]]
|
||||
name = "document"
|
||||
required-features = ["gemtext"]
|
||||
|
||||
[[example]]
|
||||
name = "ratelimiting"
|
||||
required-features = ["ratelimiting"]
|
||||
|
|
30
README.md
30
README.md
|
@ -10,21 +10,7 @@
|
|||
```
|
||||
# kochab
|
||||
|
||||
A hybrid Raw/SCGI gemini server library to make manifesting your best ideas as painless as possible
|
||||
|
||||
*(**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.
|
||||
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.
|
||||
|
||||
# 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" }
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
# 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"
|
||||
[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"
|
||||
|
|
|
@ -1,42 +1,29 @@
|
|||
use anyhow::Result;
|
||||
use log::LevelFilter;
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use kochab::{Request, Response, Server};
|
||||
|
||||
#[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<()> {
|
||||
|
||||
// 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()
|
||||
.filter_module("kochab", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
Server::new() // Create a new server
|
||||
.add_route("/", handle_request) // Bind our handling function to the root path
|
||||
.serve_ip("localhost:1965") // Start serving content on the default gemini port
|
||||
Server::new()
|
||||
.add_route("/", handle_request)
|
||||
.serve_unix("kochab.sock")
|
||||
.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> {
|
||||
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() {
|
||||
let message = format!(
|
||||
"You connected with a certificate with a fingerprint of:\n{}",
|
||||
fingerprint,
|
||||
);
|
||||
for byte in fingerprint {
|
||||
write!(&mut message, "{:x}", byte).unwrap();
|
||||
}
|
||||
|
||||
Ok(Response::success_plain(message))
|
||||
} else {
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
use anyhow::*;
|
||||
use log::LevelFilter;
|
||||
use kochab::{Server, Response, Document};
|
||||
use kochab::document::HeadingLevel::*;
|
||||
use kochab::{Server, Response, Gemtext};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
@ -9,10 +8,11 @@ async fn main() -> Result<()> {
|
|||
.filter_module("kochab", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
let response: Response = Document::new()
|
||||
.add_preformatted_with_alt("kochab", include_str!("kochab_logo.txt"))
|
||||
.add_blank_line()
|
||||
.add_text(
|
||||
// Generate a fancy procedural response
|
||||
let response: Response = Gemtext::new()
|
||||
.preformatted("kochab", include_str!("kochab_logo.txt"))
|
||||
.blank_line()
|
||||
.text(
|
||||
concat!(
|
||||
"Kochab is an extension & a fork of the Gemini SDK [northstar]. Where",
|
||||
" 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."
|
||||
)
|
||||
)
|
||||
.add_blank_line()
|
||||
.add_link("https://github.com/Alch-Emi/kochab", "GitHub")
|
||||
.add_blank_line()
|
||||
.add_heading(H2, "Usage")
|
||||
.add_blank_line()
|
||||
.add_text("Add the latest version of kochab to your `Cargo.toml`.")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("toml", r#"kochab = { git = "https://github.com/Alch-Emi/kochab.git" }"#)
|
||||
.add_blank_line()
|
||||
.add_heading(H2, "Generating a key & certificate")
|
||||
.add_blank_line()
|
||||
.add_preformatted_with_alt("sh", concat!(
|
||||
.blank_line()
|
||||
.link("https://github.com/Alch-Emi/kochab", Some("GitLab".to_string()))
|
||||
.blank_line()
|
||||
.heading(2, "Usage")
|
||||
.blank_line()
|
||||
.text("Add the latest version of kochab to your `Cargo.toml`.")
|
||||
.blank_line()
|
||||
.preformatted("toml", r#"kochab = { git = "https://github.com/Alch-Emi/kochab.git" }"#)
|
||||
.blank_line()
|
||||
.heading(2, "Generating a key & certificate")
|
||||
.blank_line()
|
||||
.preformatted("sh", concat!(
|
||||
"mkdir cert && cd cert\n",
|
||||
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
|
||||
))
|
||||
.into();
|
||||
|
||||
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)
|
||||
.serve_ip("localhost:1965")
|
||||
.serve_unix("kochab.sock")
|
||||
.await
|
||||
}
|
||||
|
|
|
@ -2,50 +2,36 @@ use std::time::Duration;
|
|||
|
||||
use anyhow::*;
|
||||
use log::LevelFilter;
|
||||
use kochab::{Server, Request, Response, Document};
|
||||
use kochab::{Server, Request, Response, Gemtext};
|
||||
|
||||
#[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<()> {
|
||||
// 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()
|
||||
.filter_module("kochab", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
Server::new() // Create a server
|
||||
.add_route("/", handle_request) // Create a page, content doesn't matter
|
||||
.ratelimit("/limit", 2, Duration::from_secs(10)) // Set the ratelimit to 2 / 10s
|
||||
.serve_ip("localhost:1965") // Start the server
|
||||
Server::new()
|
||||
.add_route("/", handle_request)
|
||||
.ratelimit("/limit", 2, Duration::from_secs(60))
|
||||
.serve_unix("kochab.sock")
|
||||
.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> {
|
||||
let mut document = Document::new();
|
||||
|
||||
if let Some("limit") = request.trailing_segments().get(0).map(String::as_str) {
|
||||
document.add_text("You're on a rate limited page!")
|
||||
.add_text("You can only access this page twice every 10 seconds");
|
||||
let mut document = if let Some("limit") = request.trailing_segments().get(0).map(String::as_str) {
|
||||
Gemtext::new()
|
||||
.text("You're on a rate limited page!")
|
||||
.text("You can only access this page twice per minute")
|
||||
} else {
|
||||
document.add_text("You're on a normal page!")
|
||||
.add_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");
|
||||
Gemtext::new()
|
||||
.text("You're on a normal page!")
|
||||
.text("You can access this page as much as you like.")
|
||||
};
|
||||
|
||||
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())
|
||||
}
|
||||
|
|
|
@ -1,68 +1,45 @@
|
|||
use anyhow::*;
|
||||
use log::LevelFilter;
|
||||
use kochab::{Document, document::HeadingLevel, Request, Response};
|
||||
use kochab::{Gemtext, Request, Response};
|
||||
|
||||
#[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<()> {
|
||||
|
||||
// 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()
|
||||
.filter_module("kochab", LevelFilter::Debug)
|
||||
.init();
|
||||
|
||||
kochab::Server::new() // Create a new server
|
||||
.add_route("/", handle_base) // Register the base route (order irrelevant)
|
||||
.add_route("/route", handle_short) // Reigster the short route
|
||||
.add_route("/route/long", handle_long) // Register the long route
|
||||
.serve_ip("localhost:1965") // Start the server
|
||||
kochab::Server::new()
|
||||
.add_route("/", handle_base)
|
||||
.add_route("/route", handle_short)
|
||||
.add_route("/route/long", handle_long)
|
||||
.serve_unix("kochab.sock")
|
||||
.await
|
||||
}
|
||||
|
||||
async fn handle_base(req: Request) -> Result<Response> {
|
||||
let doc = generate_doc("base", &req);
|
||||
Ok(doc.into())
|
||||
Ok(generate_resp("base", &req))
|
||||
}
|
||||
|
||||
async fn handle_short(req: Request) -> Result<Response> {
|
||||
let doc = generate_doc("short", &req);
|
||||
Ok(doc.into())
|
||||
Ok(generate_resp("short", &req))
|
||||
}
|
||||
|
||||
async fn handle_long(req: Request) -> Result<Response> {
|
||||
let doc = generate_doc("long", &req);
|
||||
Ok(doc.into())
|
||||
Ok(generate_resp("long", &req))
|
||||
}
|
||||
|
||||
fn generate_doc(route_name: &str, req: &Request) -> Document {
|
||||
|
||||
// Trailing segments comes in as a Vec of segments, so we join them together for
|
||||
// display purposes
|
||||
fn generate_resp(route_name: &str, req: &Request) -> Response {
|
||||
let trailing = req.trailing_segments().join("/");
|
||||
|
||||
let mut doc = Document::new();
|
||||
doc.add_heading(HeadingLevel::H1, "Routing Demo")
|
||||
.add_text(&format!("You're currently on the {} route", route_name))
|
||||
.add_text(&format!("Trailing segments: /{}", trailing))
|
||||
.add_blank_line()
|
||||
.add_text("Here's some links to try:")
|
||||
.add_link_without_label("/")
|
||||
.add_link_without_label("/route")
|
||||
.add_link_without_label("/route/long")
|
||||
.add_link_without_label("/route/not_real")
|
||||
.add_link_without_label("/rowte");
|
||||
doc
|
||||
Gemtext::new()
|
||||
.heading(1, "Routing Demo")
|
||||
.text(&format!("You're currently on the {} route", route_name))
|
||||
.text(&format!("Trailing segments: /{}", trailing))
|
||||
.blank_line()
|
||||
.text("Here's some links to try:")
|
||||
.link("/", Option::<String>::None)
|
||||
.link("/route", Option::<String>::None)
|
||||
.link("/route/long", Option::<String>::None)
|
||||
.link("/route/not_real", Option::<String>::None)
|
||||
.link("/rowte", Option::<String>::None)
|
||||
.into()
|
||||
}
|
||||
|
|
|
@ -5,18 +5,7 @@ use log::LevelFilter;
|
|||
use kochab::Server;
|
||||
|
||||
#[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<()> {
|
||||
|
||||
// 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()
|
||||
.filter_module("kochab", LevelFilter::Debug)
|
||||
.init();
|
||||
|
@ -24,6 +13,6 @@ async fn main() -> Result<()> {
|
|||
Server::new()
|
||||
.add_route("/", PathBuf::from("public")) // Serve directory listings & file contents
|
||||
.add_route("/about", PathBuf::from("README.md")) // Serve a single file
|
||||
.serve_ip("localhost:1965")
|
||||
.serve_unix("kochab.sock")
|
||||
.await
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use anyhow::*;
|
||||
use log::LevelFilter;
|
||||
use kochab::{
|
||||
Document,
|
||||
Gemtext,
|
||||
Request,
|
||||
Response,
|
||||
Server,
|
||||
|
@ -35,7 +35,7 @@ async fn main() -> Result<()> {
|
|||
.add_um_routes::<String>()
|
||||
|
||||
// Start the server
|
||||
.serve_ip("localhost:1965")
|
||||
.serve_unix("kochab.sock")
|
||||
.await
|
||||
}
|
||||
|
||||
|
@ -50,12 +50,12 @@ async fn main() -> Result<()> {
|
|||
/// certificate will be prompted to add a certificate and register.
|
||||
async fn handle_main(_req: Request, user: RegisteredUser<String>) -> Result<Response> {
|
||||
// If the user is signed in, render and return their page
|
||||
let response = Document::new()
|
||||
.add_text("Your personal secret string:")
|
||||
.add_text(user.as_ref())
|
||||
.add_blank_line()
|
||||
.add_link("/update", "Change your string")
|
||||
.add_link("/account", "Update your account")
|
||||
let response = Gemtext::new()
|
||||
.text("Your personal secret string:")
|
||||
.text(user.as_ref())
|
||||
.blank_line()
|
||||
.link("/update", Some("Change your string".to_string()))
|
||||
.link("/account", Some("Update your account".to_string()))
|
||||
.into();
|
||||
Ok(response)
|
||||
}
|
||||
|
@ -72,10 +72,10 @@ async fn handle_update(_request: Request, mut user: RegisteredUser<String>, inpu
|
|||
*user.as_mut() = input;
|
||||
|
||||
// Render a response
|
||||
let response = Document::new()
|
||||
.add_text("String updated!")
|
||||
.add_blank_line()
|
||||
.add_link("/", "Back")
|
||||
let response = Gemtext::new()
|
||||
.text("String updated!")
|
||||
.blank_line()
|
||||
.link("/", Some("Back".to_string()))
|
||||
.into();
|
||||
Ok(response)
|
||||
|
||||
|
|
20
molly-brown.conf
Normal file
20
molly-brown.conf
Normal 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"
|
|
@ -3,19 +3,13 @@
|
|||
//! ⚠️ Docs still under construction & API not yet stable ⚠️
|
||||
#![allow(missing_docs)]
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(feature="serve_dir")]
|
||||
use tokio::{
|
||||
fs::{self, File},
|
||||
io,
|
||||
};
|
||||
#[cfg(feature="serve_dir")]
|
||||
use crate::types::{Document, document::HeadingLevel::*};
|
||||
#[cfg(feature="serve_dir")]
|
||||
use crate::types::Response;
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Response {
|
||||
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)
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Response {
|
||||
debug!("Dir: {}", dir.as_ref().display());
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
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 {
|
||||
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 mut document = Document::new();
|
||||
let mut document = gemtext::Builder::new();
|
||||
|
||||
document.add_heading(H1, format!("Index of /{}", breadcrumbs.display()));
|
||||
document.add_blank_line();
|
||||
document = document.heading(1, format!("Index of /{}", breadcrumbs.display()))
|
||||
.blank_line();
|
||||
|
||||
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") {
|
||||
|
@ -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 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 { '📄' },
|
||||
name = file_name,
|
||||
trailing_slash = trailing_slash
|
||||
));
|
||||
)));
|
||||
}
|
||||
|
||||
document.into()
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> &'static str {
|
||||
let path = path.as_ref();
|
||||
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")
|
||||
}
|
||||
|
||||
#[cfg(feature="serve_dir")]
|
||||
/// 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 {
|
||||
warn!(
|
||||
|
@ -159,19 +149,3 @@ pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32
|
|||
);
|
||||
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,
|
||||
{}
|
|
@ -13,7 +13,9 @@ use std::{
|
|||
#[cfg(feature = "serve_dir")]
|
||||
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.
|
||||
///
|
||||
|
@ -48,11 +50,11 @@ pub enum Handler {
|
|||
/// For serving files & directories, try looking at creating a [`FilesHandler`] by
|
||||
/// [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
|
||||
/// [`From<Response>`]: #impl-From<Response>
|
||||
/// [`From<Document>`]: #impl-From<%26'_%20Document>
|
||||
/// [`From<Gemtext>`]: #impl-From<Gemtext>
|
||||
StaticHandler(Response),
|
||||
|
||||
#[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>
|
||||
///
|
||||
/// 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`]
|
||||
///
|
||||
/// [`StaticHandler`]: Self::StaticHandler
|
||||
fn from(doc: &Document) -> Self {
|
||||
fn from(doc: Gemtext) -> Self {
|
||||
Self::StaticHandler(doc.into())
|
||||
}
|
||||
}
|
||||
|
|
85
src/lib.rs
85
src/lib.rs
|
@ -1,5 +1,4 @@
|
|||
#![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
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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.
|
||||
|
@ -137,19 +140,15 @@
|
|||
//! 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,
|
||||
//! 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
|
||||
//! already using cargo.
|
||||
//! mollybrown, which has very good SCGI support and is super easy to set up
|
||||
//!
|
||||
//! You can install stargazer by running.
|
||||
//! ```sh
|
||||
//! cargo install stargazer
|
||||
//! ```
|
||||
//! You can grab a copy of molly brown from [tildegit.org/solderpunk/molly-brown][1].
|
||||
//!
|
||||
//! Once you have it, you can find a super simple configuration file [here][2], and then
|
||||
//! just run
|
||||
//!
|
||||
//! ```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
|
||||
|
@ -177,7 +176,7 @@
|
|||
//! For more information, see [`Server::set_autorewrite()`].
|
||||
//!
|
||||
//! [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:"
|
||||
//! <style>
|
||||
//! img[alt=blobcat-pout] { width: 20px; vertical-align: top; }
|
||||
|
@ -185,13 +184,14 @@
|
|||
|
||||
#[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");
|
||||
|
||||
#[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");
|
||||
|
||||
use std::{
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
future::Future,
|
||||
};
|
||||
|
@ -235,7 +235,8 @@ use rustls::Session;
|
|||
|
||||
mod types;
|
||||
mod handling;
|
||||
pub mod util;
|
||||
#[cfg(feature = "serve_dir")]
|
||||
pub mod files;
|
||||
pub mod routing;
|
||||
#[cfg(feature = "ratelimiting")]
|
||||
mod ratelimiting;
|
||||
|
@ -253,39 +254,43 @@ pub use uriparse::URIReference;
|
|||
pub use types::*;
|
||||
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
|
||||
pub const REQUEST_URI_MAX_LEN: usize = 1024;
|
||||
|
||||
/// The default port for the gemini protocol
|
||||
pub const GEMINI_PORT: u16 = 1965;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerInner {
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
tls_acceptor: TlsAcceptor,
|
||||
routes: RoutingNode<Handler>,
|
||||
routes: Arc<RoutingNode<Handler>>,
|
||||
timeout: Duration,
|
||||
complex_timeout: Option<Duration>,
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
autorewrite: bool,
|
||||
#[cfg(feature="ratelimiting")]
|
||||
rate_limits: RoutingNode<RateLimiter<IpAddr>>,
|
||||
rate_limits: Arc<RoutingNode<RateLimiter<IpAddr>>>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager,
|
||||
}
|
||||
|
||||
impl ServerInner {
|
||||
async fn serve_ip(self, listener: TcpListener) -> Result<()> {
|
||||
let static_self: &'static Self = Box::leak(Box::new(self));
|
||||
|
||||
#[cfg(feature = "ratelimiting")]
|
||||
tokio::spawn(prune_ratelimit_log(&static_self.rate_limits));
|
||||
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
|
||||
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await
|
||||
.context("Failed to accept client")?;
|
||||
let this = self.clone();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
@ -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
|
||||
// getting for now
|
||||
async fn serve_unix(self, listener: UnixListener) -> Result<()> {
|
||||
let static_self: &'static Self = Box::leak(Box::new(self));
|
||||
|
||||
#[cfg(feature = "ratelimiting")]
|
||||
tokio::spawn(prune_ratelimit_log(&static_self.rate_limits));
|
||||
tokio::spawn(prune_ratelimit_log(self.rate_limits.clone()));
|
||||
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await
|
||||
.context("Failed to accept client")?;
|
||||
let this = self.clone();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
@ -314,10 +318,10 @@ impl ServerInner {
|
|||
}
|
||||
|
||||
async fn serve_client(
|
||||
&'static self,
|
||||
&self,
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
stream: TcpStream,
|
||||
#[cfg(all(feature = "scgi_srv", not(feature = "gemini_srv")))]
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
stream: impl AsyncWrite + AsyncRead + Unpin + Send,
|
||||
) -> Result<()> {
|
||||
let fut_accept_request = async {
|
||||
|
@ -435,7 +439,6 @@ impl ServerInner {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::useless_let_if_seq)]
|
||||
async fn send_response(&self, response: Response, stream: &mut (impl AsyncWrite + Unpin + Send)) -> Result<()> {
|
||||
let use_complex_timeout =
|
||||
response.body.is_some() &&
|
||||
|
@ -499,7 +502,7 @@ impl ServerInner {
|
|||
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
async fn receive_request(
|
||||
&'static self,
|
||||
&self,
|
||||
stream: &mut (impl AsyncBufRead + Unpin + Send),
|
||||
) -> Result<Request> {
|
||||
const HEADER_LIMIT: usize = REQUEST_URI_MAX_LEN + "\r\n".len();
|
||||
|
@ -527,13 +530,13 @@ impl ServerInner {
|
|||
Request::new(
|
||||
uri,
|
||||
#[cfg(feature="user_management")]
|
||||
&self.manager,
|
||||
self.manager.clone(),
|
||||
).context("Failed to create request from URI")
|
||||
}
|
||||
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
async fn receive_request(
|
||||
&'static self,
|
||||
&self,
|
||||
stream: &mut (impl AsyncBufRead + Unpin),
|
||||
) -> Result<Request> {
|
||||
let mut buff = Vec::with_capacity(4);
|
||||
|
@ -601,7 +604,7 @@ impl ServerInner {
|
|||
Request::new(
|
||||
headers,
|
||||
#[cfg(feature = "user_management")]
|
||||
&self.manager,
|
||||
self.manager.clone(),
|
||||
)?
|
||||
)
|
||||
}
|
||||
|
@ -675,7 +678,6 @@ pub struct Server {
|
|||
timeout: Duration,
|
||||
complex_body_timeout_override: Option<Duration>,
|
||||
routes: RoutingNode<Handler>,
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
autorewrite: bool,
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
cert_path: PathBuf,
|
||||
|
@ -698,7 +700,6 @@ impl Server {
|
|||
timeout: Duration::from_secs(1),
|
||||
complex_body_timeout_override: Some(Duration::from_secs(30)),
|
||||
routes: RoutingNode::default(),
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
autorewrite: false,
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
cert_path: PathBuf::from("cert/cert.pem"),
|
||||
|
@ -884,7 +885,6 @@ impl Server {
|
|||
self
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "gemini_srv", allow(unused_mut), allow(unused_variables))]
|
||||
/// Enable or disable autorewrite
|
||||
///
|
||||
/// 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,
|
||||
/// see [`Response::rewrite_all()`].
|
||||
pub fn set_autorewrite(mut self, autorewrite: bool) -> Self {
|
||||
#[cfg(feature = "scgi_srv")] {
|
||||
self.autorewrite = autorewrite;
|
||||
}
|
||||
self.autorewrite = autorewrite;
|
||||
self
|
||||
}
|
||||
|
||||
|
@ -968,15 +966,14 @@ impl Server {
|
|||
let data_dir = self.data_dir;
|
||||
|
||||
Ok(ServerInner {
|
||||
routes: self.routes,
|
||||
routes: Arc::new(self.routes),
|
||||
timeout: self.timeout,
|
||||
complex_timeout: self.complex_body_timeout_override,
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
autorewrite: self.autorewrite,
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
tls_acceptor: TlsAcceptor::from(config),
|
||||
#[cfg(feature="ratelimiting")]
|
||||
rate_limits: self.rate_limits,
|
||||
rate_limits: Arc::new(self.rate_limits),
|
||||
#[cfg(feature="user_management")]
|
||||
manager: UserManager::new(
|
||||
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
|
||||
/// "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<()> {
|
||||
let server = self.build()?;
|
||||
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
|
||||
/// 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<()> {
|
||||
let server = self.build()?;
|
||||
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")]
|
||||
/// 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 log = rate_limits.as_ref();
|
||||
loop {
|
||||
interval.tick().await;
|
||||
rate_limits.iter().for_each(RateLimiter::trim_keys_verbose);
|
||||
log.iter().for_each(RateLimiter::trim_keys_verbose);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,3 @@ pub use response::Response;
|
|||
|
||||
mod body;
|
||||
pub use body::Body;
|
||||
|
||||
pub mod document;
|
||||
pub use document::Document;
|
||||
|
|
|
@ -4,9 +4,8 @@ use tokio::io::AsyncReadExt;
|
|||
#[cfg(feature="serve_dir")]
|
||||
use tokio::fs::File;
|
||||
|
||||
use std::borrow::Borrow;
|
||||
|
||||
use crate::types::Document;
|
||||
#[cfg(feature = "gemtext")]
|
||||
use crate::Gemtext;
|
||||
|
||||
/// The body of a response
|
||||
///
|
||||
|
@ -70,9 +69,27 @@ impl Body {
|
|||
}
|
||||
}
|
||||
|
||||
impl<D: Borrow<Document>> From<D> for Body {
|
||||
fn from(document: D) -> Self {
|
||||
Self::from(document.borrow().to_string())
|
||||
#[cfg(feature = "gemtext")]
|
||||
#[allow(clippy::fallible_impl_from)] // It's really not fallible but thanks
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(" ")
|
||||
}
|
|
@ -1,7 +1,4 @@
|
|||
use std::{
|
||||
fmt::Write,
|
||||
ops,
|
||||
};
|
||||
use std::ops;
|
||||
#[cfg(feature = "gemini_srv")]
|
||||
use std::convert::TryInto;
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
|
@ -42,7 +39,7 @@ pub struct Request {
|
|||
certificate: Option<[u8; 32]>,
|
||||
trailing_segments: Option<Vec<String>>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: &'static UserManager,
|
||||
manager: UserManager,
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
headers: HashMap<String, String>,
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
|
@ -83,7 +80,7 @@ impl Request {
|
|||
#[cfg(feature = "scgi_srv")]
|
||||
headers: HashMap<String, String>,
|
||||
#[cfg(feature="user_management")]
|
||||
manager: &'static UserManager,
|
||||
manager: UserManager,
|
||||
) -> Result<Self> {
|
||||
#[cfg(feature = "scgi_srv")]
|
||||
#[allow(clippy::or_fun_call)] // Lay off it's a macro
|
||||
|
@ -250,43 +247,10 @@ impl Request {
|
|||
|
||||
#[allow(clippy::missing_const_for_fn)]
|
||||
/// 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]> {
|
||||
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")]
|
||||
/// Attempt to determine the user who sent this request
|
||||
///
|
||||
|
@ -296,15 +260,15 @@ impl Request {
|
|||
where
|
||||
UserData: Serialize + DeserializeOwned
|
||||
{
|
||||
Ok(self.manager.get_user_by_cert(self.certificate())?)
|
||||
Ok(self.manager.get_user(self.certificate())?)
|
||||
}
|
||||
|
||||
#[cfg(feature="user_management")]
|
||||
/// Expose the server's UserManager
|
||||
///
|
||||
/// Can be used to query users, or directly access the database
|
||||
pub fn user_manager(&self) -> &'static UserManager {
|
||||
self.manager
|
||||
pub fn user_manager(&self) -> &UserManager {
|
||||
&self.manager
|
||||
}
|
||||
|
||||
/// Attempt to rewrite an absolute URL against the base path of the SCGI script
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
use std::borrow::Borrow;
|
||||
|
||||
use crate::types::{Body, Document};
|
||||
use crate::types::Body;
|
||||
|
||||
/// A response to a client's [`Request`]
|
||||
///
|
||||
|
@ -180,16 +178,8 @@ impl 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 {
|
||||
self.status == 20
|
||||
self.status == 10
|
||||
}
|
||||
|
||||
#[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 {
|
||||
Self::success_gemini(doc)
|
||||
Self::success_gemini(doc.into())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
use std::convert::TryInto;
|
||||
|
||||
use crate::user_management::{User, Result};
|
||||
use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser};
|
||||
|
||||
|
@ -10,22 +8,9 @@ use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser}
|
|||
///
|
||||
/// Wraps a [`sled::Db`]
|
||||
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 (crate) users: sled::Tree, // user_id:u64 maps to data:PartialUser
|
||||
pub (crate) certificates: sled::Tree, // fingerprint:[u8; 32] maps to uid:u64
|
||||
pub (crate) usernames: sled::Tree, // username:String maps to uid:u64
|
||||
pub (crate) users: sled::Tree, // user_id:String maps to data:UserData
|
||||
pub (crate) certificates: sled::Tree, // certificate:u64 maps to data:CertificateData
|
||||
}
|
||||
|
||||
impl UserManager {
|
||||
|
@ -38,7 +23,6 @@ impl UserManager {
|
|||
Ok(Self {
|
||||
users: db.open_tree("gay.emii.kochab.users")?,
|
||||
certificates: db.open_tree("gay.emii.kochab.certificates")?,
|
||||
usernames: db.open_tree("gay.emii.kochab.usernames")?,
|
||||
db,
|
||||
})
|
||||
}
|
||||
|
@ -48,60 +32,29 @@ impl UserManager {
|
|||
/// # 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 certificate.
|
||||
pub fn lookup_certificate<UserData: Serialize + DeserializeOwned>(
|
||||
&self,
|
||||
cert: [u8; 32]
|
||||
) -> Result<Option<RegisteredUser<UserData>>> {
|
||||
pub fn lookup_certificate(&self, cert: [u8; 32]) -> Result<Option<String>> {
|
||||
if let Some(bytes) = self.certificates.get(cert)? {
|
||||
let id = u64::from_le_bytes(bytes.as_ref().try_into()?);
|
||||
Ok(Some(
|
||||
self.lookup_user(id)?
|
||||
.ok_or(super::DeserializeError::InvalidReference(id))?
|
||||
))
|
||||
Ok(Some(std::str::from_utf8(bytes.as_ref())?.to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the user with the specified 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
|
||||
/// 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
|
||||
pub fn lookup_user<UserData>(
|
||||
&self,
|
||||
uid: u64,
|
||||
username: impl AsRef<str>
|
||||
) -> Result<Option<RegisteredUser<UserData>>>
|
||||
where
|
||||
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())?;
|
||||
Ok(Some(RegisteredUser::new(uid, None, self.clone(), inner)))
|
||||
Ok(Some(RegisteredUser::new(username.as_ref().to_owned(), None, self.clone(), inner)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
|
@ -114,18 +67,20 @@ impl UserManager {
|
|||
/// the database is corrupt
|
||||
pub fn all_users<UserData>(
|
||||
&self,
|
||||
) -> impl Iterator<Item = Result<RegisteredUser<UserData>>>
|
||||
) -> Vec<RegisteredUser<UserData>>
|
||||
where
|
||||
UserData: Serialize + DeserializeOwned
|
||||
{
|
||||
let this = self.clone();
|
||||
self.users.iter()
|
||||
.map(move|result| {
|
||||
let (uid, bytes) = result?;
|
||||
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
|
||||
let uid = u64::from_le_bytes(uid.as_ref().try_into()?);
|
||||
Ok(RegisteredUser::new(uid, None, this.clone(), inner))
|
||||
.map(|result| {
|
||||
let (username, bytes) = result.expect("Failed to connect to database");
|
||||
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())
|
||||
.expect("Received malformed data from database");
|
||||
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.
|
||||
|
@ -136,7 +91,7 @@ impl UserManager {
|
|||
///
|
||||
/// # Panics
|
||||
/// Pancis if the database is corrupt
|
||||
pub fn get_user_by_cert<UserData>(
|
||||
pub fn get_user<UserData>(
|
||||
&self,
|
||||
cert: Option<&[u8; 32]>
|
||||
) -> Result<User<UserData>>
|
||||
|
@ -144,7 +99,9 @@ impl UserManager {
|
|||
UserData: Serialize + DeserializeOwned
|
||||
{
|
||||
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)))
|
||||
} else {
|
||||
Ok(User::NotSignedIn(NotSignedInUser {
|
||||
|
|
|
@ -33,45 +33,14 @@ use user::{NotSignedInUser, RegisteredUser};
|
|||
use crate::types::Request;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// An error that occured in the user manager
|
||||
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,
|
||||
|
||||
/// 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,
|
||||
|
||||
/// There was an error connecting to sled
|
||||
///
|
||||
/// Recommended handling: Log a visible error for the sysadmin to see, and exit
|
||||
DatabaseError(sled::Error),
|
||||
|
||||
/// There was an error running a database transaction
|
||||
///
|
||||
/// Recommended handling: Same as [`UserManagerError::DatabaseError`]
|
||||
DatabaseTransactionError(sled::transaction::TransactionError),
|
||||
|
||||
/// There was 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.
|
||||
///
|
||||
/// Recommended handling: Log a visible error and exit. Recommend seeking a database
|
||||
/// migration script or deleting the database
|
||||
DeserializeError(DeserializeError),
|
||||
|
||||
DeserializeBincodeError(bincode::Error),
|
||||
DeserializeUtf8Error(std::str::Utf8Error),
|
||||
#[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),
|
||||
}
|
||||
|
||||
|
@ -89,25 +58,13 @@ impl From<sled::transaction::TransactionError> for UserManagerError {
|
|||
|
||||
impl From<bincode::Error> for UserManagerError {
|
||||
fn from(error: bincode::Error) -> Self {
|
||||
Self::DeserializeError(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::array::TryFromSliceError> for UserManagerError {
|
||||
fn from(error: std::array::TryFromSliceError) -> Self {
|
||||
Self::DeserializeError(error.into())
|
||||
Self::DeserializeBincodeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::str::Utf8Error> for UserManagerError {
|
||||
fn from(error: std::str::Utf8Error) -> Self {
|
||||
Self::DeserializeError(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeserializeError> for UserManagerError {
|
||||
fn from(error: DeserializeError) -> Self {
|
||||
Self::DeserializeError(error)
|
||||
Self::DeserializeUtf8Error(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -123,7 +80,8 @@ impl std::error::Error for UserManagerError {
|
|||
match self {
|
||||
Self::DatabaseError(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")]
|
||||
Self::Argon2Error(e) => Some(e),
|
||||
_ => None
|
||||
|
@ -142,8 +100,10 @@ impl std::fmt::Display for UserManagerError {
|
|||
write!(f, "Error accessing the user database: {}", e),
|
||||
Self::DatabaseTransactionError(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),
|
||||
Self::DeserializeUtf8Error(e) =>
|
||||
write!(f, "Recieved invalid UTF-8 from database, possible corruption: {}", e),
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
Self::Argon2Error(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>;
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
# Account Deleted
|
||||
|
||||
Your account has been successfully deleted.
|
||||
|
||||
=> / Back to app
|
|
@ -10,8 +10,7 @@ use std::sync::RwLock;
|
|||
|
||||
use std::future::Future;
|
||||
|
||||
use crate::{Document, Request, Response};
|
||||
use crate::types::document::HeadingLevel;
|
||||
use crate::{Gemtext, Request, Response};
|
||||
use crate::user_management::{
|
||||
User,
|
||||
RegisteredUser,
|
||||
|
@ -29,7 +28,6 @@ pub trait UserManagementRoutes: private::Sealed {
|
|||
/// * `/account/register`, for users to register a new account
|
||||
/// * `/account/login`, for users to link their certificate to an existing account
|
||||
/// * `/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
|
||||
/// 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
|
||||
/// 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
|
||||
///
|
||||
|
@ -94,15 +92,14 @@ impl UserManagementRoutes for crate::Server {
|
|||
/// Add pre-configured routes to the serve to handle authentication
|
||||
///
|
||||
/// 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"));
|
||||
|
||||
#[allow(unused_mut)]
|
||||
let mut modified_self = self.add_route("/account", handle_base::<UserData>)
|
||||
.add_route("/account/askcert", handle_ask_cert::<UserData>)
|
||||
.add_route("/account/register", handle_register::<UserData>)
|
||||
.add_route("/account/clients", clients_page)
|
||||
.add_authenticated_route("/account/delete", handle_delete::<UserData>);
|
||||
.add_route("/account/clients", clients_page);
|
||||
|
||||
#[cfg(feature = "user_management_advanced")] {
|
||||
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")]
|
||||
async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
|
||||
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>(
|
||||
user: RegisteredUser<UserData>
|
||||
) -> Response {
|
||||
let mut document = Document::new();
|
||||
document
|
||||
.add_heading(HeadingLevel::H1, "User Settings")
|
||||
.add_blank_line()
|
||||
.add_text(&format!("Welcome {}!", user.username()))
|
||||
.add_blank_line()
|
||||
.add_link(get_redirect(&user).as_str(), "Back to the app")
|
||||
.add_blank_line();
|
||||
#[cfg_attr(not(feature = "user_management_advanced"), allow(unused_mut))]
|
||||
let mut document = Gemtext::new()
|
||||
.heading(1, "User Settings")
|
||||
.blank_line()
|
||||
.text(&format!("Welcome {}!", user.username()))
|
||||
.blank_line()
|
||||
.link(get_redirect(&user).as_str(), Some("Back to the app".to_string()))
|
||||
.blank_line();
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
document
|
||||
.add_text(
|
||||
if user.has_password() {
|
||||
concat!(
|
||||
"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",
|
||||
" password, or would like to change it, you may do so here.",
|
||||
#[cfg(feature = "user_management_advanced")] {
|
||||
document = document
|
||||
.text(
|
||||
if user.has_password() {
|
||||
concat!(
|
||||
"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",
|
||||
" 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
|
||||
.add_link("/account/delete", "Delete your account")
|
||||
.into()
|
||||
document.into()
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
use serde::{Deserialize, Serialize, de::DeserializeOwned};
|
||||
use sled::Transactional;
|
||||
|
||||
#[cfg(not(feature = "ring"))]
|
||||
#[cfg(all(not(feature = "ring"), feature = "user_management_advanced"))]
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::user_management::UserManager;
|
||||
|
@ -48,7 +48,6 @@ lazy_static::lazy_static! {
|
|||
pub (crate) struct PartialUser<UserData> {
|
||||
pub data: UserData,
|
||||
pub certificates: Vec<[u8; 32]>,
|
||||
pub username: String,
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
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
|
||||
/// 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
|
||||
UserData: Serialize
|
||||
{
|
||||
tree.insert(
|
||||
uid.to_le_bytes(),
|
||||
&username,
|
||||
bincode::serialize(&self)?,
|
||||
)?;
|
||||
Ok(())
|
||||
|
@ -118,48 +117,26 @@ impl NotSignedInUser {
|
|||
if self.manager.users.contains_key(username.as_str())? {
|
||||
Err(super::UserManagerError::UsernameNotUnique)
|
||||
} else {
|
||||
info!("User {} registered!", username);
|
||||
|
||||
// Create the partial user that will go into the database. We can't create
|
||||
// the full user yet, since the ID won't be generated until we perform the
|
||||
// insert.
|
||||
let partial = PartialUser {
|
||||
let mut newser = RegisteredUser::new(
|
||||
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),
|
||||
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,
|
||||
password: Option<&[u8]>,
|
||||
) -> 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
|
||||
if let Some(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.active_certificate = Some(self.certificate);
|
||||
Ok(Some(user))
|
||||
|
@ -217,7 +195,7 @@ impl NotSignedInUser {
|
|||
///
|
||||
/// For more information about the user lifecycle and sign-in stages, see [`User`]
|
||||
pub struct RegisteredUser<UserData: Serialize + DeserializeOwned> {
|
||||
uid: u64,
|
||||
username: String,
|
||||
active_certificate: Option<[u8; 32]>,
|
||||
manager: UserManager,
|
||||
inner: PartialUser<UserData>,
|
||||
|
@ -229,13 +207,13 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
|||
|
||||
/// Create a new user from parts
|
||||
pub (crate) fn new(
|
||||
uid: u64,
|
||||
username: String,
|
||||
active_certificate: Option<[u8; 32]>,
|
||||
manager: UserManager,
|
||||
inner: PartialUser<UserData>
|
||||
) -> Self {
|
||||
Self {
|
||||
uid,
|
||||
username,
|
||||
active_certificate,
|
||||
manager,
|
||||
inner,
|
||||
|
@ -268,19 +246,9 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
|||
|
||||
/// Get the user's current username.
|
||||
///
|
||||
/// NOTE: This is not guaranteed not to change. If you need an immutable reference to
|
||||
/// this user, prefer their [UID], which is guaranteed static.
|
||||
///
|
||||
/// [UID]: Self::uid()
|
||||
/// NOTE: This is not guaranteed not to change.
|
||||
pub fn username(&self) -> &String {
|
||||
&self.inner.username
|
||||
}
|
||||
|
||||
/// Get the user's id.
|
||||
///
|
||||
/// This is not guaranteed not to change.
|
||||
pub fn uid(&self) -> u64 {
|
||||
self.uid
|
||||
&self.username
|
||||
}
|
||||
|
||||
#[cfg(feature = "user_management_advanced")]
|
||||
|
@ -294,18 +262,13 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
|||
try_password: impl AsRef<[u8]>
|
||||
) -> Result<bool> {
|
||||
if let Some((hash, salt)) = &self.inner.pass_hash {
|
||||
let result = argon2::verify_raw(
|
||||
Ok(argon2::verify_raw(
|
||||
try_password.as_ref(),
|
||||
salt,
|
||||
hash.as_ref(),
|
||||
&ARGON2_CONFIG,
|
||||
)?;
|
||||
if !result {
|
||||
info!("Someone failed to log in to the account of {} (wrong)", self);
|
||||
}
|
||||
Ok(result)
|
||||
)?)
|
||||
} else {
|
||||
info!("Someone failed to log in to the account of {} (not set)", self);
|
||||
Err(super::UserManagerError::PasswordNotSet)
|
||||
}
|
||||
}
|
||||
|
@ -354,8 +317,6 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
|||
salt,
|
||||
));
|
||||
self.has_changed = true;
|
||||
|
||||
info!("Updated password for user {}", self);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -367,9 +328,8 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
|||
where
|
||||
UserData: Serialize
|
||||
{
|
||||
self.inner.store(&self.manager.users, self.uid)?;
|
||||
self.inner.store(&self.manager.users, &self.username)?;
|
||||
self.has_changed = false;
|
||||
debug!("Changes to user {} saved", self);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -386,67 +346,20 @@ impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
|
|||
self.inner.certificates.push(certificate);
|
||||
|
||||
let inner_serialized = bincode::serialize(&self.inner)?;
|
||||
let uid_bytes = self.uid.to_le_bytes();
|
||||
|
||||
(&self.manager.users, &self.manager.certificates)
|
||||
.transaction(|(tx_usr, tx_crt)| {
|
||||
tx_usr.insert(
|
||||
&uid_bytes,
|
||||
self.username.as_str(),
|
||||
inner_serialized.clone(),
|
||||
)?;
|
||||
tx_crt.insert(
|
||||
&certificate,
|
||||
&uid_bytes,
|
||||
self.username.as_bytes(),
|
||||
)?;
|
||||
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(())
|
||||
}
|
||||
|
||||
|
@ -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> {
|
||||
fn drop(&mut self) {
|
||||
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")))]
|
||||
/// Inexpensive but low quality random
|
||||
fn pcg8(state: &mut u16) -> u8 {
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue