Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
6eb1a8e
fix: don't run migrations in debug mode
Fleeym Jul 14, 2026
1181bb5
feat(users): add migrations for developer_login_info
Fleeym Jul 14, 2026
a0f885a
feat: add URL signing and checking
Fleeym Jul 14, 2026
73cdbe8
fix: fix urlsign doc examples
Fleeym Jul 14, 2026
d8ff083
fix: split up query param tuples in urlsign
Fleeym Jul 14, 2026
b052607
test: add tests for urlsign
Fleeym Jul 14, 2026
f7fddbf
feat: temp middleware commit
Fleeym Jul 16, 2026
2bb8e7b
Merge branch 'main' into full-accounts
Fleeym Aug 8, 2026
c31cbe4
feat: finish SignedUrl middleware
Fleeym Aug 8, 2026
e01329f
feat: add email blocklist and mailer
Fleeym Aug 10, 2026
08f559a
feat: use types to validate email blacklist
Fleeym Aug 10, 2026
237c80a
fix: switch to parking_lot for sync
Fleeym Aug 10, 2026
1e70746
feat: add migrations for email setup / change
Fleeym Aug 10, 2026
38f2972
feat(email): finish /v1/me/email/setup
Fleeym Aug 17, 2026
fff435d
feat: add /v1/email/setup/verify
Fleeym Sep 7, 2026
5c246ba
fix: make blocklist log warn for no path
Fleeym Sep 8, 2026
325c6ac
feat: add /v1/login
Fleeym Sep 8, 2026
697db6a
fix: fix one revert migration
Fleeym Sep 21, 2026
d37c4ce
Merge branch 'main' into full-accounts
Fleeym Sep 21, 2026
6dd7e17
fix: delete urisign
Fleeym Sep 21, 2026
761958e
chore: cleanup dependencies
Fleeym Sep 21, 2026
29cb0da
fix: more cleanup!!
Fleeym Sep 21, 2026
75f0c40
Merge branch 'main' into full-accounts
Fleeym Oct 6, 2026
c358799
fix: I may have commited conflict markers
Fleeym Oct 6, 2026
2d35532
feat: add POST /v1/email/change
Fleeym Oct 6, 2026
1393cef
fix: be consistent with "email"
Fleeym Oct 7, 2026
6358a2f
fix: this has no reason to take &String
Fleeym Oct 7, 2026
fea7963
feat: add doc & tracing span to email change setup
Fleeym Oct 7, 2026
c1bd0c8
fix: let's be consistent with naming
Fleeym Oct 7, 2026
519aa44
fix: actually validate JSON body
Fleeym Oct 7, 2026
43ad8b0
feat: add /v1/email/change/verify
Fleeym Oct 7, 2026
d8e4f2b
fix: prevent possible SQL tomfoolery
Fleeym Oct 7, 2026
29730a5
fix: ensure lowercase emails
Fleeym Oct 7, 2026
3b77576
fix: actually check change request expiration
Fleeym Oct 7, 2026
4bb15f3
feat: warn if not using SMTP security
Fleeym Oct 7, 2026
6f84497
fix: cleanup setup / change requests properly
Fleeym Oct 7, 2026
8484681
fix: set status codes for some more errors
Fleeym Oct 7, 2026
a1d75a6
fix: more misc fixes
Fleeym Oct 7, 2026
db4a8e0
feat: properly parse email domains
Fleeym Oct 7, 2026
cfb589a
fix: register some routes
Fleeym Oct 7, 2026
14a895e
test: add more blocklist tests
Fleeym Oct 7, 2026
f6bc7f0
feat: send success email to old email on change
Fleeym Oct 7, 2026
076696f
fix: wikipedia.
Fleeym Oct 7, 2026
8e957a3
fix: dedupe password verify logic
Fleeym Oct 7, 2026
b226b45
feat: move templates to separate directory
Fleeym Oct 10, 2026
4e9528d
fix: string cleanup
Fleeym Oct 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
363 changes: 292 additions & 71 deletions Cargo.lock

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ edition = "2024"
serve-static = ["dep:actix-files"]

[dependencies]
argon2 = "0.6.0"
actix-cors = "0.7"
actix-files = { version = "0.7", optional = true }
actix-multipart = "0.8"
Expand All @@ -16,10 +17,27 @@ anyhow = "1.0"
bytes = "1.11"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
constant_time_eq = "0.6.0"
dotenvy = "0.15"
futures = "0.3"
image = "0.25"
html2text = "0.17.1"
lettre = { version = "0.11.23", default-features = false, features = [
"builder",
"hostname",
"smtp-transport",
"pool",
"tokio1-rustls-tls",
"rustls-platform-verifier",
"tracing",
] }
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"webp",
"rayon",
] }
moka = { version = "0.12.13", features = ["future"] }
parking_lot = "0.12.5"
regex = "1.10"
reqwest = { version = "0.13", features = [
"gzip",
Expand Down Expand Up @@ -61,9 +79,11 @@ rust-s3 = { git = "https://github.com/w4nderlust/rust-s3", rev = "2936eb7", defa
"tokio-rustls-tls",
"fail-on-err",
] }
maud = "0.27.0"
phc = "0.6.1"
idna = "1.1.0"

[profile.release]
opt-level = 's'
lto = true

[target.'cfg(target_env = "musl")'.dependencies]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add down migration script here

DROP TABLE IF EXISTS developer_login_info;
11 changes: 11 additions & 0 deletions migrations/20260714181725_create_developer_login_info_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- Add up migration script here

CREATE TABLE IF NOT EXISTS developer_login_info (
developer_id INTEGER NOT NULL PRIMARY KEY,
email TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
password TEXT NOT NULL,

FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE
);

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add down migration script here

DROP INDEX developer_login_info_email_lower_idx;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Add up migration script here

CREATE UNIQUE INDEX developer_login_info_email_lower_idx
ON developer_login_info(LOWER(email));
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add down migration script here

DROP TABLE email_setup_requests;
12 changes: 12 additions & 0 deletions migrations/20260810163645_add_email_setup_requests_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-- Add up migration script here

CREATE TABLE email_setup_requests (
developer_id INTEGER NOT NULL PRIMARY KEY,
token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
password TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,

FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Add down migration script here

DROP TABLE email_change_history;
DROP TABLE email_change_requests;
21 changes: 21 additions & 0 deletions migrations/20260810163652_add_email_change_requests_table.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- Add up migration script here

CREATE TABLE email_change_requests (
developer_id INTEGER NOT NULL PRIMARY KEY,
token UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
new_email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,

FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE
);

CREATE TABLE email_change_history (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
developer_id INTEGER NOT NULL,
old_email TEXT NOT NULL,
new_email TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL,

FOREIGN KEY (developer_id) REFERENCES developers(id) ON DELETE CASCADE
);
1 change: 1 addition & 0 deletions src/auth/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod github;
pub mod password;

#[derive(thiserror::Error, Debug)]
pub enum AuthenticationError {
Expand Down
71 changes: 71 additions & 0 deletions src/auth/password.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
use std::sync::OnceLock;

use argon2::{Algorithm, Argon2, Params, PasswordHasher, PasswordVerifier, Version};
use phc::PasswordHash;


#[derive(Debug, thiserror::Error)]
pub enum PasswordError {
#[error("stored password hash is malformed")]
MalformedHash(String),
#[error("password verification failed")]
Verify(String),
#[error("password task panicked")]
Join(#[from] tokio::task::JoinError)
}

pub async fn verify(hash: String, password: String, pepper: Option<String>) -> Result<bool, PasswordError> {
tokio::task::spawn_blocking(move || verify_blocking(&hash, &password, pepper.as_ref()))
.await
.inspect_err(|e| tracing::error!("tokio join error: {e}"))?
}

/// Used to prevent timing attacks: still hash-check something,
/// but discard the result after
pub async fn verify_or_dummy(hash: Option<String>, password: String, pepper: Option<String>) -> Result<bool, PasswordError> {
tokio::task::spawn_blocking(move || {
let exists = hash.is_some();
let hash = hash.as_deref().unwrap_or_else(|| dummy_hash(pepper.as_ref()));

Ok(verify_blocking(hash, &password, pepper.as_ref())? && exists)
})
.await
.inspect_err(|e| tracing::error!("tokio join error: {e}"))?
}

pub fn argon2(pepper: Option<&String>) -> Argon2<'_> {
match pepper {
Some(pepper) => Argon2::new_with_secret(
pepper.as_bytes(),
Algorithm::default(),
Version::default(),
Params::default(),
)
.unwrap(), // This only fails if the secret is too long, it'll be fine!
None => Argon2::default(),
}
}

fn verify_blocking(hash: &str, password: &str, pepper: Option<&String>) -> Result<bool, PasswordError> {
let parsed_hash = PasswordHash::new(hash)
.inspect_err(|e| tracing::error!("failed to parse password hash - pretty bad: {e}"))
.map_err(|_| PasswordError::MalformedHash(hash.into()))?;

match argon2(pepper).verify_password(password.as_bytes(), &parsed_hash) {
Ok(()) => Ok(true),
Err(argon2::password_hash::Error::PasswordInvalid) => Ok(false),
Err(e) => Err(PasswordError::Verify(e.to_string()))
}
}

fn dummy_hash(pepper: Option<&String>) -> &'static str {
static HASH: OnceLock<String> = OnceLock::new();

HASH.get_or_init(|| {
argon2(pepper)
.hash_password(b"soggymod")
.expect("hashing a static str can't fail")
.to_string()
})
}

59 changes: 59 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use std::{
path::PathBuf,
str::FromStr,
sync::{Arc, OnceLock},
time::Duration,
};
Expand All @@ -9,6 +11,9 @@ use tokio::sync::mpsc::Sender;
use crate::{
auth::github::GithubClient,
dns::ValidateDnsResolver,
email::{
SmtpConfig, SmtpSecurity, blocklist::Blocklist, lettre::LettreBackend, mailer::Mailer,
},
endpoints::mods::IndexQueryParams,
s3_worker::S3WorkerTask,
storage::{LocalBackend, PrivateDisk, PublicDisk, S3Backend, S3Configuration},
Expand All @@ -34,10 +39,13 @@ pub struct AppData {
max_download_mb: u32,
port: u16,
debug: bool,
password_hash_pepper: Option<String>,

mods_cache: Cache<IndexQueryParams, ApiResponse<PaginatedData<Mod>>>,
http_client: reqwest::Client,
check_dns_http_client: reqwest::Client,
mailer: Option<Mailer>,
email_blocklist: Option<Blocklist>,

s3_sender: OnceLock<Sender<S3WorkerTask>>,
}
Expand All @@ -60,6 +68,14 @@ pub async fn build_config() -> anyhow::Result<AppData> {
let github_client = dotenvy::var("GITHUB_CLIENT_ID").unwrap_or("".to_string());
let github_secret = dotenvy::var("GITHUB_CLIENT_SECRET").unwrap_or("".to_string());
let webhook_url = dotenvy::var("DISCORD_WEBHOOK_URL").unwrap_or("".to_string());
let password_hash_pepper = dotenvy::var("PASSWORD_HASH_PEPPER").ok();

if password_hash_pepper.is_none() {
tracing::warn!(
"IMPORTANT: no password hash pepper found, password storage will be LESS secure. Check https://en.wikipedia.org/wiki/Pepper_(cryptography) for more information."
);
}

let index_admin_webhook_url =
dotenvy::var("INDEX_ADMIN_DISCORD_WEBHOOK_URL").unwrap_or("".to_string());
let disable_downloads =
Expand Down Expand Up @@ -89,6 +105,26 @@ pub async fn build_config() -> anyhow::Result<AppData> {
.timeout(Duration::from_secs(30))
.build()?;

let smtp_config = SmtpConfig::from_env()?;

if !debug
&& smtp_config
.as_ref()
.is_some_and(|x| x.security == SmtpSecurity::None)
{
tracing::warn!(
"You are using no SMTP security for sending emails with APP_DEBUG=0. You have been warned."
);
}

let blocklist = if smtp_config.is_some() {
let env_var = dotenvy::var("EMAIL_BLOCKLIST_PATH").unwrap_or("./blocklist.conf".into());
let path = PathBuf::from_str(&env_var).ok();

path.map(Blocklist::load)
} else {
None
};
let http_client = reqwest::Client::builder()
.pool_max_idle_per_host(4)
.connect_timeout(Duration::from_secs(10))
Expand Down Expand Up @@ -116,9 +152,20 @@ pub async fn build_config() -> anyhow::Result<AppData> {
max_download_mb,
port,
debug,
password_hash_pepper,
mods_cache,
http_client,
check_dns_http_client,
mailer: smtp_config
.and_then(|config| match LettreBackend::new(&config) {
Ok(backend) => Some(Arc::new(backend)),
Err(e) => {
tracing::warn!("failed to create SMTP backend: {e}");
None
}
})
.map(|backend| Mailer::new(backend)),
email_blocklist: blocklist,
s3_sender: OnceLock::new(),
})
}
Expand Down Expand Up @@ -164,6 +211,10 @@ impl AppData {
self.debug
}

pub fn password_hash_pepper(&self) -> Option<&String> {
self.password_hash_pepper.as_ref()
}

pub fn static_storage(&self) -> &PublicDisk {
&self.static_storage
}
Expand All @@ -188,6 +239,14 @@ impl AppData {
&self.http_client
}

pub fn mailer(&self) -> Option<&Mailer> {
self.mailer.as_ref()
}

pub fn email_blocklist(&self) -> Option<&Blocklist> {
self.email_blocklist.as_ref()
}

/// Client that validates passed host, denies all private resolved IP addresses.
/// Useful for preventing Server Side Request Forgery.
///
Expand Down
Loading