feat: project scaffold with config and error types

This commit is contained in:
Till Wegmueller 2026-04-03 19:21:02 +02:00
parent 59d7c88707
commit 8123752c9c
No known key found for this signature in database
9 changed files with 5250 additions and 0 deletions

5028
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

42
Cargo.toml Normal file
View file

@ -0,0 +1,42 @@
[workspace]
members = [".", "migration"]
[package]
name = "webfingerd"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
sea-orm-migration = "1"
dashmap = "6"
governor = "0.8"
askama = "0.12"
askama_axum = "0.4"
argon2 = "0.5"
config = "0.14"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
hickory-resolver = "0.25"
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }
glob-match = "0.2"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "request-id", "trace", "util"] }
axum-extra = { version = "0.10", features = ["cookie-signed"] }
rand = "0.8"
base64 = "0.22"
thiserror = "2"
urlencoding = "2"
async-trait = "0.1"
[dev-dependencies]
axum-test = "16"
tempfile = "3"

25
config.toml Normal file
View file

@ -0,0 +1,25 @@
[server]
listen = "0.0.0.0:8080"
base_url = "http://localhost:8080"
[database]
path = "webfingerd.db"
wal_mode = true
[cache]
reaper_interval_secs = 30
[rate_limit]
public_rpm = 60
api_rpm = 300
batch_rpm = 10
batch_max_links = 500
[challenge]
dns_txt_prefix = "_webfinger-challenge"
http_well_known_path = ".well-known/webfinger-verify"
challenge_ttl_secs = 3600
[ui]
enabled = false
session_secret = ""

6
migration/Cargo.toml Normal file
View file

@ -0,0 +1,6 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2024"
[dependencies]

14
migration/src/lib.rs Normal file
View file

@ -0,0 +1,14 @@
pub fn add(left: u64, right: u64) -> u64 {
left + right
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_works() {
let result = add(2, 2);
assert_eq!(result, 4);
}
}

71
src/config.rs Normal file
View file

@ -0,0 +1,71 @@
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct Settings {
pub server: ServerConfig,
pub database: DatabaseConfig,
pub cache: CacheConfig,
pub rate_limit: RateLimitConfig,
pub challenge: ChallengeConfig,
pub ui: UiConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
pub listen: String,
pub base_url: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DatabaseConfig {
pub path: String,
pub wal_mode: bool,
}
#[derive(Debug, Deserialize, Clone)]
pub struct CacheConfig {
pub reaper_interval_secs: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RateLimitConfig {
pub public_rpm: u32,
pub api_rpm: u32,
pub batch_rpm: u32,
pub batch_max_links: usize,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ChallengeConfig {
pub dns_txt_prefix: String,
pub http_well_known_path: String,
pub challenge_ttl_secs: u64,
}
#[derive(Debug, Deserialize, Clone)]
pub struct UiConfig {
pub enabled: bool,
pub session_secret: String,
}
impl Settings {
pub fn load() -> Result<Self, config::ConfigError> {
let settings = config::Config::builder()
.add_source(config::File::with_name("config").required(false))
.add_source(
config::Environment::with_prefix("WEBFINGERD")
.separator("__"),
)
.build()?;
let s: Self = settings.try_deserialize()?;
if s.ui.enabled && s.ui.session_secret.is_empty() {
return Err(config::ConfigError::Message(
"ui.session_secret is required when ui is enabled".into(),
));
}
Ok(s)
}
}

49
src/error.rs Normal file
View file

@ -0,0 +1,49 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error("unauthorized")]
Unauthorized,
#[error("forbidden: {0}")]
Forbidden(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("rate limited")]
RateLimited,
#[error("internal error: {0}")]
Internal(String),
#[error("database error: {0}")]
Database(#[from] sea_orm::DbErr),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
AppError::Internal(msg) => {
tracing::error!("internal error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
AppError::Database(err) => {
tracing::error!("database error: {err}");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
pub type AppResult<T> = Result<T, AppError>;

2
src/lib.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod config;
pub mod error;

13
src/main.rs Normal file
View file

@ -0,0 +1,13 @@
use tracing_subscriber::{fmt, EnvFilter};
use webfingerd::config::Settings;
#[tokio::main]
async fn main() {
fmt()
.with_env_filter(EnvFilter::from_default_env())
.json()
.init();
let settings = Settings::load().expect("failed to load configuration");
tracing::info!(listen = %settings.server.listen, "starting webfingerd");
}