feat: JWT auth, /api prefix, SPA serving, OpenAPI, lean main.rs

- auth: register/login/refresh/logout w/ JWT+Argon2, protected mutations
- domain: User, RefreshSession, auth ports, Unauthorized/Forbidden errors
- presentation: context/state/factory/errors/extractors/openapi modules
- routes behind /api, SPA served from root w/ fallback
- OpenAPI Scalar at /docs
- frontend ssr:false, single-binary Dockerfile
This commit is contained in:
2026-07-11 21:28:52 +02:00
parent 13031347cc
commit 7bd27d9b9c
50 changed files with 1604 additions and 213 deletions

View File

@@ -8,4 +8,5 @@ sqlx = { workspace = true }
uuid = { workspace = true }
async-trait = { workspace = true }
serde_json = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
domain = { workspace = true }

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT UNIQUE NOT NULL,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS refresh_sessions (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at);

View File

@@ -1,5 +1,7 @@
mod refresh_sessions;
pub mod repository;
mod row;
mod search;
mod users;
pub use repository::{SqliteRepositoryFactory, SqliteSongRepository};

View File

@@ -0,0 +1,96 @@
use async_trait::async_trait;
use chrono::DateTime;
use domain::errors::DomainError;
use domain::models::RefreshSession;
use domain::value_objects::UserId;
use crate::repository::SqliteSongRepository;
#[derive(sqlx::FromRow)]
struct RefreshSessionRow {
id: String,
user_id: String,
token: String,
expires_at: String,
created_at: String,
}
fn row_to_session(row: RefreshSessionRow) -> Result<RefreshSession, DomainError> {
let id = uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let user_id = uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let expires_at = DateTime::parse_from_rfc3339(&row.expires_at)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_utc();
let created_at = DateTime::parse_from_rfc3339(&row.created_at)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.to_utc();
Ok(RefreshSession {
id,
user_id: UserId::from_uuid(user_id),
token: row.token,
expires_at,
created_at,
})
}
#[async_trait]
impl domain::ports::RefreshSessionRepository for SqliteSongRepository {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(session.id.to_string())
.bind(session.user_id.value().to_string())
.bind(&session.token)
.bind(session.expires_at.to_rfc3339())
.bind(session.created_at.to_rfc3339())
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
let row = sqlx::query_as::<_, RefreshSessionRow>(
"SELECT id, user_id, token, expires_at, created_at FROM refresh_sessions WHERE token = ?",
)
.bind(token)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_session).transpose()
}
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE token = ?")
.bind(token)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_sessions WHERE user_id = ?")
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let now = chrono::Utc::now().to_rfc3339();
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < ?")
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(result.rows_affected())
}
}

View File

@@ -0,0 +1,81 @@
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::models::User;
use domain::value_objects::{Email, PasswordHash, UserId, Username};
use crate::repository::SqliteSongRepository;
#[derive(sqlx::FromRow)]
struct UserRow {
id: String,
email: String,
username: String,
password_hash: String,
}
fn row_to_user(row: UserRow) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(User::from_persistence(
UserId::from_uuid(id),
Email::new(&row.email)?,
Username::new(&row.username)?,
PasswordHash::new(row.password_hash)?,
))
}
#[async_trait]
impl domain::ports::UserRepository for SqliteSongRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE email = ?",
)
.bind(email.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE username = ?",
)
.bind(username.value())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>(
"SELECT id, email, username, password_hash FROM users WHERE id = ?",
)
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
row.map(row_to_user).transpose()
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
let now = chrono::Utc::now().to_rfc3339();
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(user.id().value().to_string())
.bind(user.email().value())
.bind(user.username().value())
.bind(user.password_hash().value())
.bind(&now)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}
}