init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
pub mod password;
|
||||
pub mod routes;
|
||||
pub mod session;
|
||||
pub mod tokens;
|
||||
@@ -0,0 +1,23 @@
|
||||
use argon2::password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
|
||||
use argon2::Argon2;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Hash a plaintext password with Argon2id + random salt (PHC string).
|
||||
pub fn hash_password(plain: &str) -> AppResult<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(plain.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("password hash failed: {e}")))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored PHC hash.
|
||||
/// Returns Ok(false) on mismatch, error only on malformed stored hash.
|
||||
pub fn verify_password(plain: &str, stored_hash: &str) -> AppResult<bool> {
|
||||
let parsed = PasswordHash::new(stored_hash)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("stored hash malformed: {e}")))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(plain.as_bytes(), &parsed)
|
||||
.is_ok())
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::Duration;
|
||||
use tower_sessions::Session;
|
||||
use validator::Validate;
|
||||
|
||||
use super::password::{hash_password, verify_password};
|
||||
use super::session::{set_user, AuthUser};
|
||||
use super::tokens::{self, TokenPurpose};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::{User, UserPublic, UserSettings};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/register", post(register))
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/verify", post(verify_email))
|
||||
.route("/resend-verification", post(resend_verification))
|
||||
.route("/request-password-reset", post(request_password_reset))
|
||||
.route("/reset-password", post(reset_password))
|
||||
.route("/me", axum::routing::get(me))
|
||||
}
|
||||
|
||||
// ── Request payloads ────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct RegisterReq {
|
||||
#[validate(email(message = "invalid email"))]
|
||||
email: String,
|
||||
#[validate(length(min = 10, max = 256, message = "password must be 10-256 chars"))]
|
||||
password: String,
|
||||
#[validate(length(max = 80, message = "display name too long"))]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginReq {
|
||||
email: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenReq {
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct EmailReq {
|
||||
#[validate(email)]
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct ResetReq {
|
||||
token: String,
|
||||
#[validate(length(min = 10, max = 256, message = "password must be 10-256 chars"))]
|
||||
new_password: String,
|
||||
}
|
||||
|
||||
// ── Response payloads ───────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct MeResp {
|
||||
user: UserPublic,
|
||||
settings: UserSettings,
|
||||
}
|
||||
|
||||
fn validate<T: Validate>(payload: &T) -> AppResult<()> {
|
||||
payload
|
||||
.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))
|
||||
}
|
||||
|
||||
/// Detect a Postgres unique-violation (SQLSTATE 23505).
|
||||
fn is_unique_violation(e: &sqlx::Error) -> bool {
|
||||
matches!(e, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505"))
|
||||
}
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────
|
||||
|
||||
async fn register(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(req): Json<RegisterReq>,
|
||||
) -> AppResult<(StatusCode, Json<UserPublic>)> {
|
||||
validate(&req)?;
|
||||
|
||||
let hash = hash_password(&req.password)?;
|
||||
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO users (email, password_hash, display_name)
|
||||
VALUES ($1, $2, $3) RETURNING *",
|
||||
)
|
||||
.bind(&req.email)
|
||||
.bind(&hash)
|
||||
.bind(display)
|
||||
.fetch_one(&state.pool)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if is_unique_violation(&e) {
|
||||
AppError::Conflict("email already registered".into())
|
||||
} else {
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
|
||||
// Default settings row.
|
||||
sqlx::query("INSERT INTO user_settings (user_id) VALUES ($1)")
|
||||
.bind(user.id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Email delivery failure must not abort signup; user can resend later.
|
||||
if let Err(e) = send_verification_email(&state, &user).await {
|
||||
tracing::warn!(user_id = %user.id, error = %e, "failed to send verification email");
|
||||
}
|
||||
|
||||
// Log the new user in immediately; features can gate on email_verified.
|
||||
set_user(&session, user.id).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(UserPublic::from(&user))))
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<AppState>,
|
||||
session: Session,
|
||||
Json(req): Json<LoginReq>,
|
||||
) -> AppResult<Json<UserPublic>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&req.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
|
||||
// Constant-ish: only reveal a single generic error for unknown user or bad password.
|
||||
let user = match user {
|
||||
Some(u) if verify_password(&req.password, &u.password_hash)? => u,
|
||||
_ => return Err(AppError::BadRequest("invalid email or password".into())),
|
||||
};
|
||||
|
||||
set_user(&session, user.id).await?;
|
||||
Ok(Json(UserPublic::from(&user)))
|
||||
}
|
||||
|
||||
async fn logout(session: Session) -> AppResult<StatusCode> {
|
||||
session
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session flush: {e}")))?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn verify_email(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<TokenReq>,
|
||||
) -> AppResult<StatusCode> {
|
||||
let user_id = tokens::consume(&state.pool, &req.token, TokenPurpose::VerifyEmail).await?;
|
||||
sqlx::query("UPDATE users SET email_verified = TRUE WHERE id = $1")
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn resend_verification(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<StatusCode> {
|
||||
if user.email_verified {
|
||||
return Err(AppError::BadRequest("email already verified".into()));
|
||||
}
|
||||
send_verification_email(&state, &user).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn request_password_reset(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<EmailReq>,
|
||||
) -> AppResult<StatusCode> {
|
||||
validate(&req)?;
|
||||
|
||||
// Always return 204 regardless of account existence (no enumeration).
|
||||
if let Some(user) = sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1")
|
||||
.bind(&req.email)
|
||||
.fetch_optional(&state.pool)
|
||||
.await?
|
||||
{
|
||||
let token =
|
||||
tokens::create(&state.pool, user.id, TokenPurpose::PasswordReset, Duration::hours(1))
|
||||
.await?;
|
||||
let link = format!("{}/reset?token={}", state.config.public_app_url, token);
|
||||
let _ = state
|
||||
.mailer
|
||||
.send(
|
||||
&user.email,
|
||||
"Reset your password",
|
||||
&format!("Reset your password:\n{link}\n\nThis link expires in 1 hour."),
|
||||
&format!(
|
||||
"<p>Reset your password:</p><p><a href=\"{link}\">{link}</a></p>\
|
||||
<p>This link expires in 1 hour.</p>"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn reset_password(
|
||||
State(state): State<AppState>,
|
||||
Json(req): Json<ResetReq>,
|
||||
) -> AppResult<StatusCode> {
|
||||
validate(&req)?;
|
||||
let user_id = tokens::consume(&state.pool, &req.token, TokenPurpose::PasswordReset).await?;
|
||||
let hash = hash_password(&req.new_password)?;
|
||||
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
|
||||
.bind(&hash)
|
||||
.bind(user_id)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn me(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
) -> AppResult<Json<MeResp>> {
|
||||
let settings = sqlx::query_as::<_, UserSettings>(
|
||||
"SELECT user_id, locale, currency, theme, notify_email
|
||||
FROM user_settings WHERE user_id = $1",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(MeResp {
|
||||
user: UserPublic::from(&user),
|
||||
settings,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
async fn send_verification_email(state: &AppState, user: &User) -> AppResult<()> {
|
||||
let token =
|
||||
tokens::create(&state.pool, user.id, TokenPurpose::VerifyEmail, Duration::hours(24)).await?;
|
||||
let link = format!("{}/verify?token={}", state.config.public_app_url, token);
|
||||
state
|
||||
.mailer
|
||||
.send(
|
||||
&user.email,
|
||||
"Verify your email",
|
||||
&format!("Welcome! Verify your email:\n{link}\n\nThis link expires in 24 hours."),
|
||||
&format!(
|
||||
"<p>Welcome! Verify your email:</p><p><a href=\"{link}\">{link}</a></p>\
|
||||
<p>This link expires in 24 hours.</p>"
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::request::Parts;
|
||||
use sqlx::PgPool;
|
||||
use tower_sessions::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::User;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Session key holding the authenticated user's id.
|
||||
pub const USER_ID_KEY: &str = "user_id";
|
||||
|
||||
/// Store the user id in the session (call after a successful login/verify).
|
||||
pub async fn set_user(session: &Session, user_id: Uuid) -> AppResult<()> {
|
||||
// Rotate the session id on privilege change to prevent fixation.
|
||||
session
|
||||
.cycle_id()
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session cycle: {e}")))?;
|
||||
session
|
||||
.insert(USER_ID_KEY, user_id)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session insert: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a user by id, if present.
|
||||
pub async fn load_user(pool: &PgPool, id: Uuid) -> AppResult<Option<User>> {
|
||||
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
/// Extractor that requires an authenticated user; rejects with 401 otherwise.
|
||||
pub struct AuthUser(pub User);
|
||||
|
||||
impl FromRequestParts<AppState> for AuthUser {
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let session = parts
|
||||
.extensions
|
||||
.get::<Session>()
|
||||
.cloned()
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let id = session
|
||||
.get::<Uuid>(USER_ID_KEY)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("session read: {e}")))?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let user = load_user(&state.pool, id)
|
||||
.await?
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
Ok(AuthUser(user))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Purpose of a one-time auth token. Maps to the Postgres `token_purpose` enum.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type)]
|
||||
#[sqlx(type_name = "token_purpose", rename_all = "snake_case")]
|
||||
pub enum TokenPurpose {
|
||||
VerifyEmail,
|
||||
PasswordReset,
|
||||
}
|
||||
|
||||
/// Generate a URL-safe random token (raw, only ever emailed to the user).
|
||||
fn generate_raw() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
/// SHA-256 of the raw token; this is what we persist.
|
||||
fn hash_token(raw: &str) -> Vec<u8> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(raw.as_bytes());
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
/// Create and store a token, returning the raw value to embed in an email link.
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
purpose: TokenPurpose,
|
||||
ttl: Duration,
|
||||
) -> AppResult<String> {
|
||||
let raw = generate_raw();
|
||||
let hash = hash_token(&raw);
|
||||
let expires_at = OffsetDateTime::now_utc() + ttl;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO auth_tokens (user_id, token_hash, purpose, expires_at)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(hash)
|
||||
.bind(purpose)
|
||||
.bind(expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(raw)
|
||||
}
|
||||
|
||||
/// Consume a token: validate hash+purpose, ensure unexpired and unconsumed,
|
||||
/// mark consumed, and return the owning user id. Atomic via single UPDATE.
|
||||
pub async fn consume(pool: &PgPool, raw: &str, purpose: TokenPurpose) -> AppResult<Uuid> {
|
||||
let hash = hash_token(raw);
|
||||
|
||||
let user_id: Option<Uuid> = sqlx::query_scalar(
|
||||
"UPDATE auth_tokens
|
||||
SET consumed_at = now()
|
||||
WHERE token_hash = $1
|
||||
AND purpose = $2
|
||||
AND consumed_at IS NULL
|
||||
AND expires_at > now()
|
||||
RETURNING user_id",
|
||||
)
|
||||
.bind(hash)
|
||||
.bind(purpose)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
user_id.ok_or(AppError::BadRequest(
|
||||
"invalid or expired token".to_string(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use std::env;
|
||||
|
||||
/// Runtime configuration, loaded from environment variables.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub session_secret: String,
|
||||
pub public_app_url: String,
|
||||
pub cors_origins: Vec<String>,
|
||||
pub smtp: SmtpConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SmtpConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub from: String,
|
||||
pub security: SmtpSecurity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SmtpSecurity {
|
||||
StartTls,
|
||||
Tls,
|
||||
None,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
// Load .env if present; ignore if missing (e.g. in containers).
|
||||
let _ = dotenvy::dotenv();
|
||||
|
||||
let session_secret = req("SESSION_SECRET")?;
|
||||
if session_secret.len() < 32 {
|
||||
anyhow::bail!("SESSION_SECRET must be at least 32 chars");
|
||||
}
|
||||
|
||||
let cors_origins = opt("CORS_ORIGINS", "http://localhost:5173")
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let security = match opt("SMTP_SECURITY", "starttls").to_lowercase().as_str() {
|
||||
"tls" => SmtpSecurity::Tls,
|
||||
"none" => SmtpSecurity::None,
|
||||
_ => SmtpSecurity::StartTls,
|
||||
};
|
||||
|
||||
Ok(Config {
|
||||
database_url: req("DATABASE_URL")?,
|
||||
host: opt("BACKEND_HOST", "0.0.0.0"),
|
||||
port: opt("BACKEND_PORT", "8080").parse()?,
|
||||
session_secret,
|
||||
public_app_url: opt("PUBLIC_APP_URL", "http://localhost:5173"),
|
||||
cors_origins,
|
||||
smtp: SmtpConfig {
|
||||
host: opt("SMTP_HOST", "localhost"),
|
||||
port: opt("SMTP_PORT", "587").parse()?,
|
||||
username: opt("SMTP_USERNAME", ""),
|
||||
password: opt("SMTP_PASSWORD", ""),
|
||||
from: opt("SMTP_FROM", "Shopping List <no-reply@localhost>"),
|
||||
security,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn req(key: &str) -> anyhow::Result<String> {
|
||||
env::var(key).map_err(|_| anyhow::anyhow!("missing required env var: {key}"))
|
||||
}
|
||||
|
||||
fn opt(key: &str, default: &str) -> String {
|
||||
env::var(key).unwrap_or_else(|_| default.to_string())
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Build the Postgres pool and run migrations.
|
||||
pub async fn connect(database_url: &str) -> anyhow::Result<PgPool> {
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(10)
|
||||
.acquire_timeout(Duration::from_secs(5))
|
||||
.connect(database_url)
|
||||
.await?;
|
||||
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
/// Application-wide error type. Maps to an HTTP status + JSON body `{ "error": ... }`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
#[error("{0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("authentication required")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// Anything unexpected. Logged in full; client sees a generic message.
|
||||
#[error("internal error")]
|
||||
Internal(#[source] anyhow::Error),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
fn status(&self) -> StatusCode {
|
||||
match self {
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
AppError::Forbidden => StatusCode::FORBIDDEN,
|
||||
AppError::NotFound => StatusCode::NOT_FOUND,
|
||||
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
if let AppError::Internal(ref e) = self {
|
||||
tracing::error!(error = ?e, "internal error");
|
||||
}
|
||||
let status = self.status();
|
||||
let body = Json(json!({ "error": self.to_string() }));
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub type AppResult<T> = Result<T, AppError>;
|
||||
|
||||
// Convert common error sources into a 500 by default.
|
||||
impl From<sqlx::Error> for AppError {
|
||||
fn from(e: sqlx::Error) -> Self {
|
||||
AppError::Internal(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
AppError::Internal(e)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use lettre::message::{header::ContentType, Mailbox, MultiPart, SinglePart};
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
|
||||
use crate::config::{SmtpConfig, SmtpSecurity};
|
||||
use crate::error::{AppError, AppResult};
|
||||
|
||||
/// Wraps an async SMTP transport plus the configured `From` address.
|
||||
#[derive(Clone)]
|
||||
pub struct Mailer {
|
||||
transport: AsyncSmtpTransport<Tokio1Executor>,
|
||||
from: Mailbox,
|
||||
}
|
||||
|
||||
impl Mailer {
|
||||
pub fn from_config(cfg: &SmtpConfig) -> anyhow::Result<Self> {
|
||||
let mut builder = match cfg.security {
|
||||
SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)?,
|
||||
SmtpSecurity::StartTls => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)?
|
||||
}
|
||||
SmtpSecurity::None => {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&cfg.host)
|
||||
}
|
||||
}
|
||||
.port(cfg.port);
|
||||
|
||||
if !cfg.username.is_empty() {
|
||||
builder = builder.credentials(Credentials::new(
|
||||
cfg.username.clone(),
|
||||
cfg.password.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
let from: Mailbox = cfg
|
||||
.from
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("invalid SMTP_FROM: {e}"))?;
|
||||
|
||||
Ok(Mailer {
|
||||
transport: builder.build(),
|
||||
from,
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a multipart (plain + HTML) email.
|
||||
pub async fn send(&self, to: &str, subject: &str, text: &str, html: &str) -> AppResult<()> {
|
||||
let to: Mailbox = to
|
||||
.parse()
|
||||
.map_err(|_| AppError::BadRequest("invalid recipient address".into()))?;
|
||||
|
||||
let msg = Message::builder()
|
||||
.from(self.from.clone())
|
||||
.to(to)
|
||||
.subject(subject)
|
||||
.multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(text.to_string()),
|
||||
)
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(html.to_string()),
|
||||
),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("build email: {e}")))?;
|
||||
|
||||
self.transport
|
||||
.send(msg)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(anyhow::anyhow!("send email: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod error;
|
||||
mod mail;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod state;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::http::{header, HeaderValue, Method};
|
||||
use axum::Router;
|
||||
use time::Duration;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tower_sessions::{Expiry, SessionManagerLayer};
|
||||
use tower_sessions_sqlx_store::PostgresStore;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
|
||||
|
||||
use config::Config;
|
||||
use mail::Mailer;
|
||||
use state::AppState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,sqlx=warn".into()))
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let config = Config::from_env()?;
|
||||
tracing::info!(port = config.port, "starting shoplist-backend");
|
||||
|
||||
let pool = db::connect(&config.database_url).await?;
|
||||
let mailer = Mailer::from_config(&config.smtp)?;
|
||||
|
||||
// Session store (separate table set, managed by the store's own migrations).
|
||||
let session_store = PostgresStore::new(pool.clone());
|
||||
session_store.migrate().await?;
|
||||
|
||||
let session_layer = SessionManagerLayer::new(session_store)
|
||||
.with_secure(false) // set true behind HTTPS in production
|
||||
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||
.with_expiry(Expiry::OnInactivity(Duration::days(30)));
|
||||
|
||||
let cors = build_cors(&config.cors_origins)?;
|
||||
|
||||
let state = AppState {
|
||||
pool,
|
||||
config: Arc::new(config.clone()),
|
||||
mailer,
|
||||
};
|
||||
|
||||
let api = Router::new()
|
||||
.merge(routes::router())
|
||||
.nest("/auth", auth::routes::router());
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/api", api)
|
||||
.layer(cors)
|
||||
.layer(session_layer)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("{}:{}", config.host, config.port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
tracing::info!("listening on {addr}");
|
||||
axum::serve(listener, app).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_cors(origins: &[String]) -> anyhow::Result<CorsLayer> {
|
||||
let parsed: Vec<HeaderValue> = origins
|
||||
.iter()
|
||||
.map(|o| o.parse::<HeaderValue>())
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|e| anyhow::anyhow!("invalid CORS origin: {e}"))?;
|
||||
|
||||
Ok(CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list(parsed))
|
||||
.allow_credentials(true)
|
||||
.allow_methods([Method::GET, Method::POST, Method::PATCH, Method::DELETE])
|
||||
.allow_headers([header::CONTENT_TYPE]))
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use serde::Serialize;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Full user row. `password_hash` never leaves the backend.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
pub struct User {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub password_hash: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email_verified: bool,
|
||||
pub created_at: OffsetDateTime,
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Safe representation sent to clients.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserPublic {
|
||||
pub id: Uuid,
|
||||
pub email: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email_verified: bool,
|
||||
}
|
||||
|
||||
impl From<&User> for UserPublic {
|
||||
fn from(u: &User) -> Self {
|
||||
UserPublic {
|
||||
id: u.id,
|
||||
email: u.email.clone(),
|
||||
display_name: u.display_name.clone(),
|
||||
email_verified: u.email_verified,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct UserSettings {
|
||||
#[serde(skip)]
|
||||
pub user_id: Uuid,
|
||||
pub locale: String,
|
||||
pub currency: String,
|
||||
pub theme: String,
|
||||
pub notify_email: bool,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
use axum::extract::State;
|
||||
use axum::routing::{get, patch};
|
||||
use axum::{Json, Router};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use validator::Validate;
|
||||
|
||||
use crate::auth::session::AuthUser;
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::models::UserSettings;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/settings", patch(update_settings))
|
||||
.route("/profile", patch(update_profile))
|
||||
}
|
||||
|
||||
async fn health() -> Json<Value> {
|
||||
Json(json!({ "status": "ok" }))
|
||||
}
|
||||
|
||||
const ALLOWED_LOCALES: &[&str] = &["de", "en"];
|
||||
const ALLOWED_THEMES: &[&str] = &["breakcore", "grunge", "minimal"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SettingsReq {
|
||||
locale: Option<String>,
|
||||
#[serde(default)]
|
||||
currency: Option<String>,
|
||||
theme: Option<String>,
|
||||
notify_email: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_settings(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<SettingsReq>,
|
||||
) -> AppResult<Json<UserSettings>> {
|
||||
if let Some(loc) = &req.locale {
|
||||
if !ALLOWED_LOCALES.contains(&loc.as_str()) {
|
||||
return Err(AppError::Validation(format!("unsupported locale: {loc}")));
|
||||
}
|
||||
}
|
||||
if let Some(theme) = &req.theme {
|
||||
if !ALLOWED_THEMES.contains(&theme.as_str()) {
|
||||
return Err(AppError::Validation(format!("unsupported theme: {theme}")));
|
||||
}
|
||||
}
|
||||
if let Some(cur) = &req.currency {
|
||||
if cur.len() != 3 {
|
||||
return Err(AppError::Validation("currency must be a 3-letter code".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let settings = sqlx::query_as::<_, UserSettings>(
|
||||
"UPDATE user_settings SET
|
||||
locale = COALESCE($2, locale),
|
||||
currency = COALESCE($3, currency),
|
||||
theme = COALESCE($4, theme),
|
||||
notify_email = COALESCE($5, notify_email)
|
||||
WHERE user_id = $1
|
||||
RETURNING user_id, locale, currency, theme, notify_email",
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(req.locale)
|
||||
.bind(req.currency.map(|c| c.to_uppercase()))
|
||||
.bind(req.theme)
|
||||
.bind(req.notify_email)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
struct ProfileReq {
|
||||
#[validate(length(max = 80, message = "display name too long"))]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
State(state): State<AppState>,
|
||||
AuthUser(user): AuthUser,
|
||||
Json(req): Json<ProfileReq>,
|
||||
) -> AppResult<Json<Value>> {
|
||||
req.validate()
|
||||
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let display = req.display_name.as_deref().map(str::trim).filter(|s| !s.is_empty());
|
||||
sqlx::query("UPDATE users SET display_name = $2 WHERE id = $1")
|
||||
.bind(user.id)
|
||||
.bind(display)
|
||||
.execute(&state.pool)
|
||||
.await?;
|
||||
|
||||
Ok(Json(json!({ "display_name": display })))
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::mail::Mailer;
|
||||
|
||||
/// Shared application state injected into every handler.
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub pool: PgPool,
|
||||
pub config: Arc<Config>,
|
||||
pub mailer: Mailer,
|
||||
}
|
||||
Reference in New Issue
Block a user