This commit is contained in:
2026-06-17 00:21:00 +02:00
commit 408e48c568
41 changed files with 6617 additions and 0 deletions
+2907
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "shoplist-backend"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
sqlx = { version = "0.8", default-features = false, features = [
"runtime-tokio", "tls-rustls", "postgres", "uuid", "time", "macros",
] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
argon2 = "0.5"
rand = "0.8"
sha2 = "0.10"
hex = "0.4"
lettre = { version = "0.11", default-features = false, features = [
"tokio1-rustls-tls", "smtp-transport", "builder", "hostname",
] }
validator = { version = "0.19", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
time = { version = "0.3", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenvy = "0.15"
+58
View File
@@ -0,0 +1,58 @@
-- Phase 1: auth foundation
-- Requires Postgres. citext for case-insensitive email; pgcrypto for gen_random_uuid.
CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email CITEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One row per user; created on registration.
CREATE TABLE user_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
locale TEXT NOT NULL DEFAULT 'de', -- 'de' | 'en'
currency TEXT NOT NULL DEFAULT 'EUR',
theme TEXT NOT NULL DEFAULT 'breakcore',
notify_email BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email verification + password reset tokens.
-- Only the SHA-256 hash of the token is stored; raw token lives in the emailed link.
CREATE TYPE token_purpose AS ENUM ('verify_email', 'password_reset');
CREATE TABLE auth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash BYTEA NOT NULL UNIQUE,
purpose token_purpose NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_auth_tokens_user ON auth_tokens(user_id);
CREATE INDEX idx_auth_tokens_expires ON auth_tokens(expires_at);
-- Touch updated_at on row changes.
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_users_updated
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_user_settings_updated
BEFORE UPDATE ON user_settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+4
View File
@@ -0,0 +1,4 @@
pub mod password;
pub mod routes;
pub mod session;
pub mod tokens;
+23
View File
@@ -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())
}
+265
View File
@@ -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(())
}
+65
View File
@@ -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))
}
}
+78
View File
@@ -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(),
))
}
+79
View File
@@ -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())
}
+15
View File
@@ -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)
}
+70
View File
@@ -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)
}
}
+77
View File
@@ -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(())
}
}
+86
View File
@@ -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]))
}
+45
View File
@@ -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,
}
+99
View File
@@ -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 })))
}
+14
View File
@@ -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,
}