Merge branch 'devel' into gemtext

This commit is contained in:
Emi Tatsuo 2020-12-07 17:29:34 -05:00
commit 9ec1a5663d
Signed by: Emi
GPG Key ID: 68FAB2E2E6DFC98B
49 changed files with 3938 additions and 893 deletions

4
.gemgit Normal file
View File

@ -0,0 +1,4 @@
name = "Kochab"
url = "https://gitlab.com/Alch_Emi/kochab.git"
desc = "Kochab is an ergonomic and intuitive library for quickly building highly functional and advanced Gemini applications on either SCGI or raw Gemini."
readme = "README.md"

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/target
Cargo.lock
/cert/
/data/
/public/

View File

@ -4,38 +4,4 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- `document` API for creating Gemini documents
- preliminary timeout API, incl a special case for complex MIMEs by [@Alch-Emi]
- `Response::success_*` variants by [@Alch-Emi]
- `redirect_temporary_lossy` for `Response` and `ResponseHeader`
- `bad_request_lossy` for `Response` and `ResponseHeader`
- support for a lot more mime-types in `guess_mime_from_path`, backed by the `mime_guess` crate
- customizable TLS cert & key paths by [@Alch-Emi]
- `server_dir` default feature for serve_dir utils [@Alch-Emi]
- Docments can be converted into responses with std::convert::Into [@Alch-Emi]
### Improved
- build time and size by [@Alch-Emi]
- Improved error handling in serve_dir [@Alch-Emi]
## [0.3.0] - 2020-11-14
### Added
- `GEMINI_MIME_STR`, the `&str` representation of the Gemini MIME
- `Meta::new_lossy`, constructor that never fails
- `Meta::MAX_LEN`, which is `1024`
- "lossy" constructors for `Response` and `Status` (see `Meta::new_lossy`)
### Changed
- `Meta::new` now rejects strings exceeding `Meta::MAX_LEN` (`1024`)
- Some `Response` and `Status` constructors are now infallible
- Improve error messages
### Deprecated
- Instead of `gemini_mime()` use `GEMINI_MIME`
## [0.2.0] - 2020-11-14
### Added
- Access to client certificates by [@Alch-Emi]
[@Alch-Emi]: https://github.com/Alch-Emi
As of yet no versions have been released

View File

@ -1,37 +1,57 @@
[package]
name = "northstar"
version = "0.3.1"
authors = ["panicbit <panicbit.dev@gmail.com>"]
name = "kochab"
version = "0.1.0-unreleased"
description = "Ergonomic Gemini SDK"
authors = ["Emii Tatsuo <emi@alchemi.dev>", "panicbit <panicbit.dev@gmail.com>"]
license = "Hippocratic-2.1"
keywords = ["gemini", "server", "smallnet"]
categories = ["asynchronous", "network-programming"]
edition = "2018"
license = "MIT OR Apache-2.0"
description = "Gemini server implementation"
repository = "https://github.com/panicbit/northstar"
documentation = "https://docs.rs/northstar"
repository = "https://gitlab.com/Alch_Emi/kochab"
readme = "README.md"
include = ["src/**", "Cargo.*", "CHANGELOG.md", "LICENSE*", "README.md"]
[features]
default = ["serve_dir"]
default = ["certgen"]
user_management = ["sled", "bincode", "serde/derive", "crc32fast", "lazy_static"]
user_management_advanced = ["rust-argon2", "user_management"]
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"]
scgi_srv = ["base64"]
[dependencies]
anyhow = "1.0.33"
rustls = { version = "0.18.1", features = ["dangerous_configuration"] }
tokio-rustls = "0.20.0"
tokio = { version = "0.3.1", features = ["io-util","net","time", "rt"] }
mime = "0.3.16"
uriparse = "0.6.3"
percent-encoding = "2.1.0"
futures-core = "0.3.7"
log = "0.4.11"
webpki = "0.21.0"
lazy_static = "1.4.0"
ring = { version = "0.16.15", optional = true }
base64 = { version = "0.13.0", optional = true }
lazy_static = { version = "1.4.0", optional = true }
rustls = { version = "0.19", features = ["dangerous_configuration"], optional = true}
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 }
serde = { version = "1.0", optional = true }
rust-argon2 = { version = "0.8.2", optional = true }
crc32fast = { version = "1.2.1", optional = true }
rcgen = { version = "0.8.5", optional = true }
[dev-dependencies]
env_logger = "0.8.1"
futures-util = "0.3.7"
tokio = { version = "0.3.1", features = ["macros", "rt-multi-thread", "sync"] }
[[example]]
name = "user_management"
required-features = ["user_management_routes"]
[[example]]
name = "serve_dir"
required-features = ["serve_dir"]
@ -39,3 +59,7 @@ required-features = ["serve_dir"]
[[example]]
name = "document"
required-features = ["gemtext"]
[[example]]
name = "ratelimiting"
required-features = ["ratelimiting"]

33
LICENSE.md Normal file
View File

@ -0,0 +1,33 @@
kochab Copyright 2020 Emii Tatsuo & panicbit (“Licensor”)
Hippocratic License Version Number: 2.1.
Purpose. The purpose of this License is for the Licensor named above to permit the Licensee (as defined below) broad permission, if consistent with Human Rights Laws and Human Rights Principles (as each is defined below), to use and work with the Software (as defined below) within the full scope of Licensors copyright and patent rights, if any, in the Software, while ensuring attribution and protecting the Licensor from liability.
Permission and Conditions. The Licensor grants permission by this license (“License”), free of charge, to the extent of Licensors rights under applicable copyright and patent law, to any person or entity (the “Licensee”) obtaining a copy of this software and associated documentation files (the “Software”), to do everything with the Software that would otherwise infringe (i) the Licensors copyright in the Software or (ii) any patent claims to the Software that the Licensor can license or becomes able to license, subject to all of the following terms and conditions:
* Acceptance. This License is automatically offered to every person and entity subject to its terms and conditions. Licensee accepts this License and agrees to its terms and conditions by taking any action with the Software that, absent this License, would infringe any intellectual property right held by Licensor.
* Notice. Licensee must ensure that everyone who gets a copy of any part of this Software from Licensee, with or without changes, also receives the License and the above copyright notice (and if included by the Licensor, patent, trademark and attribution notice). Licensee must cause any modified versions of the Software to carry prominent notices stating that Licensee changed the Software. For clarity, although Licensee is free to create modifications of the Software and distribute only the modified portion created by Licensee with additional or different terms, the portion of the Software not modified must be distributed pursuant to this License. If anyone notifies Licensee in writing that Licensee has not complied with this Notice section, Licensee can keep this License by taking all practical steps to comply within 30 days after the notice. If Licensee does not do so, Licensees License (and all rights licensed hereunder) shall end immediately.
* Compliance with Human Rights Principles and Human Rights Laws.
1. Human Rights Principles.
(a) Licensee is advised to consult the articles of the United Nations Universal Declaration of Human Rights and the United Nations Global Compact that define recognized principles of international human rights (the “Human Rights Principles”). Licensee shall use the Software in a manner consistent with Human Rights Principles.
(b) Unless the Licensor and Licensee agree otherwise, any dispute, controversy, or claim arising out of or relating to (i) Section 1(a) regarding Human Rights Principles, including the breach of Section 1(a), termination of this License for breach of the Human Rights Principles, or invalidity of Section 1(a) or (ii) a determination of whether any Law is consistent or in conflict with Human Rights Principles pursuant to Section 2, below, shall be settled by arbitration in accordance with the Hague Rules on Business and Human Rights Arbitration (the “Rules”); provided, however, that Licensee may elect not to participate in such arbitration, in which event this License (and all rights licensed hereunder) shall end immediately. The number of arbitrators shall be one unless the Rules require otherwise.
Unless both the Licensor and Licensee agree to the contrary: (1) All documents and information concerning the arbitration shall be public and may be disclosed by any party; (2) The repository referred to under Article 43 of the Rules shall make available to the public in a timely manner all documents concerning the arbitration which are communicated to it, including all submissions of the parties, all evidence admitted into the record of the proceedings, all transcripts or other recordings of hearings and all orders, decisions and awards of the arbitral tribunal, subject only to the arbitral tribunal's powers to take such measures as may be necessary to safeguard the integrity of the arbitral process pursuant to Articles 18, 33, 41 and 42 of the Rules; and (3) Article 26(6) of the Rules shall not apply.
2. Human Rights Laws. The Software shall not be used by any person or entity for any systems, activities, or other uses that violate any Human Rights Laws. “Human Rights Laws” means any applicable laws, regulations, or rules (collectively, “Laws”) that protect human, civil, labor, privacy, political, environmental, security, economic, due process, or similar rights; provided, however, that such Laws are consistent and not in conflict with Human Rights Principles (a dispute over the consistency or a conflict between Laws and Human Rights Principles shall be determined by arbitration as stated above). Where the Human Rights Laws of more than one jurisdiction are applicable or in conflict with respect to the use of the Software, the Human Rights Laws that are most protective of the individuals or groups harmed shall apply.
3. Indemnity. Licensee shall hold harmless and indemnify Licensor (and any other contributor) against all losses, damages, liabilities, deficiencies, claims, actions, judgments, settlements, interest, awards, penalties, fines, costs, or expenses of whatever kind, including Licensors reasonable attorneys fees, arising out of or relating to Licensees use of the Software in violation of Human Rights Laws or Human Rights Principles.
* Failure to Comply. Any failure of Licensee to act according to the terms and conditions of this License is both a breach of the License and an infringement of the intellectual property rights of the Licensor (subject to exceptions under Laws, e.g., fair use). In the event of a breach or infringement, the terms and conditions of this License may be enforced by Licensor under the Laws of any jurisdiction to which Licensee is subject. Licensee also agrees that the Licensor may enforce the terms and conditions of this License against Licensee through specific performance (or similar remedy under Laws) to the extent permitted by Laws. For clarity, except in the event of a breach of this License, infringement, or as otherwise stated in this License, Licensor may not terminate this License with Licensee.
* Enforceability and Interpretation. If any term or provision of this License is determined to be invalid, illegal, or unenforceable by a court of competent jurisdiction, then such invalidity, illegality, or unenforceability shall not affect any other term or provision of this License or invalidate or render unenforceable such term or provision in any other jurisdiction; provided, however, subject to a court modification pursuant to the immediately following sentence, if any term or provision of this License pertaining to Human Rights Laws or Human Rights Principles is deemed invalid, illegal, or unenforceable against Licensee by a court of competent jurisdiction, all rights in the Software granted to Licensee shall be deemed null and void as between Licensor and Licensee. Upon a determination that any term or provision is invalid, illegal, or unenforceable, to the extent permitted by Laws, the court may modify this License to affect the original purpose that the Software be used in compliance with Human Rights Principles and Human Rights Laws as closely as possible. The language in this License shall be interpreted as to its fair meaning and not strictly for or against any party.
* Disclaimer. TO THE FULL EXTENT ALLOWED BY LAW, THIS SOFTWARE COMES “AS IS,” WITHOUT ANY WARRANTY, EXPRESS OR IMPLIED, AND LICENSOR AND ANY OTHER CONTRIBUTOR SHALL NOT BE LIABLE TO ANYONE FOR ANY DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THIS LICENSE, UNDER ANY KIND OF LEGAL CLAIM.
This Hippocratic License is an Ethical Source license (https://ethicalsource.dev) and is offered for use by licensors and licensees at their own risk, on an “AS IS” basis, and with no warranties express or implied, to the maximum extent permitted by Laws.

9
LICENSE_NORTHSTAR.md Normal file
View File

@ -0,0 +1,9 @@
Several components of this software were written by panicbit, and are released under the MIT License, available below. The specific elements written by panicbit can be found on panicbit's GitHub repo (https://github.com/panicbit/northstar), or by examining the git history. Please note that this project as a whole is licensed under *Hippocratic 2.1* and is **NOT** available under the MIT license.
```
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```

View File

@ -1,37 +1,29 @@
```
__ __ __
____ ____ _____/ /_/ /_ _____/ /_____ ______
/ __ \/ __ \/ ___/ __/ __ \/ ___/ __/ __ `/ ___/
/ / / / /_/ / / / /_/ / / (__ ) /_/ /_/ / /
/_/ /_/\____/_/ \__/_/ /_/____/\__/\__,_/_/
*. ,.-*,,..
.` `. .,-'` ````--*,,,..
.` ;*` ```''-o
* ,' __ __ __
`. ,' / /______ _____/ /_ ____ _/ /_
⭐ / //_/ __ \/ ___/ __ \/ __ `/ __ \
/ ,< / /_/ / /__/ / / / /_/ / /_/ /
/_/|_|\____/\___/_/ /_/\__,_/_.___/
```
# kochab
- [Documentation](https://docs.rs/northstar)
- [GitHub](https://github.com/panicbit/northstar)
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
Add the latest version of northstar to your `Cargo.toml`.
## Manually
It is currently only possible to use kochab through it's git repo, although it may wind up on crates.rs someday.
```toml
northstar = "0.3.0" # check crates.io for the latest version
```
## Automatically
```sh
cargo add northstar
kochab = { git = "https://gitlab.com/Alch_Emi/kochab.git", branch = "stable" }
```
# Generating a key & certificate
Run
```sh
mkdir cert && cd cert
openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365
```
and enter your domain name (e.g. "localhost" for testing) as Common Name (CN).
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`.
Alternatively, if you want to include multiple domains add something like `-addext "subjectAltName = DNS:localhost, DNS:example.org"`.
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
[northstar]: https://github.com/panicbit/northstar "Northstar GitHub"

View File

@ -1,71 +1,33 @@
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use anyhow::Result;
use log::LevelFilter;
use tokio::sync::RwLock;
use northstar::{Certificate, GEMINI_PORT, Request, Response, Server};
use std::collections::HashMap;
use std::sync::Arc;
// Workaround for Certificates not being hashable
type CertBytes = Vec<u8>;
use std::fmt::Write;
use kochab::{Request, Response, Server};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::builder()
.filter_module("northstar", LevelFilter::Debug)
.filter_module("kochab", LevelFilter::Debug)
.init();
let users = Arc::<RwLock::<HashMap<CertBytes, String>>>::default();
Server::bind(("0.0.0.0", GEMINI_PORT))
.serve(move|req| handle_request(users.clone(), req))
Server::new()
.add_route("/", handle_request)
.serve_unix("kochab.sock")
.await
}
/// An ultra-simple demonstration of simple authentication.
///
/// If the user attempts to connect, they will be prompted to create a client certificate.
/// Once they've made one, they'll be given the opportunity to create an account by
/// selecting a username. They'll then get a message confirming their account creation.
/// Any time this user visits the site in the future, they'll get a personalized welcome
/// message.
fn handle_request(users: Arc<RwLock<HashMap<CertBytes, String>>>, request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
if let Some(Certificate(cert_bytes)) = request.certificate() {
// The user provided a certificate
let users_read = users.read().await;
if let Some(user) = users_read.get(cert_bytes) {
// The user has already registered
Ok(
Response::success_gemini(
format!("Welcome {}!", user)
)
)
} else {
// The user still needs to register
drop(users_read);
if let Some(query_part) = request.uri().query() {
// The user provided some input (a username request)
let username = query_part.as_str();
let mut users_write = users.write().await;
users_write.insert(cert_bytes.clone(), username.to_owned());
Ok(
Response::success_gemini(
format!(
"Your account has been created {}! Welcome!",
username
)
)
)
} else {
// The user didn't provide input, and should be prompted
Response::input("What username would you like?")
}
}
} else {
// The user didn't provide a certificate
Ok(Response::client_certificate_required())
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");
for byte in fingerprint {
write!(&mut message, "{:x}", byte).unwrap();
}
}.boxed()
Ok(Response::success_plain(message))
} else {
// The user didn't provide a certificate
Ok(Response::client_certificate_required("You didn't provide a client certificate"))
}
}

View File

@ -1,20 +1,43 @@
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use log::LevelFilter;
use northstar::{Server, Request, Response, GEMINI_PORT, Gemtext};
use kochab::{Server, Response, Gemtext};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::builder()
.filter_module("northstar", LevelFilter::Debug)
.filter_module("kochab", LevelFilter::Debug)
.init();
Server::bind(("localhost", GEMINI_PORT))
.serve(handle_request)
.await
}
let response: Response = Document::new()
.add_preformatted_with_alt("kochab", include_str!("kochab_logo.txt"))
.add_blank_line()
.add_text(
concat!(
"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."
)
)
.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!(
"mkdir cert && cd cert\n",
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
))
.into();
<<<<<<< HEAD
fn handle_request(_request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
let response = Gemtext::new()
@ -45,4 +68,41 @@ fn handle_request(_request: Request) -> BoxFuture<'static, Result<Response>> {
Ok(response)
}
.boxed()
||||||| merged common ancestors
fn handle_request(_request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
let response = Document::new()
.add_preformatted(include_str!("northstar_logo.txt"))
.add_blank_line()
.add_link("https://docs.rs/northstar", "Documentation")
.add_link("https://github.com/panicbit/northstar", "GitHub")
.add_blank_line()
.add_heading(H1, "Usage")
.add_blank_line()
.add_text("Add the latest version of northstar to your `Cargo.toml`.")
.add_blank_line()
.add_heading(H2, "Manually")
.add_blank_line()
.add_preformatted_with_alt("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#)
.add_blank_line()
.add_heading(H2, "Automatically")
.add_blank_line()
.add_preformatted_with_alt("sh", "cargo add northstar")
.add_blank_line()
.add_heading(H1, "Generating a key & certificate")
.add_blank_line()
.add_preformatted_with_alt("sh", concat!(
"mkdir cert && cd cert\n",
"openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365",
))
.into();
Ok(response)
}
.boxed()
=======
Server::new()
.add_route("/", response)
.serve_unix("kochab.sock")
.await
>>>>>>> devel
}

8
examples/kochab_logo.txt Normal file
View File

@ -0,0 +1,8 @@
*. ,.-*,,..
.` `. .,-'` ````--*,,,..
.` ;*` ```''-o
* ,' __ __ __
`. ,' / /______ _____/ /_ ____ _/ /_
⭐ / //_/ __ \/ ___/ __ \/ __ `/ __ \
/ ,< / /_/ / /__/ / / / /_/ / /_/ /
/_/|_|\____/\___/_/ /_/\__,_/_.___/

View File

@ -1,5 +0,0 @@
__ __ __
____ ____ _____/ /_/ /_ _____/ /_____ ______
/ __ \/ __ \/ ___/ __/ __ \/ ___/ __/ __ `/ ___/
/ / / / /_/ / / / /_/ / / (__ ) /_/ /_/ / /
/_/ /_/\____/_/ \__/_/ /_/____/\__/\__,_/_/

34
examples/ratelimiting.rs Normal file
View File

@ -0,0 +1,34 @@
use std::time::Duration;
use anyhow::*;
use log::LevelFilter;
use kochab::{Server, Request, Response, Document};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
Server::new()
.add_route("/", handle_request)
.ratelimit("/limit", 2, Duration::from_secs(60))
.serve_unix("kochab.sock")
.await
}
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 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");
Ok(document.into())
}

48
examples/routing.rs Normal file
View File

@ -0,0 +1,48 @@
use anyhow::*;
use log::LevelFilter;
use kochab::{Document, document::HeadingLevel, Request, Response};
#[tokio::main]
async fn main() -> Result<()> {
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
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())
}
async fn handle_short(req: Request) -> Result<Response> {
let doc = generate_doc("short", &req);
Ok(doc.into())
}
async fn handle_long(req: Request) -> Result<Response> {
let doc = generate_doc("long", &req);
Ok(doc.into())
}
fn generate_doc(route_name: &str, req: &Request) -> Document {
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
}

View File

@ -1,26 +1,18 @@
use std::path::PathBuf;
use anyhow::*;
use futures_core::future::BoxFuture;
use futures_util::FutureExt;
use log::LevelFilter;
use northstar::{Server, Request, Response, GEMINI_PORT};
use kochab::Server;
#[tokio::main]
async fn main() -> Result<()> {
env_logger::builder()
.filter_module("northstar", LevelFilter::Debug)
.filter_module("kochab", LevelFilter::Debug)
.init();
Server::bind(("localhost", GEMINI_PORT))
.serve(handle_request)
Server::new()
.add_route("/", PathBuf::from("public")) // Serve directory listings & file contents
.add_route("/about", PathBuf::from("README.md")) // Serve a single file
.serve_unix("kochab.sock")
.await
}
fn handle_request(request: Request) -> BoxFuture<'static, Result<Response>> {
async move {
let path = request.path_segments();
let response = northstar::util::serve_dir("public", &path).await?;
Ok(response)
}
.boxed()
}

View File

@ -0,0 +1,82 @@
use anyhow::*;
use log::LevelFilter;
use kochab::{
Document,
Request,
Response,
Server,
user_management::{
user::RegisteredUser,
UserManagementRoutes,
},
};
#[tokio::main]
/// An ultra-simple demonstration of authentication.
///
/// The user should be able to set a secret string that only they can see. They should be
/// able to change this at any time to any thing. Both the string and the user account
/// will persist across restarts.
///
/// This method sets up and starts the server
async fn main() -> Result<()> {
// Turn on logging
env_logger::builder()
.filter_module("kochab", LevelFilter::Debug)
.init();
Server::new()
// Add our main routes
.add_authenticated_route("/", handle_main)
.add_authenticated_input_route("/update", "Enter your new string:", handle_update)
// Add routes for handling user authentication
.add_um_routes::<String>()
// Start the server
.serve_unix("kochab.sock")
.await
}
/// The landing page
///
/// Displays the user's current secret string, or prompts the user to sign in if they
/// haven't. Includes links to update your string (`/update`) or your account
/// (`/account`). Even though we haven't added an explicit handler for `/account`, this
/// route is managed by kochab.
///
/// Because this route is registered as an authenticated route, any connections without a
/// 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")
.into();
Ok(response)
}
/// The update endpoint
///
/// Users can update their secret string here.
async fn handle_update(_request: Request, mut user: RegisteredUser<String>, input: String) -> Result<Response> {
// The user has already been prompted to log in if they weren't and asked to give an
// input string, so all we need to do is...
// Update the users data
*user.as_mut() = input;
// Render a response
let response = Document::new()
.add_text("String updated!")
.add_blank_line()
.add_link("/", "Back")
.into();
Ok(response)
}

20
molly-brown.conf Normal file
View File

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

View File

@ -1,33 +0,0 @@
```
__ __ __
____ ____ _____/ /_/ /_ _____/ /_____ ______
/ __ \/ __ \/ ___/ __/ __ \/ ___/ __/ __ `/ ___/
/ / / / /_/ / / / /_/ / / (__ ) /_/ /_/ / /
/_/ /_/\____/_/ \__/_/ /_/____/\__/\__,_/_/
```
=> https://docs.rs/northstar Documentation
=> https://github.com/panicbit/northstar GitHub
# Usage
Add the latest version of northstar to your `Cargo.toml`.
## Manually
```toml
northstar = "0.3.0" # check crates.io for the latest version
```
## Automatically
```sh
cargo add northstar
```
# Generating a key & certificate
```sh
mkdir cert && cd cert
openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365
```

37
public/README.gmi Normal file
View File

@ -0,0 +1,37 @@
```kochab
*. ,.-*,,..
.` `. .,-'` ````--*,,,..
.` ;*` ```''-o
* ,' __ __ __
`. ,' / /______ _____/ /_ ____ _/ /_
⭐ / //_/ __ \/ ___/ __ \/ __ `/ __ \
/ ,< / /_/ / /__/ / / / /_/ / /_/ /
/_/|_|\____/\___/_/ /_/\__,_/_.___/
```
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.
=> https://github.com/panicbit/northstar Northstar GitHub
## Usage
It is currently only possible to use kochab through it's git repo, although it may wind up on crates.rs someday.
```toml
kochab = { git = "https://github.com/Alch-Emi/kochab.git" }
```
## Generating a key & certificate
Run
```sh
mkdir cert && cd cert
openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365
```
and enter your domain name (e.g. "localhost" for testing) as Common Name (CN).
Alternatively, if you want to include multiple domains add something like:
```
-addext "subjectAltName = DNS:localhost, DNS:example.org"
```

260
src/cert.rs Normal file
View File

@ -0,0 +1,260 @@
//! Tools for automatically generating certificates
//!
//! Really, the only thing you will probably ever need from this module if you aren't
//! developing the project is the [`CertGenMode`] enum, which can be passed to
//! [`Server::set_certificate_generation_mode()`]. You won't even need to call any
//! methods on it or anything.
//!
//! [`Server::set_certificate_generation_mode()`]: crate::Server::set_certificate_generation_mode()
use anyhow::{anyhow, Context, Result, ensure};
#[cfg(feature = "certgen")]
use anyhow::bail;
use rustls::ServerConfig;
use std::{path::PathBuf, sync::Arc};
#[cfg(feature = "certgen")]
use std::{
fs,
io::{stdin, stdout, Write},
path::Path,
};
use rustls::internal::msgs::handshake::DigitallySignedStruct;
use tokio_rustls::rustls;
use rustls::*;
#[derive(Clone, Debug)]
#[cfg(feature = "certgen")]
/// The mode to use for determining the domains to use for a new certificate.
///
/// Used to configure a [`Server`] using [`set_certificate_generation_mode()`]
///
/// [`Server`]: crate::Server
/// [`set_certificate_generation_mode()`]: crate::Server::set_certificate_generation_mode
pub enum CertGenMode {
/// Do not generate any certificates. Error if not available.
None,
/// Use a provided set of domains
Preset(Vec<String>),
/// Prompt the user using stdin/stdout to enter domains.
Interactive,
}
#[cfg(feature = "certgen")]
impl CertGenMode {
/// Generate a new self-signed certificate
///
/// Assumes that certificates do not already exist, and will overwrite anything at the
/// provided paths. The paths provided should be paths to non-existant files which
/// the program has access to write to.
///
/// With very rare exceptions, end users should not ever need to call this method.
/// Instead, just pass the [`CertGenMode`] to [`set_certificate_generation_mode()`]
///
/// ## Errors
///
/// Returns an error if [`CertGenMode::None`], or if there is an error generating the
/// certificate, or writing to either of the provided files.
///
/// [`set_certificate_generation_mode()`]: crate::Server::set_certificate_generation_mode
pub fn gencert(self, cert: impl AsRef<Path>, key: impl AsRef<Path>) -> Result<rcgen::Certificate> {
let (domains, interactive) = match self {
Self::None => bail!("Automatic certificate generation disabled"),
Self::Preset(domains) => (domains, false),
Self::Interactive => (prompt_domains(), true),
};
let certificate = rcgen::generate_simple_self_signed(domains)
.context("Could not generate a certificate with the given domains")?;
fs::create_dir_all(
cert.as_ref()
.parent()
.expect("Received directory as certificate path, should be a file.")
)?;
fs::create_dir_all(
key.as_ref()
.parent()
.expect("Received directory as key path, should be a file.")
)?;
fs::write(cert.as_ref(), &certificate.serialize_pem()?.as_bytes())
.context("Failed to write newly generated certificate to file")?;
fs::write(key.as_ref(), &certificate.serialize_private_key_pem().as_bytes())
.context("Failed to write newly generated private key to file")?;
if interactive {
println!("Certificate generated successfully!");
}
Ok(certificate)
}
/// Attempts to load a certificate/key from a file, or generate it if not found
///
/// The produced certificate & key is immediately fed to a [`rustls::ServerConfig`]
///
/// See [`CertGenMode::gencert()`] for more info.
///
/// ## Errors
///
/// Returns an error if a certificate is not found **and** cannot be generated.
fn load_or_generate(self, to: &mut ServerConfig, cert: impl AsRef<Path>, key: impl AsRef<Path>) -> Result<()> {
match (load_cert_chain(&cert.as_ref().into()), load_key(&key.as_ref().into())) {
(Ok(cert_chain), Ok(key)) => {
to.set_single_cert(cert_chain, key)
.context("Failed to use loaded TLS certificate")?;
},
(Err(e), _) | (_, Err(e)) => {
warn!("Failed to load certificate from file: {}, now trying automatic generation", e);
let cert = self.gencert(cert, key).context("Could not generate certificate")?;
to.set_single_cert(
vec![rustls::Certificate(cert.serialize_der()?)],
rustls::PrivateKey(cert.serialize_private_key_der())
)?;
}
}
Ok(())
}
}
#[cfg(feature = "certgen")]
/// Attempt to get domains by prompting the user
///
/// Guaranteed to return at least one domain. The user is provided `localhost` as a
/// default.
///
/// ## Panics
/// Panics if reading from stdin or writing to stdout returns an error.
fn prompt_domains() -> Vec<String> {
let mut domains = Vec::with_capacity(1);
let mut input = String::with_capacity(8);
println!("Now generating self-signed certificate...");
print!("Please enter a domain (CN) for your certificate [localhost]: ");
stdout().flush().unwrap();
loop {
stdin().read_line(&mut input).unwrap();
let domain = input.trim();
if domain.is_empty() {
if domains.is_empty() {
println!("Using `localhost` as domain.");
domains.push("localhost".to_string());
}
return domains;
} else {
domains.push(domain.to_owned());
input.clear();
print!("Add another domain, or finish with a blank input: ");
stdout().flush().unwrap();
}
}
}
pub fn tls_config(
cert_path: &PathBuf,
key_path: &PathBuf,
#[cfg(feature = "certgen")]
mode: CertGenMode,
) -> Result<Arc<ServerConfig>> {
let mut config = ServerConfig::new(AllowAnonOrSelfsignedClient::new());
#[cfg(feature = "certgen")]
mode.load_or_generate(&mut config, cert_path, key_path)?;
#[cfg(not(feature = "certgen"))] {
let cert_chain = load_cert_chain(cert_path)
.context("Failed to load TLS certificate")?;
let key = load_key(key_path)
.context("Failed to load TLS key")?;
config.set_single_cert(cert_chain, key)
.context("Failed to use loaded TLS certificate")?;
}
Ok(config.into())
}
fn load_cert_chain(cert_path: &PathBuf) -> Result<Vec<Certificate>> {
let certs = std::fs::File::open(cert_path)
.with_context(|| format!("Failed to open `{:?}`", cert_path))?;
let mut certs = std::io::BufReader::new(certs);
let certs = rustls::internal::pemfile::certs(&mut certs)
.map_err(|_| anyhow!("failed to load certs `{:?}`", cert_path))?;
Ok(certs)
}
fn load_key(key_path: &PathBuf) -> Result<PrivateKey> {
let keys = std::fs::File::open(key_path)
.with_context(|| format!("Failed to open `{:?}`", key_path))?;
let mut keys = std::io::BufReader::new(keys);
let mut keys = rustls::internal::pemfile::pkcs8_private_keys(&mut keys)
.map_err(|_| anyhow!("failed to load key `{:?}`", key_path))?;
ensure!(!keys.is_empty(), "no key found");
let key = keys.swap_remove(0);
Ok(key)
}
/// A client cert verifier that accepts all connections
///
/// Unfortunately, rustls doesn't provide a ClientCertVerifier that accepts self-signed
/// certificates, so we need to implement this ourselves.
struct AllowAnonOrSelfsignedClient { }
impl AllowAnonOrSelfsignedClient {
/// Create a new verifier
fn new() -> Arc<Self> {
Arc::new(Self {})
}
}
impl ClientCertVerifier for AllowAnonOrSelfsignedClient {
fn client_auth_root_subjects(
&self,
_: Option<&webpki::DNSName>
) -> Option<DistinguishedNames> {
Some(Vec::new())
}
fn client_auth_mandatory(&self, _sni: Option<&webpki::DNSName>) -> Option<bool> {
Some(false)
}
// the below methods are a hack until webpki doesn't break with certain certs
fn verify_client_cert(
&self,
_: &[Certificate],
_: Option<&webpki::DNSName>
) -> Result<ClientCertVerified, TLSError> {
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &Certificate,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TLSError> {
Ok(HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &Certificate,
_dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, TLSError> {
Ok(HandshakeSignatureValid::assertion())
}
}

259
src/handling.rs Normal file
View File

@ -0,0 +1,259 @@
//! Types for handling requests
//!
//! The main type is the [`Handler`], which wraps a more specific type of handler and
//! manages delegating responses to it.
use anyhow::Result;
use std::{
pin::Pin,
future::Future,
task::Poll,
panic::{catch_unwind, AssertUnwindSafe},
};
#[cfg(feature = "serve_dir")]
use std::path::PathBuf;
use crate::{Body, Response, Request};
#[cfg(feature = "gemtext")]
use crate::Gemtext;
/// A struct representing something capable of handling a request.
///
/// A crucial part of the documentation for this is the implementations of [`From`], as
/// this is what can be passed to [`Server::add_route()`](crate::Server::add_route()) in
/// order to create a new route.
///
/// Detailed descriptions on each variant also describe how each kind of handler works,
/// and how they can be created
pub enum Handler {
/// A handler that responds to a request by delegating to an [`Fn`]
///
/// Most often created by using the implementation by using the implementation of
/// [`From`]
///
/// If you're feeling overwhelmed by the function signature, don't panic. Please see
/// the [example](#example).
///
/// Any requests passed to the handler will be directly handed down to the handler,
/// with the request as the first argument. The response provided will be sent to the
/// requester. If the handler panics or returns an [`Err`], this will be logged, and
/// the requester will be sent a [`TEMPORARY FAILURE`](Response::temporary_failure()).
///
/// [`From`]: #impl-From<H>
FnHandler(HandlerInner),
/// A handler that always serves an identical response, for any and all request
///
/// Any and all requests to this handler will be responded to with the same response,
/// no matter what. This is good for static content that is provided by your app.
/// 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<Gemtext>`]
///
/// [`FilesHandler`]: Self::FilesHandler
/// [`From<Response>`]: #impl-From<Response>
/// [`From<Gemtext>`]: #impl-From<Gemtext>
StaticHandler(Response),
#[cfg(feature = "serve_dir")]
/// A handler that serves a directory, including a directory listing
///
/// Most often created with [`From<PathBuf>`]
///
/// Any requests directed to this handler will be served from this path. For example,
/// if a handler serving files from the path `./public/` and bound to `/serve`
/// receives a request for `/serve/file.txt`, it will respond with the contents of the
/// file at `./public/file.txt`, and automatically infer the MIME type.
///
/// This is equivilent to serving files using [`util::serve_dir()`], and as such will
/// include directory listings.
///
/// Additionally, if the path is only a single file, that file will be served in
/// response to *every request*. That is, adding a handler for `/path/to/file.txt`
/// to the route `/hello` will mean that `/hello`, `/hello/file.txt`, and
/// `/hello/irrele/vant` will all be responded to with the contents of `file.txt`.
///
/// [`From<PathBuf>`]: #impl-From<PathBuf>
FilesHandler(PathBuf),
}
/// Since we can't store train objects, we need to wrap fn handlers in a box
type HandlerInner = Box<dyn Fn(Request) -> HandlerResponse + Send + Sync>;
/// Same with dyn Futures
type HandlerResponse = Pin<Box<dyn Future<Output = Result<Response>> + Send>>;
impl Handler {
/// Handle an incoming request
///
/// This delegates to the request to the appropriate method of handling it, whether
/// that's fetching a file or directory listing, cloning a static response, or handing
/// the request to a wrapped handler function.
///
/// Any unexpected errors that occur will be printed to the log and potentially
/// reported to the user, depending on the handler type.
pub async fn handle(&self, request: Request) -> Response {
match self {
Self::FnHandler(inner) => {
let fut_handle = (inner)(request);
let fut_handle = AssertUnwindSafe(fut_handle);
HandlerCatchUnwind::new(fut_handle).await
.unwrap_or_else(|err| {
error!("Handler failed: {:?}", err);
Response::temporary_failure("")
})
},
Self::StaticHandler(response) => {
match &response.body {
None => Response::new(response.status, &response.meta),
Some(Body::Bytes(bytes)) => Response::success(&response.meta, bytes.clone()),
_ => {
error!(concat!(
"Cannot construct a static handler with a reader-based body! ",
" We're sending a response so that the client doesn't crash, but",
" given that this is a release build you should really fix this."
));
Response::permanent_failure(
"Very bad server error, go tell the sysadmin to look at the logs."
)
}
}
},
#[cfg(feature = "serve_dir")]
Self::FilesHandler(path) => {
if path.is_dir() {
crate::util::serve_dir(path, request.trailing_segments()).await
} else {
let mime = crate::util::guess_mime_from_path(&path);
crate::util::serve_file(path, &mime).await
}
},
}
}
}
impl<H, R> From<H> for Handler
where
H: 'static + Fn(Request) -> R + Send + Sync,
R: 'static + Future<Output = Result<Response>> + Send,
{
/// Wrap an [`Fn`] in a [`Handler`] struct, creating an [`FnHandler`]
///
/// This automatically boxes both the [`Fn`] and the [`Fn`]'s response.
///
/// Don't be overwhelmed by the function signature here. It's honestly way simpler
/// than it looks.
///
/// # Example
///
/// ```
/// # use kochab::*;
/// use anyhow::Result;
///
/// let handler: Handler = handle_request.into();
///
/// async fn handle_request(request: Request) -> Result<Response> {
/// // This could be done with a StaticHandler, but for demonstration:
/// Ok(Response::success_gemini("Hello world!"))
/// }
/// ```
///
/// [`FnHandler`]: Self::FnHandler
fn from(handler: H) -> Self {
Self::FnHandler(
Box::new(move|req| Box::pin((handler)(req)) as HandlerResponse)
)
}
}
// We tolerate a fallible `impl From` because this is *really* not the kind of thing the
// user should be catching in runtime.
#[allow(clippy::fallible_impl_from)]
impl From<Response> for Handler {
/// Serve an unchanging response
///
/// ## Panics
/// This response type **CANNOT** be created using Responses with [`Reader`] bodies.
/// Attempting to do this will cause a panic. Don't.
///
/// This will create a [`StaticHandler`]
///
/// [`Reader`]: Body::Reader
/// [`StaticHandler`]: Self::StaticHandler
fn from(response: Response) -> Self {
#[cfg(debug_assertions)] {
// We have another check once the handler is actually called that is not
// disabled for release builds
if let Some(Body::Reader(_)) = response.as_ref() {
panic!("Cannot construct a static handler with a reader-based body");
}
}
Self::StaticHandler(response)
}
}
#[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
/// handler. As with all documents, this will be a successful response with a
/// `text/gemini` MIME.
///
/// This will create a [`StaticHandler`]
///
/// [`StaticHandler`]: Self::StaticHandler
fn from(doc: Gemtext) -> Self {
Self::StaticHandler(doc.into())
}
}
#[cfg(feature = "serve_dir")]
impl From<PathBuf> for Handler {
/// Serve files from a directory
///
/// The path to a single file can be passed in order to serve only a single file for
/// any and all requests.
///
/// This will create a [`FilesHandler`].
///
/// [`util::serve_dir()`]: crate::util::serve_dir()
/// [`FilesHandler`]: Handler::FilesHandler
fn from(path: PathBuf) -> Self {
Self::FilesHandler(path)
}
}
/// A utility for catching unwinds on Futures.
///
/// This is adapted from the futures-rs CatchUnwind, in an effort to reduce the large
/// amount of dependencies tied into the feature that provides this simple struct.
#[must_use = "futures do nothing unless polled"]
struct HandlerCatchUnwind {
future: AssertUnwindSafe<HandlerResponse>,
}
impl HandlerCatchUnwind {
fn new(future: AssertUnwindSafe<HandlerResponse>) -> Self {
Self { future }
}
}
impl Future for HandlerCatchUnwind {
type Output = Result<Response>;
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context
) -> Poll<Self::Output> {
match catch_unwind(AssertUnwindSafe(|| self.future.as_mut().poll(cx))) {
Ok(res) => res,
Err(e) => {
error!("Handler panic! {:?}", e);
Poll::Ready(Ok(Response::temporary_failure("")))
}
}
}
}

1061
src/lib.rs

File diff suppressed because it is too large Load Diff

106
src/ratelimiting.rs Normal file
View File

@ -0,0 +1,106 @@
use dashmap::DashMap;
use std::{fmt::Display, collections::VecDeque, hash::Hash, time::{Duration, Instant}};
/// A simple struct to manage rate limiting.
///
/// Does not require a leaky bucket thread to empty it out, but may occassionally need to
/// trim old keys using [`trim_keys()`].
///
/// [`trim_keys()`]: Self::trim_keys()
pub struct RateLimiter<K: Eq + Hash> {
log: DashMap<K, VecDeque<Instant>>,
burst: usize,
period: Duration,
}
impl<K: Eq + Hash> RateLimiter<K> {
/// Create a new ratelimiter that allows at most `burst` connections in `period`
pub fn new(period: Duration, burst: usize) -> Self {
Self {
log: DashMap::with_capacity(8),
period,
burst,
}
}
/// Check if a key may pass
///
/// If the key has made less than `self.burst` connections in the last `self.period`,
/// then the key is allowed to connect, which is denoted by an `Ok` result. This will
/// register as a new connection from that key.
///
/// If the key is not allowed to connect, than a [`Duration`] denoting the amount of
/// time until the key is permitted is returned, wrapped in an `Err`
pub fn check_key(&self, key: K) -> Result<(), Duration> {
let now = Instant::now();
let count_after = now - self.period;
let mut connections = self.log.entry(key)
.or_insert_with(||VecDeque::with_capacity(self.burst));
let connections = connections.value_mut();
// Chcek if space can be made available. We don't need to trim all expired
// connections, just the one in question to allow this connection.
if let Some(earliest_conn) = connections.front() {
if earliest_conn < &count_after {
connections.pop_front();
}
}
// Check if the connection should be allowed
if connections.len() == self.burst {
Err(connections[0] + self.period - now)
} else {
connections.push_back(now);
Ok(())
}
}
/// Remove any expired keys from the ratelimiter
///
/// This only needs to be called if keys are continuously being added. If keys are
/// being reused, or come from a finite set, then you don't need to worry about this.
///
/// If you have many keys coming from a large set, you should infrequently call this
/// to prevent a memory leak.
///
/// If debug level logging is enabled, this prints an *approximate* number of keys
/// removed to the log. For more precise output, use [`trim_keys_verbose()`]
///
/// [`trim_keys_verbose()`]: RateLimiter::trim_keys_verbose()
pub fn trim_keys(&self) {
let count_after = Instant::now() - self.period;
let len: isize = self.log.len() as isize;
self.log.retain(|_, conns| conns.back().unwrap() > &count_after);
let removed = len - self.log.len() as isize;
if removed.is_positive() {
debug!("Pruned approximately {} expired ratelimit keys", removed);
}
}
}
impl<K: Eq + Hash + Display> RateLimiter<K> {
/// Remove any expired keys from the ratelimiter
///
/// This only needs to be called if keys are continuously being added. If keys are
/// being reused, or come from a finite set, then you don't need to worry about this.
///
/// If you have many keys coming from a large set, you should infrequently call this
/// to prevent a memory leak.
///
/// If debug level logging is on, this prints out any removed keys.
pub fn trim_keys_verbose(&self) {
let count_after = Instant::now() - self.period;
self.log.retain(|ip, conns| {
let should_keep = conns.back().unwrap() > &count_after;
if !should_keep {
debug!("Pruned expired ratelimit key: {}", ip);
}
should_keep
});
}
}

250
src/routing.rs Normal file
View File

@ -0,0 +1,250 @@
//! Utilities for routing requests
//!
//! For most users, this is more advanced than you need to get. If you're interested in
//! adding routes, please see [`Server::add_route()`], which is how most people will
//! interact with the routing API
//!
//! See [`RoutingNode`] for details on how routes are matched.
//!
//! [`Server::add_route()`]: crate::Server::add_route
use uriparse::path::{Path, Segment};
use std::collections::HashMap;
use std::convert::TryInto;
use crate::types::Request;
/// A node for linking values to routes
///
/// Routing is processed by a tree, with each child being a single path segment. For
/// example, if an entry existed at "/trans/rights", then the root-level node would have
/// a child "trans", which would have a child "rights". "rights" would have no children,
/// but would have an attached entry.
///
/// If one route is shorter than another, say "/trans/rights" and
/// "/trans/rights/r/human", then the longer route always matches first, so a request for
/// "/trans/rights/r/human/rights" would be routed to "/trans/rights/r/human", and
/// "/trans/rights/now" would route to "/trans/rights"
///
/// Routing is only performed on normalized paths, so "/endpoint" and "/endpoint/" are
/// considered to be the same route.
///
/// ```
/// # use kochab::routing::RoutingNode;
/// let mut routes = RoutingNode::<&'static str>::default();
/// routes.add_route("/", "base");
/// routes.add_route("/trans/rights/", "short route");
/// routes.add_route("/trans/rights/r/human", "long route");
///
/// assert_eq!(
/// routes.match_path(&["any", "other", "request"]),
/// Some((vec![&"any", &"other", &"request"], &"base"))
/// );
/// assert_eq!(
/// routes.match_path(&["trans", "rights"]),
/// Some((vec![], &"short route"))
/// );
/// assert_eq!(
/// routes.match_path(&["trans", "rights", "now"]),
/// Some((vec![&"now"], &"short route"))
/// );
/// assert_eq!(
/// routes.match_path(&["trans", "rights", "r", "human", "rights"]),
/// Some((vec![&"rights"], &"long route"))
/// );
/// ```
pub struct RoutingNode<T>(Option<T>, HashMap<String, Self>);
impl<T> RoutingNode<T> {
/// Attempt to find and entry based on path segments
///
/// This searches the network of routing nodes attempting to match a specific request,
/// represented as a sequence of path segments. For example, "/dir/image.png?text"
/// should be represented as `&["dir", "image.png"]`.
///
/// If a match is found, it is returned, along with the segments of the path trailing
/// the subpath matching the route. For example, a route `/foo` receiving a request to
/// `/foo/bar` would produce `vec!["bar"]`
///
/// See [`RoutingNode`] for details on how routes are matched.
pub fn match_path<I,S>(&self, path: I) -> Option<(Vec<S>, &T)>
where
I: IntoIterator<Item=S>,
S: AsRef<str>,
{
let mut node = self;
let mut path = path.into_iter().filter(|seg| !seg.as_ref().is_empty());
let mut last_seen_handler = None;
let mut since_last_handler = Vec::new();
loop {
let Self(maybe_handler, map) = node;
if maybe_handler.is_some() {
last_seen_handler = maybe_handler.as_ref();
since_last_handler.clear();
}
if let Some(segment) = path.next() {
let maybe_route = map.get(segment.as_ref());
since_last_handler.push(segment);
if let Some(route) = maybe_route {
node = route;
} else {
break;
}
} else {
break;
}
};
if let Some(handler) = last_seen_handler {
since_last_handler.extend(path);
Some((since_last_handler, handler))
} else {
None
}
}
/// Attempt to identify a route for a given [`Request`]
///
/// See [`RoutingNode::match_path()`] for more information
pub fn match_request(&self, req: &Request) -> Option<(Vec<String>, &T)> {
let mut path = req.path().to_borrowed();
path.normalize(false);
self.match_path(path.segments())
.map(|(segs, h)| (
segs.into_iter()
.map(Segment::as_str)
.map(str::to_owned)
.collect(),
h,
))
}
/// Add a route to the network
///
/// This method wraps [`add_route_by_path()`](Self::add_route_by_path()) while
/// unwrapping any errors that might occur. For this reason, this method only takes
/// static strings. If you would like to add a string dynamically, please use
/// [`RoutingNode::add_route_by_path()`] in order to appropriately deal with any
/// errors that might arise.
pub fn add_route(&mut self, path: &'static str, data: T) {
let path: Path = path.try_into().expect("Malformed path route received");
self.add_route_by_path(path, data).unwrap();
}
/// Add a route to the network
///
/// The path provided MUST be absolute. Callers should verify this before calling
/// this method.
///
/// For information about how routes work, see [`RoutingNode::match_path()`]
pub fn add_route_by_path(&mut self, mut path: Path, data: T) -> Result<(), ConflictingRouteError>{
debug_assert!(path.is_absolute());
path.normalize(false);
let mut node = self;
for segment in path.segments() {
if segment != "" {
node = node.1.entry(segment.to_string()).or_default();
}
}
if node.0.is_some() {
Err(ConflictingRouteError())
} else {
node.0 = Some(data);
Ok(())
}
}
/// Recursively shrink maps to fit
pub fn shrink(&mut self) {
let mut to_shrink = vec![&mut self.1];
while let Some(shrink) = to_shrink.pop() {
shrink.shrink_to_fit();
to_shrink.extend(shrink.values_mut().map(|n| &mut n.1));
}
}
/// Iterate over the items in this map
///
/// This includes not just the direct children of this node, but also all children of
/// those children. No guarantees are made as to the order values are visited in.
///
/// ## Example
/// ```
/// # use std::collections::HashSet;
/// # use kochab::routing::RoutingNode;
/// let mut map = RoutingNode::<usize>::default();
/// map.add_route("/", 0);
/// map.add_route("/hello/world", 1312);
/// map.add_route("/example", 621);
///
/// let values: HashSet<&usize> = map.iter().collect();
/// assert!(values.contains(&0));
/// assert!(values.contains(&1312));
/// assert!(values.contains(&621));
/// assert!(!values.contains(&1));
/// ```
pub fn iter(&self) -> Iter<'_, T> {
Iter {
unexplored: vec![self],
}
}
}
impl<'a, T> IntoIterator for &'a RoutingNode<T> {
type Item = &'a T;
type IntoIter = Iter<'a, T>;
fn into_iter(self) -> Iter<'a, T> {
self.iter()
}
}
impl<T> Default for RoutingNode<T> {
fn default() -> Self {
Self(None, HashMap::default())
}
}
#[derive(Debug, Clone, Copy)]
/// An error returned when attempting to register a route that already exists
///
/// Routes will not be overridden if this error is returned. Routes are never overwritten
///
/// See [`RoutingNode::add_route_by_path()`]
pub struct ConflictingRouteError();
impl std::error::Error for ConflictingRouteError { }
impl std::fmt::Display for ConflictingRouteError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "Attempted to create a route with the same matcher as an existing route")
}
}
#[derive(Clone)]
/// An iterator over the values in a [`RoutingNode`] map
pub struct Iter<'a, T> {
unexplored: Vec<&'a RoutingNode<T>>,
}
impl<'a, T> Iterator for Iter<'a, T> {
type Item = &'a T;
fn next(&mut self) -> Option<Self::Item> {
while let Some(node) = self.unexplored.pop() {
self.unexplored.extend(node.1.values());
if node.0.is_some() {
return node.0.as_ref();
}
}
None
}
}
impl<T> std::iter::FusedIterator for Iter<'_, T> { }

View File

@ -1,19 +1,6 @@
pub use ::mime::Mime;
pub use rustls::Certificate;
pub use uriparse::URIReference;
mod meta;
pub use self::meta::Meta;
mod request;
pub use request::Request;
mod response_header;
pub use response_header::ResponseHeader;
mod status;
pub use status::{Status, StatusCategory};
mod response;
pub use response::Response;

View File

@ -1,15 +1,74 @@
use tokio::io::AsyncRead;
#[cfg(feature="scgi_srv")]
use tokio::io::AsyncReadExt;
#[cfg(feature="serve_dir")]
use tokio::fs::File;
#[cfg(feature = "gemtext")]
use crate::Gemtext;
/// The body of a response
///
/// The content of a successful response to be sent back to the user. This can be either
/// some bytes which will be sent directly to the user, or a reader which will be read at
/// some point before sending to the user.
pub enum Body {
/// In-memory bytes that may be sent back to the user
Bytes(Vec<u8>),
/// A reader which will be streamed to the user
///
/// If a reader blocks for too long, it MAY be killed before finishing, which results
/// in the user receiving a malformed response or timing out.
Reader(Box<dyn AsyncRead + Send + Sync + Unpin>),
}
impl Body {
/// Called by [`Response::rewrite_all`]
#[cfg(feature="scgi_srv")]
pub (crate) async fn rewrite_all(&mut self, based_on: &crate::Request) -> std::io::Result<bool> {
let bytes = match self {
Self::Bytes(bytes) => {
let mut newbytes = Vec::new();
std::mem::swap(bytes, &mut newbytes);
newbytes
}
Self::Reader(reader) => {
let mut bytes = Vec::new();
reader.read_to_end(&mut bytes).await?;
bytes
}
};
let mut body = String::from_utf8(bytes).expect("text/gemini wasn't UTF8");
let mut maybe_indx = if body.starts_with("=> ") {
Some(3)
} else {
body.find("\n=> ").map(|offset| offset + 4)
};
while let Some(indx) = maybe_indx {
// Find the end of the link part
let end = (&body[indx..]).find(&[' ', '\n', '\r'][..])
.map(|offset| indx + offset )
.unwrap_or(body.len());
// Perform replacement
if let Some(replacement) = based_on.rewrite_path(&body[indx..end]) {
body.replace_range(indx..end, replacement.as_str());
} else {
return Ok(false)
};
// Find next match
maybe_indx = (&body[indx..]).find("\n=> ").map(|offset| offset + 4 + indx);
}
*self = Self::Bytes(body.into_bytes());
Ok(true)
}
}
#[cfg(feature = "gemtext")]
#[allow(clippy::fallible_impl_from)] // It's really not fallible but thanks
impl From<Vec<gemtext::Node>> for Body {

View File

@ -1,137 +0,0 @@
use anyhow::*;
use crate::Mime;
use crate::util::Cowy;
#[derive(Debug,Clone,PartialEq,Eq,Default)]
pub struct Meta(String);
impl Meta {
pub const MAX_LEN: usize = 1024;
/// Creates a new "Meta" string.
/// Fails if `meta` contains `\n`.
pub fn new(meta: impl Cowy<str>) -> Result<Self> {
ensure!(!meta.as_ref().contains('\n'), "Meta must not contain newlines");
ensure!(meta.as_ref().len() <= Self::MAX_LEN, "Meta must not exceed {} bytes", Self::MAX_LEN);
Ok(Self(meta.into()))
}
/// Creates a new "Meta" string.
/// Truncates `meta` to before:
/// - the first occurrence of `\n`
/// - the character that makes `meta` exceed `Meta::MAX_LEN`
pub fn new_lossy(meta: impl Cowy<str>) -> Self {
let meta = meta.as_ref();
let truncate_pos = meta.char_indices().position(|(i, ch)| {
let is_newline = ch == '\n';
let exceeds_limit = (i + ch.len_utf8()) > Self::MAX_LEN;
is_newline || exceeds_limit
});
let meta: String = match truncate_pos {
None => meta.into(),
Some(truncate_pos) => meta.get(..truncate_pos).expect("northstar BUG").into(),
};
Self(meta)
}
pub fn empty() -> Self {
Self::default()
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_mime(&self) -> Result<Mime> {
let mime = self.as_str().parse::<Mime>()
.context("Meta is not a valid MIME")?;
Ok(mime)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::iter::repeat;
#[test]
fn new_rejects_newlines() {
let meta = "foo\nbar";
let meta = Meta::new(meta);
assert!(meta.is_err());
}
#[test]
fn new_accepts_max_len() {
let meta: String = repeat('x').take(Meta::MAX_LEN).collect();
let meta = Meta::new(meta);
assert!(meta.is_ok());
}
#[test]
fn new_rejects_exceeding_max_len() {
let meta: String = repeat('x').take(Meta::MAX_LEN + 1).collect();
let meta = Meta::new(meta);
assert!(meta.is_err());
}
#[test]
fn new_lossy_truncates() {
let meta = "foo\r\nbar\nquux";
let meta = Meta::new_lossy(meta);
assert_eq!(meta.as_str(), "foo\r");
}
#[test]
fn new_lossy_no_truncate() {
let meta = "foo bar\r";
let meta = Meta::new_lossy(meta);
assert_eq!(meta.as_str(), "foo bar\r");
}
#[test]
fn new_lossy_empty() {
let meta = "";
let meta = Meta::new_lossy(meta);
assert_eq!(meta.as_str(), "");
}
#[test]
fn new_lossy_truncates_to_empty() {
let meta = "\n\n\n";
let meta = Meta::new_lossy(meta);
assert_eq!(meta.as_str(), "");
}
#[test]
fn new_lossy_truncates_to_max_len() {
let meta: String = repeat('x').take(Meta::MAX_LEN + 1).collect();
let meta = Meta::new_lossy(meta);
assert_eq!(meta.as_str().len(), Meta::MAX_LEN);
}
#[test]
fn new_lossy_truncates_multi_byte_sequences() {
let mut meta: String = repeat('x').take(Meta::MAX_LEN - 1).collect();
meta.push('🦀');
assert_eq!(meta.len(), Meta::MAX_LEN + 3);
let meta = Meta::new_lossy(meta);
assert_eq!(meta.as_str().len(), Meta::MAX_LEN - 1);
}
}

View File

@ -1,27 +1,131 @@
use std::ops;
#[cfg(feature = "gemini_srv")]
use std::convert::TryInto;
#[cfg(feature = "scgi_srv")]
use std::{
collections::HashMap,
convert::TryFrom,
path::Path,
};
use anyhow::*;
use percent_encoding::percent_decode_str;
use uriparse::URIReference;
use rustls::Certificate;
#[cfg(feature="user_management")]
use serde::{Serialize, de::DeserializeOwned};
#[cfg(feature = "gemini_srv")]
use ring::digest;
#[cfg(feature="user_management")]
use crate::user_management::{UserManager, User};
#[derive(Clone)]
/// A request from a Gemini client to the app
///
/// When originally sent out by a client, a request is literally just a URL, and honestly,
/// if you want to use it as just a URL, that'll work fine!
///
/// That said, kochab and any proxies the request might hit add a little bit more
/// information that you can use, like
/// * [What TLS certificate (if any) did the client use](Self::certificate)
/// * [What part of the path is relevant (ie, everything after the route)](Self::trailing_segments)
/// * [Is the user registered with the user database?](Self::user)
///
/// The only way to get your hands on one of these bad boys is when you register an [`Fn`]
/// based handler to a [`Server`](crate::Server), and a user makes a request to the
/// endpoint.
pub struct Request {
uri: URIReference<'static>,
input: Option<String>,
certificate: Option<Certificate>,
certificate: Option<[u8; 32]>,
trailing_segments: Option<Vec<String>>,
#[cfg(feature="user_management")]
manager: UserManager,
#[cfg(feature = "scgi_srv")]
headers: HashMap<String, String>,
#[cfg(feature = "scgi_srv")]
script_path: Option<String>,
}
impl Request {
pub fn from_uri(uri: URIReference<'static>) -> Result<Self> {
Self::with_certificate(uri, None)
}
pub fn with_certificate(
/// Construct a new request
///
/// When in `gemini_srv` mode, this is done using a URL. If you do construct a
/// request this way, by default it will not have a certificate attached, so make
/// sure you add in a certificate with [`Request::set_cert()`].
///
/// By contrast, in `scgi_srv` mode, the certificate fingerprint is grabbed out of the
/// request parameters, so you don't need to do anything. The headers passed should
/// be the header sent by the SCGI client.
///
/// When in SCGI mode, the following headers are expected:
///
/// * `PATH_INFO`: The part of the path following the route the app is bound to
/// * `QUERY_STRING`: The part of the request following ?, url encoded. Will produce
/// an error if it contains invalid UTF-8. No error if missing
/// * `TLS_CLIENT_HASH`: Optional. The base64 or hex encoded SHA256 sum of the DER
/// certificate of the requester.
/// * `SCRIPT_PATH` or `SCRIPT_NAME`: The base path the app is mounted on
///
/// # Errors
///
/// Produces an error if:
/// * The SCGI server didn't include the mandatory `PATH_INFO` header
/// * The provided URI reference is invalid, including if the SCGI server sent an
/// invalid `PATH_INFO`
/// * The `TLS_CLIENT_HASH` sent by the SCGI server isn't sha256, or is encoded with
/// something other than base64 or hexadecimal
pub (crate) fn new(
#[cfg(feature = "gemini_srv")]
mut uri: URIReference<'static>,
certificate: Option<Certificate>
#[cfg(feature = "scgi_srv")]
headers: HashMap<String, String>,
#[cfg(feature="user_management")]
manager: UserManager,
) -> Result<Self> {
#[cfg(feature = "scgi_srv")]
#[allow(clippy::or_fun_call)] // Lay off it's a macro
let (mut uri, certificate, script_path) = (
URIReference::try_from(
format!(
"{}{}",
headers.get("PATH_INFO")
.context("PATH_INFO header not received from SCGI client")?
.as_str(),
headers.get("QUERY_STRING")
.map(|q| format!("?{}", q))
.unwrap_or_else(String::new),
).as_str()
)
.context("Request URI is invalid")?
.into_owned(),
match headers.get("TLS_CLIENT_HASH").map(hash_decode) {
Some(maybe_hash @ Some(_)) => maybe_hash,
Some(None) => bail!("Received malformed TLS client hash from upstream. Expected 256 bit hex or b64 encoded"),
None => None,
},
headers.get("SCRIPT_PATH")
.or_else(|| headers.get("SCRIPT_NAME"))
.cloned()
);
// Send out a warning if the server did not specify a SCRIPT_PATH.
// This should only be done once to avoid spaming log files
#[cfg(feature = "scgi_srv")]
if script_path.is_none() {
static WARN: std::sync::Once = std::sync::Once::new();
WARN.call_once(||
warn!(concat!(
"The SCGI server did not send a SCRIPT_PATH, indicating that it",
" doesn't comply with Gemini's SCGI spec. This will cause a problem",
" if the app needs to rewrite a URL. Please consult the proxy server",
" to identify why this is."
))
)
}
uri.normalize();
let input = match uri.query() {
let input = match uri.query().filter(|q| !q.is_empty()) {
None => None,
Some(query) => {
let input = percent_decode_str(query.as_str())
@ -35,14 +139,57 @@ impl Request {
Ok(Self {
uri,
input,
#[cfg(feature = "scgi_srv")]
certificate,
#[cfg(feature = "gemini_srv")]
certificate: None,
trailing_segments: None,
#[cfg(feature = "scgi_srv")]
headers,
#[cfg(feature = "scgi_srv")]
script_path,
#[cfg(feature="user_management")]
manager,
})
}
/// The URI reference requested by the user
///
/// Although they are not exactly the same thing, it is generally preferred to use the
/// [`Request::trailing_segments()`] method if possible.
///
/// Returns the URIReference requested by the user. **If running in SCGI mode, this
/// will contain only the parts of the URIReference that were relevant to the app.**
/// This means you will get `/path`, not `/app/path`.
///
/// When running in `scgi_srv` mode, this is guaranteed to be a relative reference.
/// When running in `gemini_srv` mode, clients are obliged by the spec to send a full
/// URI, but if a client fails to respect this, kochab will still accept and pass on
/// the relative reference.
pub const fn uri(&self) -> &URIReference {
&self.uri
}
#[allow(clippy::missing_const_for_fn)]
/// All of the path segments following the route to which this request was bound.
///
/// For example, if this handler was bound to the `/api` route, and a request was
/// received to `/api/v1/endpoint`, then this value would be `["v1", "endpoint"]`.
/// This should not be confused with [`path_segments()`](Self::path_segments()), which
/// contains *all* of the segments, not just those trailing the route.
pub fn trailing_segments(&self) -> &Vec<String> {
self.trailing_segments.as_ref().unwrap()
}
/// All of the segments in this path, percent decoded
///
/// For example, for a request to `/api/v1/endpoint`, this would return `["api", "v1",
/// "endpoint"]`, no matter what route the handler that received this request was
/// bound to. This is not to be confused with
/// [`trailing_segments()`](Self::trailing_segments), which contains only the segments
/// following the bound route.
///
/// Additionally, unlike `trailing_segments()`, this method percent decodes the path.
pub fn path_segments(&self) -> Vec<String> {
self.uri()
.path()
@ -52,18 +199,163 @@ impl Request {
.collect::<Vec<String>>()
}
/// View any input sent by the user in the query string
///
/// Any zero-length input is treated as no input at all, and will be reported as
/// [`None`]. This is done in order to provide compatibility with the SCGI header
/// common practice of reporting no query string as a blank input.
pub fn input(&self) -> Option<&str> {
self.input.as_deref()
}
pub fn set_cert(&mut self, cert: Option<Certificate>) {
self.certificate = cert;
#[cfg(feature="scgi_srv")]
/// View any headers sent by the SCGI client
///
/// When an SCGI client delivers a request (e.g. when your gemini server sends a
/// request to this app), it includes many headers which aren't always included in
/// the request otherwise. Bear in mind that **not all SCGI clients send the same
/// headers**, and these are *never* available when operating in `gemini_srv` mode.
///
/// By using this method, you are almost certainly reducing the number of proxy
/// servers your app supports, and you are strongly encouraged to find a different
/// method.
///
/// Some examples of headers mollybrown sets are:
/// - `REMOTE_ADDR` (The user's IP address and port)
/// - `TLS_CLIENT_SUBJECT_CN` (The CommonName on the user's certificate, when present)
/// - `SERVER_NAME` (The host name of the server the request was received on)
/// - `SERVER_SOFTWARE` (= "MOLLY_BROWN")
/// - `SCRIPT_PATH` (The prefix the script is being served on)
pub const fn headers(&self) -> &HashMap<String, String> {
&self.headers
}
#[cfg(feature = "gemini_srv")]
pub (crate) fn set_cert(&mut self, cert: Option<rustls::Certificate>) {
self.certificate = cert.map(|cert| {
digest::digest(&digest::SHA256, cert.0.as_ref())
.as_ref()
.try_into()
.expect("SHA256 didn't return 256 bits")
});
}
/// Sets the segments returned by [`Request::trailing_segments()`]
pub (crate) fn set_trailing(&mut self, segments: Vec<String>) {
self.trailing_segments = Some(segments);
}
#[allow(clippy::missing_const_for_fn)]
pub fn certificate(&self) -> Option<&Certificate> {
/// Get the fingerprint of the certificate the user is connecting with
pub fn certificate(&self) -> Option<&[u8; 32]> {
self.certificate.as_ref()
}
#[cfg(feature="user_management")]
/// Attempt to determine the user who sent this request
///
/// May return a variant depending on if the client used a client certificate, and if
/// they've registered as a user yet.
pub fn user<UserData>(&self) -> Result<User<UserData>>
where
UserData: Serialize + DeserializeOwned
{
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) -> &UserManager {
&self.manager
}
/// Attempt to rewrite an absolute URL against the base path of the SCGI script
///
/// When writing an SCGI script, you cannot assume that your script is mounted on the
/// base path of "/". For example, a request to the gemini server for "/app/path"
/// might be received by your script as "/path" if your script is mounted on "/app/".
/// In this situation, if you linked to "/", you would be sending users to "/", which
/// is not handled by your app, instead of "/app/", where you probably intended to
/// send the user.
///
/// This method attempts to infer where the script is mounted, and rewrite an absolute
/// url relative to that. For example, if the application was mounted on "/app/", and
/// you passed "/path", the result would be "/app/path".
///
/// When running in `gemini_srv` mode, the application is always mounted at the base
/// path, so this will always return the path unchanged.
///
/// Not all SCGI clients will correctly report the application's path, so this may
/// fail if unable to infer the correct path. If this is the case, None will be
/// returned. Currently, the SCGI headers checked are:
///
/// * `SCRIPT_PATH` (Used by [mollybrown] and [stargazer])
/// * `SCRIPT_NAME` (Used by [GLV-1.12556])
///
/// [mollybrown]: https://tildegit.org/solderpunk/molly-brown
/// [stargazer]: https://git.sr.ht/~zethra/stargazer/
/// [GLV-1.12556]: https://github.com/spc476/GLV-1.12556
///
/// For an overview of methods for rewriting links, see [`Server::set_autorewrite()`].
///
/// [`Server::set_autorewrite()`]: crate::Server::set_autorewrite()
pub fn rewrite_path(&self, path: impl AsRef<str>) -> Option<String> {
#[cfg(feature = "scgi_srv")] {
self.script_path.as_ref().map(|base| {
let base: &Path = base.as_ref();
// Make path relative
let mut path_as_path: &Path = path.as_ref().as_ref();
if path_as_path.is_absolute() {
path_as_path = (&path.as_ref()[1..]).as_ref();
}
base.join(path_as_path).display().to_string()
})
}
#[cfg(feature = "gemini_srv")] {
Some(path.as_ref().to_string())
}
}
}
#[allow(clippy::ptr_arg)] // This is a single use function that expects a &String
#[cfg(feature = "scgi_srv")]
/// Attempt to decode a 256 bit hash
///
/// Will attempt to decode first as hexadecimal, and then as base64. If both fail, return
/// [`None`]
fn hash_decode(hash: &String) -> Option<[u8; 32]> {
let mut buffer = [0u8; 32];
if hash.len() == 64 { // Looks like a hex
// Lifted (lightly modified) from ring::test::from_hex
for (i, digits) in hash.as_bytes().chunks(2).enumerate() {
let hi = from_hex_digit(digits[0])?;
let lo = from_hex_digit(digits[1])?;
buffer[i] = (hi * 0x10) | lo;
}
Some(buffer)
} else if hash.len() == 44 { // Look like base64
base64::decode_config_slice(hash, base64::STANDARD, &mut buffer).ok()?;
Some(buffer)
} else {
None
}
}
#[cfg(feature = "scgi_srv")]
/// Attempt to decode a hex encoded nibble to u8
///
/// Returns [`None`] if not a valid hex character
fn from_hex_digit(d: u8) -> Option<u8> {
match d {
b'0'..=b'9' => Some(d - b'0'),
b'a'..=b'f' => Some(d - b'a' + 10u8),
b'A'..=b'F' => Some(d - b'A' + 10u8),
_ => None,
}
}
impl ops::Deref for Request {

View File

@ -1,93 +1,248 @@
use std::convert::TryInto;
use anyhow::*;
use uriparse::URIReference;
use crate::types::{ResponseHeader, Body, Mime};
use crate::util::Cowy;
use crate::GEMINI_MIME;
use crate::types::Body;
/// A response to a client's [`Request`]
///
/// Requests in Gemini are pretty simple. They consist of three parts:
///
/// * A two status code, similar to the status codes in HTML. You don't need to know
/// anything about these, since this part of the response will be filled in for you
/// depending on the associated function you use to create the Response
/// * A meta, a <1024 byte string whose meaning depends on the status
/// * A body, but only for successful requests
///
/// Responses will be identical in both `scgi_srv` mode and `gemini_srv` mode.
///
/// [`Request`]: crate::Request
pub struct Response {
header: ResponseHeader,
body: Option<Body>,
/// The status code of the request. A value between 10 and 62
///
/// Each block of 10 status codes (e.g. 10-19) has a specific meaning or category,
/// defined in depth in the gemini documentation. Generally:
///
/// * 1X is input
/// * 20 is success
/// * 3X is redirect
/// * >= 40 is an error
pub status: u8,
/// The meta associated with this request
///
/// Because the meaning of the meta field depends on the status, please consult the
/// status code before interpreting this value. The function signature of the method
/// used to create the response should also provide more detail about what the field
/// is. In general, the meaning of the meta for a status code is
///
/// * If the status code is 20, the meta is the mime type of the body
/// * If the status code is 3X, the meta is a URL to redirect to
/// * If the status code is 44, the meta is a time in seconds until ratelimiting ends
/// * If the status code is anything els, the meta is a message or prompt for the user
pub meta: String,
/// The body of this request
///
/// This never needs to be present, and **cannot** be present if the status code !=
/// 20.
pub body: Option<Body>,
}
impl Response {
pub const fn new(header: ResponseHeader) -> Self {
/// Create a response with a given status and meta
pub fn new(status: u8, meta: impl ToString) -> Self {
Self {
header,
status,
meta: meta.to_string(),
body: None,
}
}
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
let header = ResponseHeader::input(prompt)?;
Ok(Self::new(header))
/// Create a INPUT (10) response with a given prompt
///
/// Use [`Response::sensitive_input()`] for collecting any sensitive input, as input
/// collected by this request may be logged.
pub fn input(prompt: impl ToString) -> Self {
Self::new(10, prompt)
}
pub fn input_lossy(prompt: impl Cowy<str>) -> Self {
let header = ResponseHeader::input_lossy(prompt);
Self::new(header)
/// Create a SENSITIVE INPUT (11) response with a given prompt
///
/// See also [`Response::input()`] for unsensitive inputs
pub fn sensitive_input(prompt: impl ToString) -> Self {
Self::new(11, prompt)
}
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> Self {
let header = ResponseHeader::redirect_temporary_lossy(location);
Self::new(header)
}
/// Create a successful response with a given body and MIME
pub fn success(mime: &Mime, body: impl Into<Body>) -> Self {
/// Create a SUCCESS (20) response with a given body and MIME
pub fn success(mime: impl ToString, body: impl Into<Body>) -> Self {
Self {
header: ResponseHeader::success(mime),
status: 20,
meta: mime.to_string(),
body: Some(body.into()),
}
}
/// Create a successful response with a `text/gemini` MIME
/// Create a SUCCESS (20) response with a `text/gemini` MIME
pub fn success_gemini(body: impl Into<Body>) -> Self {
Self::success(&GEMINI_MIME, body)
Self::success("text/gemini", body)
}
/// Create a successful response with a `text/plain` MIME
/// Create a SUCCESS (20) response with a `text/plain` MIME
pub fn success_plain(body: impl Into<Body>) -> Self {
Self::success(&mime::TEXT_PLAIN, body)
Self::success("text/plain", body)
}
pub fn server_error(reason: impl Cowy<str>) -> Result<Self> {
let header = ResponseHeader::server_error(reason)?;
Ok(Self::new(header))
/// Create a REDIRECT - TEMPORARY (30) response with a destination
pub fn redirect_temporary(dest: impl ToString) -> Self {
Self::new(30, dest)
}
/// Create a REDIRECT - PERMANENT (31) response with a destination
pub fn redirect_permanent(dest: impl ToString) -> Self {
Self::new(31, dest)
}
/// Create a TEMPORARY FAILURE (40) response with a human readable error
pub fn temporary_failure(reason: impl ToString) -> Self {
Self::new(40, reason)
}
/// Create a SERVER UNAVAILABLE (41) response with a human readable error
///
/// Used to denote that the server is temporarily unavailable, for example due to
/// heavy load, or maintenance
pub fn server_unavailable(reason: impl ToString) -> Self {
Self::new(41, reason)
}
/// Create a CGI ERROR (42) response with a human readable error
pub fn cgi_error(reason: impl ToString) -> Self {
Self::new(42, reason)
}
/// Create a PROXY ERROR (43) response with a human readable error
pub fn proxy_error(reason: impl ToString) -> Self {
Self::new(43, reason)
}
/// Create a SLOW DOWN (44) response with a wait time in seconds
///
/// Used to denote that the user should wait a certain number of seconds before
/// sending another request, often for ratelimiting purposes
pub fn slow_down(wait: u64) -> Self {
Self::new(44, wait)
}
/// Create a PERMANENT FAILURE (50) response with a human readable error
pub fn permanent_failure(reason: impl ToString) -> Self {
Self::new(50, reason)
}
/// Create a NOT FOUND (51) response with no further information
///
/// Essentially a 404
pub fn not_found() -> Self {
let header = ResponseHeader::not_found();
Self::new(header)
Self::new(51, String::new())
}
pub fn bad_request_lossy(reason: impl Cowy<str>) -> Self {
let header = ResponseHeader::bad_request_lossy(reason);
Self::new(header)
/// Create a GONE (52) response with a human readable error
///
/// For when a resource used to be here, but never will be again
pub fn gone(reason: impl ToString) -> Self {
Self::new(52, reason)
}
pub fn client_certificate_required() -> Self {
let header = ResponseHeader::client_certificate_required();
Self::new(header)
/// Create a PROXY REQUEST REFUSED (53) response with a human readable error
///
/// The server does not serve content on this domain
pub fn proxy_request_refused(reason: impl ToString) -> Self {
Self::new(53, reason)
}
pub fn certificate_not_authorized() -> Self {
let header = ResponseHeader::certificate_not_authorized();
Self::new(header)
/// Create a BAD REQUEST (59) response with a human readable error
pub fn bad_request(reason: impl ToString) -> Self {
Self::new(59, reason)
}
pub fn with_body(mut self, body: impl Into<Body>) -> Self {
self.body = Some(body.into());
self
/// Create a CLIENT CERTIFICATE REQUIRED (60) response with a human readable error
pub fn client_certificate_required(reason: impl ToString) -> Self {
Self::new(60, reason)
}
pub const fn header(&self) -> &ResponseHeader {
&self.header
/// Create a CERTIFICATE NOT AUTHORIZED (61) response with a human readable error
pub fn certificate_not_authorized(reason: impl ToString) -> Self {
Self::new(61, reason)
}
pub fn take_body(&mut self) -> Option<Body> {
self.body.take()
/// Create a CERTIFICATE NOT VALID (62) response with a human readable error
pub fn certificate_not_valid(reason: impl ToString) -> Self {
Self::new(62, reason)
}
/// True if the response is a SUCCESS (10) response
pub const fn is_success(&self) -> bool {
self.status == 10
}
#[cfg_attr(feature="gemini_srv",allow(unused_variables))]
/// Rewrite any links in this response based on the path identified by a request
///
/// Currently, this rewrites any links in:
/// * SUCCESS (10) requests with a `text/gemini` MIME
/// * REDIRECT (3X) requests
///
/// For all other responses, and for any responses without links, this method has no
/// effect.
///
/// If this response contains a reader-based body, this **MAY** load the reader's
/// contents into memory if the mime is "text/gemini". If an IO error occurs during
/// this process, this error will be raised
///
/// If the request does not contain enough information to rewrite a link (in other
/// words, if [`Request::rewrite_path()`] returns [`None`]), then false is returned.
/// In all other cases, this method returns true.
///
/// Panics if a "text/gemini" response is not UTF-8 formatted
///
/// For an overview of methods for rewriting links, see
/// [`Server::set_autorewrite()`].\
/// For more information about how rewritten paths are calculated, see
/// [`Request::rewrite_path()`].
///
/// [`Server::set_autorewrite()`]: crate::Server::set_autorewrite()
/// [`Request::rewrite_path()`]: crate::Server::set_autorewrite()
pub async fn rewrite_all(&mut self, based_on: &crate::Request) -> std::io::Result<bool> {
#[cfg(feature = "scgi_srv")]
match self.status {
20 if self.meta == "text/gemini" => {
if let Some(body) = self.body.as_mut() {
body.rewrite_all(based_on).await
} else {
Ok(false)
}
},
30 | 31 => {
if let Some(path) = based_on.rewrite_path(&self.meta) {
self.meta = path;
Ok(true)
} else {
Ok(false)
}
},
_ => Ok(true),
}
#[cfg(feature = "gemini_srv")]
Ok(true)
}
}
impl AsRef<Option<Body>> for Response {
fn as_ref(&self) -> &Option<Body> {
&self.body
}
}
impl AsMut<Option<Body>> for Response {
fn as_mut(&mut self) -> &mut Option<Body> {
&mut self.body
}
}

View File

@ -1,98 +0,0 @@
use std::convert::TryInto;
use anyhow::*;
use uriparse::URIReference;
use crate::Mime;
use crate::util::Cowy;
use crate::types::{Status, Meta};
#[derive(Debug,Clone)]
pub struct ResponseHeader {
pub status: Status,
pub meta: Meta,
}
impl ResponseHeader {
pub fn input(prompt: impl Cowy<str>) -> Result<Self> {
Ok(Self {
status: Status::INPUT,
meta: Meta::new(prompt).context("Invalid input prompt")?,
})
}
pub fn input_lossy(prompt: impl Cowy<str>) -> Self {
Self {
status: Status::INPUT,
meta: Meta::new_lossy(prompt),
}
}
pub fn success(mime: &Mime) -> Self {
Self {
status: Status::SUCCESS,
meta: Meta::new_lossy(mime.to_string()),
}
}
pub fn redirect_temporary_lossy<'a>(location: impl TryInto<URIReference<'a>>) -> Self {
let location = match location.try_into() {
Ok(location) => location,
Err(_) => return Self::bad_request_lossy("Invalid redirect location"),
};
Self {
status: Status::REDIRECT_TEMPORARY,
meta: Meta::new_lossy(location.to_string()),
}
}
pub fn server_error(reason: impl Cowy<str>) -> Result<Self> {
Ok(Self {
status: Status::PERMANENT_FAILURE,
meta: Meta::new(reason).context("Invalid server error reason")?,
})
}
pub fn server_error_lossy(reason: impl Cowy<str>) -> Self {
Self {
status: Status::PERMANENT_FAILURE,
meta: Meta::new_lossy(reason),
}
}
pub fn not_found() -> Self {
Self {
status: Status::NOT_FOUND,
meta: Meta::new_lossy("Not found"),
}
}
pub fn bad_request_lossy(reason: impl Cowy<str>) -> Self {
Self {
status: Status::BAD_REQUEST,
meta: Meta::new_lossy(reason),
}
}
pub fn client_certificate_required() -> Self {
Self {
status: Status::CLIENT_CERTIFICATE_REQUIRED,
meta: Meta::new_lossy("No certificate provided"),
}
}
pub fn certificate_not_authorized() -> Self {
Self {
status: Status::CERTIFICATE_NOT_AUTHORIZED,
meta: Meta::new_lossy("Your certificate is not authorized to view this content"),
}
}
pub const fn status(&self) -> &Status {
&self.status
}
pub const fn meta(&self) -> &Meta {
&self.meta
}
}

View File

@ -1,82 +0,0 @@
#[derive(Debug,Copy,Clone,PartialEq,Eq)]
pub struct Status(u8);
impl Status {
pub const INPUT: Self = Self(10);
pub const SENSITIVE_INPUT: Self = Self(11);
pub const SUCCESS: Self = Self(20);
pub const REDIRECT_TEMPORARY: Self = Self(30);
pub const REDIRECT_PERMANENT: Self = Self(31);
pub const TEMPORARY_FAILURE: Self = Self(40);
pub const SERVER_UNAVAILABLE: Self = Self(41);
pub const CGI_ERROR: Self = Self(42);
pub const PROXY_ERROR: Self = Self(43);
pub const SLOW_DOWN: Self = Self(44);
pub const PERMANENT_FAILURE: Self = Self(50);
pub const NOT_FOUND: Self = Self(51);
pub const GONE: Self = Self(52);
pub const PROXY_REQUEST_REFUSED: Self = Self(53);
pub const BAD_REQUEST: Self = Self(59);
pub const CLIENT_CERTIFICATE_REQUIRED: Self = Self(60);
pub const CERTIFICATE_NOT_AUTHORIZED: Self = Self(61);
pub const CERTIFICATE_NOT_VALID: Self = Self(62);
pub const fn code(&self) -> u8 {
self.0
}
pub fn is_success(&self) -> bool {
self.category().is_success()
}
#[allow(clippy::missing_const_for_fn)]
pub fn category(&self) -> StatusCategory {
let class = self.0 / 10;
match class {
1 => StatusCategory::Input,
2 => StatusCategory::Success,
3 => StatusCategory::Redirect,
4 => StatusCategory::TemporaryFailure,
5 => StatusCategory::PermanentFailure,
6 => StatusCategory::ClientCertificateRequired,
_ => StatusCategory::PermanentFailure,
}
}
}
#[derive(Copy,Clone,PartialEq,Eq)]
pub enum StatusCategory {
Input,
Success,
Redirect,
TemporaryFailure,
PermanentFailure,
ClientCertificateRequired,
}
impl StatusCategory {
pub fn is_input(&self) -> bool {
*self == Self::Input
}
pub fn is_success(&self) -> bool {
*self == Self::Success
}
pub fn redirect(&self) -> bool {
*self == Self::Redirect
}
pub fn is_temporary_failure(&self) -> bool {
*self == Self::TemporaryFailure
}
pub fn is_permanent_failure(&self) -> bool {
*self == Self::PermanentFailure
}
pub fn is_client_certificate_required(&self) -> bool {
*self == Self::ClientCertificateRequired
}
}

View File

@ -0,0 +1,116 @@
use serde::{Serialize, de::DeserializeOwned};
use crate::user_management::{User, Result};
use crate::user_management::user::{RegisteredUser, NotSignedInUser, PartialUser};
#[derive(Debug, Clone)]
/// A struct containing information for managing users.
///
/// Wraps a [`sled::Db`]
pub struct UserManager {
pub db: sled::Db,
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 {
/// Create or open a new UserManager
///
/// The `dir` argument is the path to a data directory, to be populated using sled.
/// This will be created if it does not exist.
pub fn new(db: sled::Db) -> Result<Self> {
Ok(Self {
users: db.open_tree("gay.emii.kochab.users")?,
certificates: db.open_tree("gay.emii.kochab.certificates")?,
db,
})
}
/// Lookup the owner of a certificate based on it's fingerprint
///
/// # 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_certificate(&self, cert: [u8; 32]) -> Result<Option<String>> {
if let Some(bytes) = self.certificates.get(cert)? {
Ok(Some(std::str::from_utf8(bytes.as_ref())?.to_string()))
} else {
Ok(None)
}
}
/// 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,
username: impl AsRef<str>
) -> Result<Option<RegisteredUser<UserData>>>
where
UserData: Serialize + DeserializeOwned
{
if let Some(bytes) = self.users.get(username.as_ref())? {
let inner: PartialUser<UserData> = bincode::deserialize_from(bytes.as_ref())?;
Ok(Some(RegisteredUser::new(username.as_ref().to_owned(), None, self.clone(), inner)))
} else {
Ok(None)
}
}
/// Produce a list of all users in the database
///
/// # Panics
/// An panics if there is an error reading from the database or if data recieved from
/// the database is corrupt
pub fn all_users<UserData>(
&self,
) -> Vec<RegisteredUser<UserData>>
where
UserData: Serialize + DeserializeOwned
{
self.users.iter()
.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.
///
/// # Errors
/// An error is thrown if there is an error reading from the database or if data
/// recieved from the database is corrupt
///
/// # Panics
/// Pancis if the database is corrupt
pub fn get_user<UserData>(
&self,
cert: Option<&[u8; 32]>
) -> Result<User<UserData>>
where
UserData: Serialize + DeserializeOwned
{
if let Some(certificate) = cert {
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 {
certificate: *certificate,
manager: self.clone(),
}))
}
} else {
Ok(User::Unauthenticated)
}
}
}

114
src/user_management/mod.rs Normal file
View File

@ -0,0 +1,114 @@
//! Tools for registering users & persisting arbitrary user data
//!
//! Many Gemini applications use some form of a login method in order to allow users to
//! persist personal data, authenticate themselves, and login from multiple devices using
//! multiple certificates.
//!
//! This module contains tools to help you build a system like this without stress. A
//! typical workflow looks something like this:
//!
//! * Call [`Request::user()`] to retrieve information about a user
//! * Direct any users without a certificate to create a certificate
//! * Ask users with a certificate not yet linked to an account to create an account using
//! [`NotSignedInUser::register()`] or link their certificate to an existing account
//! with a password using [`NotSignedInUser::attach()`].
//! * You should now have a [`RegisteredUser`] either from registering/attaching a
//! [`NotSignedInUser`] or because the user was already registered
//! * Access and modify user data using [`RegisteredUser::as_mut()`], changes are
//! automatically persisted to the database (on user drop).
//!
//! Use of this module requires the `user_management` feature to be enabled
pub mod user;
mod manager;
#[cfg(feature = "user_management_routes")]
mod routes;
#[cfg(feature = "user_management_routes")]
pub use routes::UserManagementRoutes;
pub use manager::UserManager;
pub use user::User;
// Imports for docs
#[allow(unused_imports)]
use user::{NotSignedInUser, RegisteredUser};
#[allow(unused_imports)]
use crate::types::Request;
#[derive(Debug)]
pub enum UserManagerError {
UsernameNotUnique,
PasswordNotSet,
DatabaseError(sled::Error),
DatabaseTransactionError(sled::transaction::TransactionError),
DeserializeBincodeError(bincode::Error),
DeserializeUtf8Error(std::str::Utf8Error),
#[cfg(feature = "user_management_advanced")]
Argon2Error(argon2::Error),
}
impl From<sled::Error> for UserManagerError {
fn from(error: sled::Error) -> Self {
Self::DatabaseError(error)
}
}
impl From<sled::transaction::TransactionError> for UserManagerError {
fn from(error: sled::transaction::TransactionError) -> Self {
Self::DatabaseTransactionError(error)
}
}
impl From<bincode::Error> for UserManagerError {
fn from(error: bincode::Error) -> Self {
Self::DeserializeBincodeError(error)
}
}
impl From<std::str::Utf8Error> for UserManagerError {
fn from(error: std::str::Utf8Error) -> Self {
Self::DeserializeUtf8Error(error)
}
}
#[cfg(feature = "user_management_advanced")]
impl From<argon2::Error> for UserManagerError {
fn from(error: argon2::Error) -> Self {
Self::Argon2Error(error)
}
}
impl std::error::Error for UserManagerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::DatabaseError(e) => Some(e),
Self::DatabaseTransactionError(e) => Some(e),
Self::DeserializeBincodeError(e) => Some(e),
Self::DeserializeUtf8Error(e) => Some(e),
#[cfg(feature = "user_management_advanced")]
Self::Argon2Error(e) => Some(e),
_ => None
}
}
}
impl std::fmt::Display for UserManagerError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::result::Result<(), std::fmt::Error> {
match self {
Self::UsernameNotUnique =>
write!(f, "Attempted to create a user using a username that's already been taken"),
Self::PasswordNotSet =>
write!(f, "Attempted to check the password of a user who has not set one yet"),
Self::DatabaseError(e) =>
write!(f, "Error accessing the user database: {}", e),
Self::DatabaseTransactionError(e) =>
write!(f, "Error accessing the user database: {}", 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),
}
}
}
pub type Result<T> = std::result::Result<T, UserManagerError>;

View File

@ -0,0 +1,7 @@
# Account Exists
Hi {username}!
It looks like you already have an account all set up, and you're good to go. The link below will take you back to the app.
=> {redirect} Back

View File

@ -0,0 +1,11 @@
# Certificate Found!
Your certificate was found, and you're good to go.
If this is your first time, please create an account to get started!
=> /account/register Sign Up
If you already have an account, and this is a new certificate that you'd like to link, you can login using your password (if you've set it) below.
=> /account/login Log In

View File

@ -0,0 +1,32 @@
# Client-cert Compatible Gemini Clients
The following is an (incomplete) list of clients known to support client certicates. You will need to use one of these (or another client-cert compatible client) in order to log in.
## Amfora (TUI)
Amfora supports client certs, although they currently need to be manually linked. Instructions for how to do this can be found on Amfora's GitHub
=> https://github.com/makeworld-the-better-one/amfora#client-certificates Instructions
=> https://github.com/makeworld-the-better-one/amfora.git Website
## Kristal (GUI)
Kristal supports certificates out of the box! All you need to do is click on a link requesting a client certificate, and Kristall will prompt you to create one. Remember to set an account password if you're using ephemeral certificates though, otherwise you won't be able to log back in when they expire!
=> https://github.com/MasterQ32/kristall Website
## AV-98 (TUI)
AV-98 (The OG gemini client) will automatically generate a client certificate for you when you click on a link that requires one.
=> https://tildegit.org/solderpunk/AV-98 Website
## Gemini for iOS (Mobile)
=> https://github.com/pitr/gemini-ios Website
## Others?
If you know of more clients, please feel free to add them to this list by opening a PR or an issue on the kochab (Gemini Server SDK) GitHub!
=> https://github.com/Alch-Emi/kochab/issues/new New Issue

View File

@ -0,0 +1,7 @@
# Success!
Welcome {username}!
Your certificate has been linked.
=> {redirect} Back to app

View File

@ -0,0 +1,8 @@
# Wrong username or password
Sorry {username},
It looks like that username and password didn't match.
=> /account/login/{username} Try another password
=> /account/login That's not me!

View File

@ -0,0 +1,5 @@
# Certificate Found!
Your certificate was found, all that's left to do is pick a username!
=> /account/register Sign Up

View File

@ -0,0 +1,5 @@
# Welcome!
To continue, please create an account.
=> /account/register Set up my account

View File

@ -0,0 +1,5 @@
# Username Exists
Unfortunately, it looks like the username {username} is already taken.
=> /account/register Choose a different username

View File

@ -0,0 +1,5 @@
# Account Created!
Welcome {username}! Your account has been created.
=> {redirect} Back to app

View File

@ -0,0 +1,8 @@
# Welcome!
To continue, please create an account, or log in to link this certificate to an existing account.
=> /account/login Log In
=> /account/register Sign Up
Note: You can only link a new certificate if you have set a password using your original certificate. If you haven't already, please log in and set a password.

View File

@ -0,0 +1,5 @@
# Password Updated
To add a certificate, log in using the new certificate and provide your username and password.
=> /account Back to settings

View File

@ -0,0 +1,6 @@
# Username Exists
Unfortunately, it looks like the username {username} is already taken. If this is your account, and you have a password set, you can link this certificate to your account. Otherwise, please choose another username.
=> /account/register Choose a different username
=> /account/login/{username} Link this certificate

View File

@ -0,0 +1,6 @@
# Account Created!
Welcome {username}! Your account has been created. The link below will take you back to the app, or you can take a moment to review your account settings, including adding a password.
=> {redirect} Back
=> /account Settings

View File

@ -0,0 +1,9 @@
# Welcome!
It seems like you don't have a client certificate enabled. In order to log in, you need to connect using a client certificate. If your client supports it, you can use the link below to activate a certificate.
=> /account/askcert/{redirect} Choose a Certificate
If your client can't automatically manage client certificates, check the link below for a list of clients that support client certificates.
=> /account/clients/{redirect} Clients

View File

@ -0,0 +1,441 @@
use anyhow::Result;
use serde::{Serialize, de::DeserializeOwned};
#[cfg(feature = "dashmap")]
use dashmap::DashMap;
#[cfg(not(feature = "dashmap"))]
use std::collections::HashMap;
#[cfg(not(feature = "dashmap"))]
use std::sync::RwLock;
use std::future::Future;
use crate::{Gemtext, Request, Response};
use crate::user_management::{
User,
RegisteredUser,
UserManagerError,
user::NotSignedInUser,
};
/// Import this trait to use [`add_um_routes()`](Self::add_um_routes())
pub trait UserManagementRoutes: private::Sealed {
/// Add pre-configured routes to the serve to handle authentication
///
/// Specifically, the following routes are added:
/// * `/account`, the main settings & login page
/// * `/account/askcert`, a page which always prompts for a certificate
/// * `/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
///
/// 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
/// to `/account`, which will start the login/registration flow.
///
/// 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 + 'static>(self) -> Self;
/// Add a special route that requires users to be logged in
///
/// In addition to the normal [`Request`], your handler will recieve a copy of the
/// [`RegisteredUser`] for the current user. If a user tries to connect to the page
/// without logging in, they will be prompted to register or link an account.
///
/// To use this method, ensure that [`add_um_routes()`](Self::add_um_routes()) has
/// also been called.
fn add_authenticated_route<UserData, Handler, F>(
self,
path: &'static str,
handler: Handler,
) -> Self
where
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>) -> F,
F: Send + Sync + 'static + Future<Output = Result<Response>>;
/// Add a special route that requires users to be logged in AND takes input
///
/// Like with [`add_authenticated_route()`](Self::add_authenticated_route()), this
/// prompts the user to log in if they haven't already, but additionally prompts the
/// user for input before running the handler with both the user object and the input
/// they provided.
///
/// To a user, this might look something like this:
/// * Click a link to `/your/route`
/// * See a screen asking you to sign in or create an account
/// * Create a new account, and return to the app.
/// * Now, clicking the link shows the prompt provided.
/// * After entering some value, the user receives the response from the handler.
///
/// For a user whose already logged in, this will just look like a normal input route,
/// where they enter some query and see a page. This method just takes the burden of
/// having to check if the user sent a query string and respond with an INPUT response
/// if not.
///
/// To use this method, ensure that [`add_um_routes()`](Self::add_um_routes()) has
/// also been called.
fn add_authenticated_input_route<UserData, Handler, F>(
self,
path: &'static str,
prompt: &'static str,
handler: Handler,
) -> Self
where
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>, String) -> F,
F: Send + Sync + 'static + Future<Output = Result<Response>>;
}
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 + '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);
#[cfg(feature = "user_management_advanced")] {
modified_self = modified_self
.add_route("/account/login", handle_login::<UserData>)
.add_route("/account/password", handle_password::<UserData>);
}
modified_self
}
/// Add a special route that requires users to be logged in
///
/// See [`UserManagementRoutes::add_authenticated_route()`]
fn add_authenticated_route<UserData, Handler, F>(
self,
path: &'static str,
handler: Handler,
) -> Self
where
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>) -> F,
F: Send + Sync + 'static + Future<Output = Result<Response>>
{
self.add_route(path, move|request: Request| {
let handler = handler.clone();
async move {
let segments = request.path_segments();
let segments = segments.iter().map(String::as_ref).collect::<Vec<&str>>();
Ok(match request.user::<UserData>()? {
User::Unauthenticated => {
render_unauth_page(segments)
},
User::NotSignedIn(user) => {
save_redirect(&user, segments);
Response::success_gemini(NSI)
},
User::SignedIn(user) => {
(handler)(request, user).await?
},
})
}
})
}
/// Add a special route that requires users to be logged in AND takes input
///
/// See [`UserManagementRoutes::add_authenticated_input_route()`]
fn add_authenticated_input_route<UserData, Handler, F>(
self,
path: &'static str,
prompt: &'static str,
handler: Handler,
) -> Self
where
UserData: Serialize + DeserializeOwned + 'static + Send + Sync,
Handler: Clone + Send + Sync + 'static + Fn(Request, RegisteredUser<UserData>, String) -> F,
F: Send + Sync + 'static + Future<Output = Result<Response>>
{
self.add_authenticated_route(path, move|request, user| {
let handler = handler.clone();
async move {
if let Some(input) = request.input().map(str::to_owned) {
(handler.clone())(request, user, input).await
} else {
Ok(Response::input(prompt))
}
}
})
}
}
#[cfg(feature = "user_management_advanced")]
const NSI: &str = include_str!("pages/nsi.gmi");
#[cfg(not(feature = "user_management_advanced"))]
const NSI: &str = include_str!("pages/nopass/nsi.gmi");
// TODO periodically clean these
#[cfg(feature = "dashmap")]
lazy_static::lazy_static! {
static ref PENDING_REDIRECTS: DashMap<[u8; 32], String> = Default::default();
}
#[cfg(not(feature = "dashmap"))]
lazy_static::lazy_static! {
static ref PENDING_REDIRECTS: RwLock<HashMap<[u8; 32], String>> = Default::default();
}
async fn handle_base<UserData: Serialize + DeserializeOwned>(request: Request) -> Result<Response> {
let segments = request.trailing_segments().iter().map(String::as_str).collect::<Vec<&str>>();
Ok(match request.user::<UserData>()? {
User::Unauthenticated => {
render_unauth_page(segments)
},
User::NotSignedIn(usr) => {
save_redirect(&usr, segments);
Response::success_gemini(NSI)
},
User::SignedIn(user) => {
render_settings_menu(user)
},
})
}
async fn handle_ask_cert<UserData: Serialize + DeserializeOwned>(request: Request) -> Result<Response> {
Ok(match request.user::<UserData>()? {
User::Unauthenticated => {
Response::client_certificate_required("Please select a client certificate to proceed.")
},
User::NotSignedIn(nsi) => {
let segments = request.trailing_segments().iter().map(String::as_str).collect::<Vec<&str>>();
save_redirect(&nsi, segments);
#[cfg(feature = "user_management_advanced")] {
Response::success_gemini(include_str!("pages/askcert/success.gmi"))
}
#[cfg(not(feature = "user_management_advanced"))] {
Response::success_gemini(include_str!("pages/nopass/askcert/success.gmi"))
}
},
User::SignedIn(user) => {
Response::success_gemini(format!(
include_str!("pages/askcert/exists.gmi"),
username = user.username(),
redirect = get_redirect(&user),
))
},
})
}
async fn handle_register<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
Ok(match request.user::<UserData>()? {
User::Unauthenticated => {
render_unauth_page(&[""])
},
User::NotSignedIn(nsi) => {
if let Some(username) = request.input() {
match nsi.register::<UserData>(username.to_owned()) {
Err(UserManagerError::UsernameNotUnique) => {
#[cfg(feature = "user_management_advanced")] {
Response::success_gemini(format!(
include_str!("pages/register/exists.gmi"),
username = username,
))
}
#[cfg(not(feature = "user_management_advanced"))] {
Response::success_gemini(format!(
include_str!("pages/register/exists.gmi"),
username = username,
))
}
},
Ok(user) => {
#[cfg(feature = "user_management_advanced")] {
Response::success_gemini(format!(
include_str!("pages/register/success.gmi"),
username = username,
redirect = get_redirect(&user),
))
}
#[cfg(not(feature = "user_management_advanced"))] {
Response::success_gemini(format!(
include_str!("pages/nopass/register/success.gmi"),
username = username,
redirect = get_redirect(&user),
))
}
},
Err(e) => return Err(e.into())
}
} else {
Response::input("Please pick a username")
}
},
User::SignedIn(user) => {
render_settings_menu(user)
},
})
}
#[cfg(feature = "user_management_advanced")]
async fn handle_login<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
Ok(match request.user::<UserData>()? {
User::Unauthenticated => {
render_unauth_page(&[""])
},
User::NotSignedIn(nsi) => {
if let Some(username) = request.trailing_segments().get(0) {
if let Some(password) = request.input() {
match nsi.attach::<UserData>(username, Some(password.as_bytes())) {
Err(UserManagerError::PasswordNotSet) | Ok(None) => {
Response::success_gemini(format!(
include_str!("pages/login/wrong.gmi"),
username = username,
))
},
Ok(Some(user)) => {
Response::success_gemini(format!(
include_str!("pages/login/success.gmi"),
username = username,
redirect = get_redirect(&user),
))
},
Err(e) => return Err(e.into()),
}
} else {
Response::sensitive_input("Please enter your password")
}
} else if let Some(username) = request.input() {
Response::redirect_temporary(
format!("/account/login/{}", username).as_str()
)
} else {
Response::input("Please enter your username")
}
},
User::SignedIn(user) => {
render_settings_menu(user)
},
})
}
#[cfg(feature = "user_management_advanced")]
async fn handle_password<UserData: Serialize + DeserializeOwned + Default>(request: Request) -> Result<Response> {
Ok(match request.user::<UserData>()? {
User::Unauthenticated => {
render_unauth_page(&[""])
},
User::NotSignedIn(nsi) => {
save_redirect(&nsi, &[""]);
Response::success_gemini(NSI)
},
User::SignedIn(mut user) => {
if let Some(password) = request.input() {
user.set_password(password)?;
Response::success_gemini(include_str!("pages/password/success.gmi"))
} else {
Response::sensitive_input(
format!("Please enter a {}password",
if user.has_password() {
"new "
} else {
""
}
)
)
}
},
})
}
fn render_settings_menu<UserData: Serialize + DeserializeOwned>(
user: RegisteredUser<UserData>
) -> Response {
#[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"))
.blank_line();
#[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"
}
)
);
}
document.into()
}
fn render_unauth_page<'a>(
redirect: impl AsRef<[&'a str]>,
) -> Response {
Response::success_gemini(format!(
include_str!("pages/unauth.gmi"),
redirect = redirect.as_ref().join("/"),
))
}
fn save_redirect<'a>(
user: &NotSignedInUser,
redirect: impl AsRef<[&'a str]>,
) {
let mut redirect = redirect.as_ref().join("/");
redirect.insert(0, '/');
if redirect.len() > 1 {
#[cfg(feature = "dashmap")]
let ref_to_map = &*PENDING_REDIRECTS;
#[cfg(not(feature = "dashmap"))]
let mut ref_to_map = PENDING_REDIRECTS.write().unwrap();
debug!("Added \"{}\" as redirect for cert {:x?}", redirect, &user.certificate);
ref_to_map.insert(user.certificate, redirect);
}
}
fn get_redirect<T: Serialize + DeserializeOwned>(user: &RegisteredUser<T>) -> String {
let cert = user.active_certificate().unwrap();
#[cfg(feature = "dashmap")]
let maybe_redir = PENDING_REDIRECTS.get(cert).map(|r| r.clone());
#[cfg(not(feature = "dashmap"))]
let ref_to_map = PENDING_REDIRECTS.read().unwrap();
#[cfg(not(feature = "dashmap"))]
let maybe_redir = ref_to_map.get(cert).cloned();
let redirect = maybe_redir.unwrap_or_else(||"/".to_string());
debug!("Accessed redirect to \"{}\" for cert {:x?}", redirect, cert);
redirect
}
mod private {
pub trait Sealed {}
impl Sealed for crate::Server {}
}

428
src/user_management/user.rs Normal file
View File

@ -0,0 +1,428 @@
//! Several structs representing data about users
//!
//! This module contains any structs needed to store and retrieve data about users. The
//! different varieties have different purposes and come from different places.
//!
//! [`User`] is the most common for of user struct, and typically comes from calling
//! [`Request::user()`](crate::types::Request::user()). This is an enum with several
//! variants, and can be specialized into a [`NotSignedInUser`] or a [`RegisteredUser`] if
//! the user has presented a certificate. These two subtypes have more specific
//! information, like the user's username and active certificate.
//!
//! [`RegisteredUser`] is particularly signifigant in that this is the struct used to modify
//! the data stored for almost all users. This is accomplished through the
//! [`as_mut()`](RegisteredUser::as_mut) method. Changes made this way must be persisted
//! using [`save()`](RegisteredUser::save()) or by dropping the user.
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use sled::Transactional;
#[cfg(all(not(feature = "ring"), feature = "user_management_advanced"))]
use std::time::{SystemTime, UNIX_EPOCH};
use crate::user_management::UserManager;
use crate::user_management::Result;
#[cfg(feature = "user_management_advanced")]
const ARGON2_CONFIG: argon2::Config = argon2::Config {
ad: &[],
hash_length: 32,
lanes: 1,
mem_cost: 4096,
secret: &[],
thread_mode: argon2::ThreadMode::Sequential,
time_cost: 3,
variant: argon2::Variant::Argon2id,
version: argon2::Version::Version13,
};
#[cfg(all(feature = "user_management_advanced", feature = "ring"))]
lazy_static::lazy_static! {
static ref RANDOM: ring::rand::SystemRandom = ring::rand::SystemRandom::new();
}
/// An struct corresponding to the data stored in the user tree
///
/// In order to generate a full user obj, you need to perform a lookup with a specific
/// certificate.
#[derive(Clone, Debug, Deserialize, Serialize)]
pub (crate) struct PartialUser<UserData> {
pub data: UserData,
pub certificates: Vec<[u8; 32]>,
#[cfg(feature = "user_management_advanced")]
pub pass_hash: Option<(Vec<u8>, [u8; 32])>,
}
impl<UserData> PartialUser<UserData> {
/// Write user data to the database
///
/// 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, username: impl AsRef<[u8]>) -> Result<()>
where
UserData: Serialize
{
tree.insert(
&username,
bincode::serialize(&self)?,
)?;
Ok(())
}
}
/// Any information about the connecting user
#[derive(Clone, Debug)]
pub enum User<UserData: Serialize + DeserializeOwned> {
/// A user who is connected without using a client certificate.
///
/// This could be a user who has an account but just isn't presenting a certificate at
/// the minute, a user whose client does not support client certificates, or a user
/// who has not yet created a certificate for the site
Unauthenticated,
/// A user who is connecting with a certificate that isn't connected to an account
///
/// This is typically a new user who hasn't set up an account yet, or a user
/// connecting with a new certificate that needs to be added to an existing account.
NotSignedIn(NotSignedInUser),
/// A user connecting with an identified account
SignedIn(RegisteredUser<UserData>),
}
#[derive(Clone, Debug)]
/// Data about a user with a certificate not associated with an account
///
/// For more information about the user lifecycle and sign-in stages, see [`User`]
pub struct NotSignedInUser {
pub (crate) certificate: [u8; 32],
pub (crate) manager: UserManager,
}
impl NotSignedInUser {
/// Register a new user with this certificate.
///
/// This creates a new user & user data entry in the database with the given username.
/// From now on, when this user logs in with this certificate, they will be
/// automatically authenticated, and their user data automatically retrieved.
///
/// # Errors
/// The provided username must be unique, or else an error will be raised.
///
/// Additional errors might occur if there is a problem writing to the database
pub fn register<UserData: Serialize + DeserializeOwned + Default>(
self,
username: String,
) -> Result<RegisteredUser<UserData>> {
if self.manager.users.contains_key(username.as_str())? {
Err(super::UserManagerError::UsernameNotUnique)
} else {
info!("User {} registered!", username);
let mut newser = RegisteredUser::new(
username,
Some(self.certificate),
self.manager,
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)
}
}
#[cfg(feature = "user_management_advanced")]
/// Attach this certificate to an existing user
///
/// Try to add this certificate to another user using a username and password. If
/// successful, the user this certificate is attached to will be able to automatically
/// log in with either this certificate or any of the certificates they already had
/// registered.
///
/// This method can check the user's password to ensure that they match before
/// registering. If you want to skip this verification, perhaps because you've
/// already verified that this user owns this account, then you can pass [`None`] as
/// the password to skip the password check.
///
/// This method returns the new RegisteredUser instance representing the now-attached
/// user, or [`None`] if the username and password didn't match.
///
/// Because this method both performs a bcrypt verification and a database access, it
/// should be considered expensive.
///
/// If you already have a [`RegisteredUser`] that you would like to attach a
/// certificate to, consider using [`RegisteredUser::add_certificate()`]
///
/// # Errors
/// This will error if the user has yet to set a password.
///
/// Additional errors might occur if an error occurs during database lookup and
/// deserialization
pub fn attach<UserData: Serialize + DeserializeOwned>(
self,
username: &str,
password: Option<&[u8]>,
) -> Result<Option<RegisteredUser<UserData>>> {
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)? {
return Ok(None);
}
}
info!("User {} attached certificate with fingerprint {:x?}", username, &self.certificate[..]);
user.add_certificate(self.certificate)?;
user.active_certificate = Some(self.certificate);
Ok(Some(user))
} else {
Ok(None)
}
}
}
#[derive(Clone, Debug)]
/// Data about a logged in user
///
/// For more information about the user lifecycle and sign-in stages, see [`User`]
pub struct RegisteredUser<UserData: Serialize + DeserializeOwned> {
username: String,
active_certificate: Option<[u8; 32]>,
manager: UserManager,
inner: PartialUser<UserData>,
/// Indicates that [`RegisteredUser::as_mut()`] has been called, but [`RegisteredUser::save()`] has not
has_changed: bool,
}
impl<UserData: Serialize + DeserializeOwned> RegisteredUser<UserData> {
/// Create a new user from parts
pub (crate) fn new(
username: String,
active_certificate: Option<[u8; 32]>,
manager: UserManager,
inner: PartialUser<UserData>
) -> Self {
Self {
username,
active_certificate,
manager,
inner,
has_changed: false,
}
}
/// Update the active certificate
///
/// This is not to be confused with [`RegisteredUser::add_certificate`], which
/// performs the database operations needed to register a new certificate to a user.
/// This literally just marks the active certificate.
pub (crate) fn with_cert(mut self, cert: [u8; 32]) -> Self {
self.active_certificate = Some(cert);
self
}
/// Get the fingerprint of the certificate that the user is currently using.
///
/// If this user was retrieved by a [`UserManager::lookup_user()`], this will be
/// [`None`]. In all other cases, this will be [`Some`].
pub fn active_certificate(&self) -> Option<&[u8; 32]> {
self.active_certificate.as_ref()
}
/// Produce a list of all certificate fingerprints registered to this account
pub fn all_certificates(&self) -> &Vec<[u8; 32]> {
&self.inner.certificates
}
/// Get the user's current username.
///
/// NOTE: This is not guaranteed not to change.
pub fn username(&self) -> &String {
&self.username
}
#[cfg(feature = "user_management_advanced")]
/// Check a password against the user's password hash
///
/// # Errors
/// An error is raised if the user has yet to set a password, or if the user's
/// password hash is somehow malformed.
pub fn check_password(
&self,
try_password: impl AsRef<[u8]>
) -> Result<bool> {
if let Some((hash, salt)) = &self.inner.pass_hash {
Ok(argon2::verify_raw(
try_password.as_ref(),
salt,
hash.as_ref(),
&ARGON2_CONFIG,
)?)
} else {
Err(super::UserManagerError::PasswordNotSet)
}
}
#[cfg(feature = "user_management_advanced")]
/// Set's the password for this user
///
/// By default, users have no password, meaning the cannot add any certificates beyond
/// the one they created their account with. However, by setting their password,
/// users are able to add more devices to their account, and recover their account if
/// it's lost. Note that this will completely overwrite the users old password.
///
/// Use [`RegisteredUser::check_password()`] and [`NotSignedInUser::attach()`] to check
/// the password against another one, or to link a new certificate.
///
/// Because this method uses a key derivation algorithm, this should be considered a
/// very expensive operation.
pub fn set_password(
&mut self,
password: impl AsRef<[u8]>,
) -> Result<()> {
#[cfg_attr(feature = "ring", allow(unused_mut))]
let mut salt: [u8; 32];
// For a simple salt, system time nanos and a bit of PCG is plenty secure enough,
// but if we have ring anyway, may as well use it
#[cfg(feature = "ring")] {
salt = ring::rand::generate(&*RANDOM)
.expect("Error generating random salt")
.expose();
}
#[cfg(not(feature = "ring"))] {
let mut random = (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() | 0xffff) as u16;
random = random.wrapping_mul(0xd09d);
salt = [0; 32];
for byte in salt.as_mut() { *byte = pcg8(&mut random) }
}
self.inner.pass_hash = Some((
argon2::hash_raw(
password.as_ref(),
salt.as_ref(),
&ARGON2_CONFIG,
)?,
salt,
));
self.has_changed = true;
Ok(())
}
/// Write any updates to the user to the database.
///
/// Updates caused by calling methods directly on the user do not need to be saved.
/// This is only for changes made to the UserData.
pub fn save(&mut self) -> Result<()>
where
UserData: Serialize
{
self.inner.store(&self.manager.users, &self.username)?;
self.has_changed = false;
Ok(())
}
/// Register a new certificate to this user
///
/// This adds a new certificate to this user for use in logins. This requires a
/// couple database accesses, one in order to link the user to the certificate, and
/// one in order to link the certificate to the user.
///
/// If you have a [`NotSignedInUser`] and are looking for a way to link them to an
/// existing user, consider [`NotSignedInUser::attach()`], which contains facilities for
/// password checking and automatically performs the user lookup.
pub fn add_certificate(&mut self, certificate: [u8; 32]) -> Result<()> {
self.inner.certificates.push(certificate);
let inner_serialized = bincode::serialize(&self.inner)?;
(&self.manager.users, &self.manager.certificates)
.transaction(|(tx_usr, tx_crt)| {
tx_usr.insert(
self.username.as_str(),
inner_serialized.clone(),
)?;
tx_crt.insert(
&certificate,
self.username.as_bytes(),
)?;
Ok(())
})?;
Ok(())
}
#[cfg(feature = "user_management_advanced")]
/// Check if the user has a password set
///
/// Since authentication is done using client certificates, users aren't required to
/// set a password up front. In some cases, it may be useful to know if a user has or
/// has not set a password yet.
///
/// This returns `true` if the user has a password set, or `false` otherwise
pub fn has_password(&self) -> bool {
self.inner.pass_hash.is_some()
}
/// Get an immutable reference to the data associated with this user
pub fn data(&self) -> &UserData {
&self.inner.data
}
/// Get a mutable reference to the data associated with this user
///
/// This automatically flags the user data as needing to be saved to the database,
/// which automatically performs the action when this user falls out of scope. If
/// need be, you can push these changes to the database sooner by calling [`save()`]
///
/// [`save()`]: Self::save()
pub fn mut_data(&mut self) -> &mut UserData {
self.has_changed = true;
&mut self.inner.data
}
}
impl<UserData: Serialize + DeserializeOwned> std::ops::Drop for RegisteredUser<UserData> {
fn drop(&mut self) {
if self.has_changed {
if let Err(e) = self.save() {
error!("Failed to save user data to database: {:?}", e);
}
}
}
}
impl<UserData: Serialize + DeserializeOwned> AsRef<UserData> for RegisteredUser<UserData> {
fn as_ref(&self) -> &UserData {
self.data()
}
}
impl<UserData: Serialize + DeserializeOwned> AsMut<UserData> for RegisteredUser<UserData> {
fn as_mut(&mut self) -> &mut UserData {
self.mut_data()
}
}
#[cfg(all(feature = "user_management_advanced", not(feature = "ring")))]
/// Inexpensive but low quality random
fn pcg8(state: &mut u16) -> u8 {
const MUL: u16 = 0xfb85;
const ADD: u16 = 0xfabb;
let mut x = *state;
*state = state.wrapping_mul(MUL).wrapping_add(ADD);
let count = x >> 13;
x ^= x >> 5;
((x >> 5) as u8).rotate_right(count as u32)
}

View File

@ -1,21 +1,20 @@
//! Utilities for serving a file or directory
//!
//! ⚠️ 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 mime::Mime;
use anyhow::*;
#[cfg(feature="serve_dir")]
use tokio::{
fs::{self, File},
io,
};
#[cfg(feature="serve_dir")]
use crate::types::Response;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::task::Poll;
use futures_core::future::Future;
use tokio::time;
#[cfg(feature="serve_dir")]
pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &Mime) -> Result<Response> {
pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &str) -> Response {
let path = path.as_ref();
let file = match File::open(path).await {
@ -23,17 +22,17 @@ pub async fn serve_file<P: AsRef<Path>>(path: P, mime: &Mime) -> Result<Response
Err(err) => match err.kind() {
std::io::ErrorKind::PermissionDenied => {
warn!("Asked to serve {}, but permission denied by OS", path.display());
return Ok(Response::not_found());
return Response::not_found();
},
_ => return warn_unexpected(err, path, line!()),
}
};
Ok(Response::success(mime, file))
Response::success(mime, file)
}
#[cfg(feature="serve_dir")]
pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P]) -> Result<Response> {
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();
let dir = match dir.canonicalize() {
@ -42,11 +41,11 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
match e.kind() {
std::io::ErrorKind::NotFound => {
warn!("Path {} not found. Check your configuration.", dir.display());
return Response::server_error("Server incorrectly configured")
return Response::temporary_failure("Server incorrectly configured")
},
std::io::ErrorKind::PermissionDenied => {
warn!("Permission denied for {}. Check that the server has access.", dir.display());
return Response::server_error("Server incorrectly configured")
return Response::temporary_failure("Server incorrectly configured")
},
_ => return warn_unexpected(e, dir, line!()),
}
@ -62,12 +61,12 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
Ok(dir) => dir,
Err(e) => {
match e.kind() {
std::io::ErrorKind::NotFound => return Ok(Response::not_found()),
std::io::ErrorKind::NotFound => return Response::not_found(),
std::io::ErrorKind::PermissionDenied => {
// Runs when asked to serve a file in a restricted dir
// i.e. not /noaccess, but /noaccess/file
warn!("Asked to serve {}, but permission denied by OS", path.display());
return Ok(Response::not_found());
return Response::not_found();
},
_ => return warn_unexpected(e, path.as_ref(), line!()),
}
@ -75,26 +74,26 @@ pub async fn serve_dir<D: AsRef<Path>, P: AsRef<Path>>(dir: D, virtual_path: &[P
};
if !path.starts_with(&dir) {
return Ok(Response::not_found());
return Response::not_found();
}
if !path.is_dir() {
let mime = guess_mime_from_path(&path);
return serve_file(path, &mime).await;
return serve_file(path, mime).await;
}
serve_dir_listing(path, virtual_path).await
}
#[cfg(feature="serve_dir")]
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Result<Response> {
async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path: &[B]) -> Response {
let mut dir = match fs::read_dir(path.as_ref()).await {
Ok(dir) => dir,
Err(err) => match err.kind() {
io::ErrorKind::NotFound => return Ok(Response::not_found()),
io::ErrorKind::NotFound => return Response::not_found(),
std::io::ErrorKind::PermissionDenied => {
warn!("Asked to serve {}, but permission denied by OS", path.as_ref().display());
return Ok(Response::not_found());
return Response::not_found();
},
_ => return warn_unexpected(err, path.as_ref(), line!()),
}
@ -110,12 +109,10 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
document = document.link("..", Some("📁 ../"));
}
while let Some(entry) = dir.next_entry().await.context("Failed to list directory")? {
while let Some(entry) = dir.next_entry().await.expect("Failed to list directory") {
let file_name = entry.file_name();
let file_name = file_name.to_string_lossy();
let is_dir = entry.file_type().await
.with_context(|| format!("Failed to get file type of `{}`", entry.path().display()))?
.is_dir();
let is_dir = entry.file_type().await.unwrap().is_dir();
let trailing_slash = if is_dir { "/" } else { "" };
let uri = format!("./{}{}", file_name, trailing_slash);
@ -126,28 +123,28 @@ async fn serve_dir_listing<P: AsRef<Path>, B: AsRef<Path>>(path: P, virtual_path
)));
}
Ok(document.into())
document.into()
}
#[cfg(feature="serve_dir")]
pub fn guess_mime_from_path<P: AsRef<Path>>(path: P) -> Mime {
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());
let extension = match extension {
Some(extension) => extension,
None => return mime::APPLICATION_OCTET_STREAM,
None => return "application/octet-stream"
};
if let "gemini" | "gmi" = extension {
return crate::GEMINI_MIME.clone();
return "text/gemini";
}
mime_guess::from_ext(extension).first_or_octet_stream()
mime_guess::from_ext(extension).first_raw().unwrap_or("application/octet-stream")
}
#[cfg(feature="serve_dir")]
/// Print a warning to the log asking to file an issue and respond with "Unexpected Error"
pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Result<Response> {
pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32) -> Response {
warn!(
concat!(
"Unexpected error serving path {} at util.rs:{}, please report to ",
@ -158,7 +155,7 @@ pub (crate) fn warn_unexpected(err: impl std::fmt::Debug, path: &Path, line: u32
line,
err
);
Response::server_error("Unexpected error")
Response::temporary_failure("Unexpected error")
}
/// A convenience trait alias for `AsRef<T> + Into<T::Owned>`,
@ -176,39 +173,3 @@ where
C: AsRef<T> + Into<T::Owned>,
T: ToOwned + ?Sized,
{}
/// A utility for catching unwinds on Futures.
///
/// This is adapted from the futures-rs CatchUnwind, in an effort to reduce the large
/// amount of dependencies tied into the feature that provides this simple struct.
#[must_use = "futures do nothing unless polled"]
pub (crate) struct HandlerCatchUnwind {
future: AssertUnwindSafe<crate::HandlerResponse>,
}
impl HandlerCatchUnwind {
pub(super) fn new(future: AssertUnwindSafe<crate::HandlerResponse>) -> Self {
Self { future }
}
}
impl Future for HandlerCatchUnwind {
type Output = Result<Result<Response>, Box<dyn std::any::Any + Send>>;
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context
) -> Poll<Self::Output> {
match catch_unwind(AssertUnwindSafe(|| self.future.as_mut().poll(cx))) {
Ok(res) => res.map(Ok),
Err(e) => Poll::Ready(Err(e))
}
}
}
pub(crate) async fn opt_timeout<T>(duration: Option<time::Duration>, future: impl Future<Output = T>) -> Result<T, time::error::Elapsed> {
match duration {
Some(duration) => time::timeout(duration, future).await,
None => Ok(future.await),
}
}