refactor: reorganize Rust codebase into modular handlers and database layers

- Split monolithic src/db.rs (1122 lines) into domain modules: projects, tags, settings
- Extract API handlers from main.rs into separate handler modules by domain
- Add proxy module for ISR/SSR coordination with Bun process
- Introduce AppState for shared application context
- Add utility functions for asset serving and request classification
- Remove obsolete middleware/auth.rs in favor of session checks in handlers
This commit is contained in:
2026-01-07 13:55:23 -06:00
parent 4663b00942
commit cf599d09d6
45 changed files with 3525 additions and 3326 deletions
+102
View File
@@ -0,0 +1,102 @@
use axum::{
Json,
extract::{Request, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use crate::{assets, proxy, state::AppState, utils};
/// Serve PGP public key
pub async fn serve_pgp_key() -> impl IntoResponse {
if let Some(content) = assets::get_static_file("publickey.asc") {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/pgp-keys"),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
axum::http::HeaderValue::from_static("attachment; filename=\"publickey.asc\""),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("public, max-age=86400"),
);
(StatusCode::OK, headers, content).into_response()
} else {
(StatusCode::NOT_FOUND, "PGP key not found").into_response()
}
}
/// Redirect /keys to /pgp
pub async fn redirect_to_pgp() -> impl IntoResponse {
axum::response::Redirect::permanent("/pgp")
}
/// Handle /pgp route - serve HTML page or raw key based on User-Agent
pub async fn handle_pgp_route(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
req: Request,
) -> Response {
if utils::prefers_raw_content(&headers) {
// Serve raw .asc file for CLI tools
serve_pgp_key().await.into_response()
} else {
// Proxy to Bun for HTML page
proxy::isr_handler(State(state), req).await
}
}
/// Proxy icon requests to SvelteKit
pub async fn proxy_icons_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
axum::extract::Path(path): axum::extract::Path<String>,
req: Request,
) -> impl IntoResponse {
let full_path = format!("/api/icons/{}", path);
let query = req.uri().query().unwrap_or("");
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
{
if query.is_empty() {
format!("http://localhost{}", full_path)
} else {
format!("http://localhost{}?{}", full_path, query)
}
} else if query.is_empty() {
format!("{}{}", state.downstream_url, full_path)
} else {
format!("{}{}?{}", state.downstream_url, full_path, query)
};
// Build trusted headers with session info
let mut forward_headers = HeaderMap::new();
if let Some(cookie) = jar.get("admin_session") {
if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) {
if let Some(session) = state.session_manager.validate_session(session_id) {
if let Ok(username_value) = axum::http::HeaderValue::from_str(&session.username) {
forward_headers.insert("x-session-user", username_value);
}
}
}
}
match proxy::proxy_to_bun(&bun_url, state, forward_headers).await {
Ok((status, headers, body)) => (status, headers, body).into_response(),
Err(err) => {
tracing::error!(error = %err, path = %full_path, "Failed to proxy icon request");
(
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({
"error": "Failed to fetch icon data"
})),
)
.into_response()
}
}
}
+166
View File
@@ -0,0 +1,166 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{auth, state::AppState};
#[derive(serde::Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(serde::Serialize)]
pub struct LoginResponse {
pub success: bool,
pub username: String,
}
#[derive(serde::Serialize)]
pub struct SessionResponse {
pub authenticated: bool,
pub username: String,
}
/// Login handler - creates a new session
pub async fn api_login_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<LoginRequest>,
) -> Result<(axum_extra::extract::CookieJar, Json<LoginResponse>), impl IntoResponse> {
let user = match auth::get_admin_user(&state.pool, &payload.username).await {
Ok(Some(user)) => user,
Ok(None) => {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid credentials",
"message": "Username or password incorrect"
})),
));
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch admin user");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to authenticate"
})),
));
}
};
let password_valid = match auth::verify_password(&payload.password, &user.password_hash) {
Ok(valid) => valid,
Err(err) => {
tracing::error!(error = %err, "Failed to verify password");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to authenticate"
})),
));
}
};
if !password_valid {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid credentials",
"message": "Username or password incorrect"
})),
));
}
let session = match state
.session_manager
.create_session(user.id, user.username.clone())
.await
{
Ok(session) => session,
Err(err) => {
tracing::error!(error = %err, "Failed to create session");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create session"
})),
));
}
};
let cookie =
axum_extra::extract::cookie::Cookie::build(("admin_session", session.id.to_string()))
.path("/")
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Lax)
.max_age(time::Duration::days(7))
.build();
let jar = jar.add(cookie);
tracing::info!(username = %user.username, "User logged in");
Ok((
jar,
Json(LoginResponse {
success: true,
username: user.username,
}),
))
}
/// Logout handler - deletes the session
pub async fn api_logout_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> (axum_extra::extract::CookieJar, StatusCode) {
if let Some(cookie) = jar.get("admin_session") {
if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) {
if let Err(e) = state.session_manager.delete_session(session_id).await {
tracing::error!(error = %e, "Failed to delete session during logout");
}
}
}
let cookie = axum_extra::extract::cookie::Cookie::build(("admin_session", ""))
.path("/")
.max_age(time::Duration::ZERO)
.build();
(jar.add(cookie), StatusCode::OK)
}
/// Session check handler - returns current session status
pub async fn api_session_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
let session_cookie = jar.get("admin_session");
let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok());
let session = session_id.and_then(|id| state.session_manager.validate_session(id));
match session {
Some(session) => (
StatusCode::OK,
Json(SessionResponse {
authenticated: true,
username: session.username,
}),
)
.into_response(),
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Unauthorized",
"message": "No valid session"
})),
)
.into_response(),
}
}
+15
View File
@@ -0,0 +1,15 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::state::AppState;
/// Health check endpoint - returns 200 if both DB and Bun are healthy
pub async fn health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let healthy = state.health_checker.check().await;
if healthy {
(StatusCode::OK, "OK")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "Unhealthy")
}
}
+35
View File
@@ -0,0 +1,35 @@
pub mod assets;
pub mod auth;
pub mod health;
pub mod projects;
pub mod settings;
pub mod tags;
// Re-export handlers for easier imports
pub use assets::*;
pub use auth::*;
pub use health::*;
pub use projects::*;
pub use settings::*;
pub use tags::*;
// Request/Response types used by handlers
#[derive(serde::Deserialize)]
pub struct CreateTagRequest {
pub name: String,
pub slug: Option<String>,
pub color: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct UpdateTagRequest {
pub name: String,
pub slug: Option<String>,
pub color: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct AddProjectTagRequest {
pub tag_id: String,
}
+677
View File
@@ -0,0 +1,677 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{auth, db, handlers::AddProjectTagRequest, state::AppState};
/// List all projects - returns filtered data based on auth status
pub async fn projects_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
let is_admin = auth::check_session(&state, &jar).is_some();
if is_admin {
// Admin view: return all projects with tags
match db::get_all_projects_with_tags_admin(&state.pool).await {
Ok(projects_with_tags) => {
let response: Vec<db::ApiAdminProject> = projects_with_tags
.into_iter()
.map(|(project, tags)| project.to_api_admin_project(tags))
.collect();
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch admin projects");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
}
} else {
// Public view: return non-hidden projects with tags
match db::get_public_projects_with_tags(&state.pool).await {
Ok(projects_with_tags) => {
let response: Vec<db::ApiAdminProject> = projects_with_tags
.into_iter()
.map(|(project, tags)| project.to_api_admin_project(tags))
.collect();
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch public projects");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
}
}
}
/// Get a single project by ID
pub async fn get_project_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let is_admin = auth::check_session(&state, &jar).is_some();
match db::get_project_by_id_with_tags(&state.pool, project_id).await {
Ok(Some((project, tags))) => {
// If project is hidden and user is not admin, return 404
if project.status == db::ProjectStatus::Hidden && !is_admin {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Json(project.to_api_admin_project(tags)).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch project"
})),
)
.into_response()
}
}
}
/// Create a new project (requires authentication)
pub async fn create_project_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::CreateProjectRequest>,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Validate request
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project name cannot be empty"
})),
)
.into_response();
}
if payload.short_description.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project short description cannot be empty"
})),
)
.into_response();
}
// Parse tag UUIDs
let tag_ids: Result<Vec<uuid::Uuid>, _> = payload
.tag_ids
.iter()
.map(|id| uuid::Uuid::parse_str(id))
.collect();
let tag_ids = match tag_ids {
Ok(ids) => ids,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid tag UUID format"
})),
)
.into_response();
}
};
// Create project
let project = match db::create_project(
&state.pool,
&payload.name,
payload.slug.as_deref(),
&payload.short_description,
&payload.description,
payload.status,
payload.github_repo.as_deref(),
payload.demo_url.as_deref(),
)
.await
{
Ok(p) => p,
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A project with this slug already exists"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to create project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create project"
})),
)
.into_response();
}
};
// Set tags
if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await {
tracing::error!(error = %err, project_id = %project.id, "Failed to set project tags");
}
// Fetch project with tags to return
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await {
Ok(Some(data)) => data,
Ok(None) => {
tracing::error!(project_id = %project.id, "Project not found after creation");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch created project"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, project_id = %project.id, "Failed to fetch created project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch created project"
})),
)
.into_response();
}
};
tracing::info!(project_id = %project.id, project_name = %project.name, "Project created");
(
StatusCode::CREATED,
Json(project.to_api_admin_project(tags)),
)
.into_response()
}
/// Update an existing project (requires authentication)
pub async fn update_project_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateProjectRequest>,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Parse project ID
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Validate exists
if db::get_project_by_id(&state.pool, project_id)
.await
.ok()
.flatten()
.is_none()
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
// Validate request
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project name cannot be empty"
})),
)
.into_response();
}
if payload.short_description.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project short description cannot be empty"
})),
)
.into_response();
}
// Parse tag UUIDs
let tag_ids: Result<Vec<uuid::Uuid>, _> = payload
.tag_ids
.iter()
.map(|id| uuid::Uuid::parse_str(id))
.collect();
let tag_ids = match tag_ids {
Ok(ids) => ids,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid tag UUID format"
})),
)
.into_response();
}
};
// Update project
let project = match db::update_project(
&state.pool,
project_id,
&payload.name,
payload.slug.as_deref(),
&payload.short_description,
&payload.description,
payload.status,
payload.github_repo.as_deref(),
payload.demo_url.as_deref(),
)
.await
{
Ok(p) => p,
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A project with this slug already exists"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to update project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update project"
})),
)
.into_response();
}
};
// Update tags (smart diff)
if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await {
tracing::error!(error = %err, project_id = %project.id, "Failed to update project tags");
}
// Fetch updated project with tags
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await {
Ok(Some(data)) => data,
Ok(None) => {
tracing::error!(project_id = %project.id, "Project not found after update");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch updated project"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, project_id = %project.id, "Failed to fetch updated project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch updated project"
})),
)
.into_response();
}
};
tracing::info!(project_id = %project.id, project_name = %project.name, "Project updated");
Json(project.to_api_admin_project(tags)).into_response()
}
/// Delete a project (requires authentication)
pub async fn delete_project_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Parse project ID
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Fetch project before deletion to return it
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project_id).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch project before deletion");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete project"
})),
)
.into_response();
}
};
// Delete project (CASCADE handles tags)
match db::delete_project(&state.pool, project_id).await {
Ok(()) => {
tracing::info!(project_id = %project_id, project_name = %project.name, "Project deleted");
Json(project.to_api_admin_project(tags)).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to delete project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete project"
})),
)
.into_response()
}
}
}
/// Get admin stats (requires authentication)
pub async fn get_admin_stats_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
match db::get_admin_stats(&state.pool).await {
Ok(stats) => Json(stats).into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch admin stats");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch statistics"
})),
)
.into_response()
}
}
}
/// Get tags for a project
pub async fn get_project_tags_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::get_tags_for_project(&state.pool, project_id).await {
Ok(tags) => {
let api_tags: Vec<db::ApiTag> = tags.into_iter().map(|t| t.to_api_tag()).collect();
Json(api_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tags for project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tags"
})),
)
.into_response()
}
}
}
/// Add a tag to a project (requires authentication)
pub async fn add_project_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<AddProjectTagRequest>,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let tag_id = match uuid::Uuid::parse_str(&payload.tag_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid tag ID",
"message": "Tag ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::add_tag_to_project(&state.pool, project_id, tag_id).await {
Ok(()) => (
StatusCode::CREATED,
Json(serde_json::json!({
"message": "Tag added to project"
})),
)
.into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_foreign_key_violation() => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project or tag not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to add tag to project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to add tag to project"
})),
)
.into_response()
}
}
}
/// Remove a tag from a project (requires authentication)
pub async fn remove_project_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let tag_id = match uuid::Uuid::parse_str(&tag_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid tag ID",
"message": "Tag ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::remove_tag_from_project(&state.pool, project_id, tag_id).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Tag removed from project"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to remove tag from project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to remove tag from project"
})),
)
.into_response()
}
}
}
+54
View File
@@ -0,0 +1,54 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{auth, db, state::AppState};
/// Get site settings (public endpoint)
pub async fn get_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::get_site_settings(&state.pool).await {
Ok(settings) => Json(settings).into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch site settings");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch settings"
})),
)
.into_response()
}
}
}
/// Update site settings (requires authentication)
pub async fn update_settings_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateSiteSettingsRequest>,
) -> impl IntoResponse {
// Check authentication
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
match db::update_site_settings(&state.pool, &payload).await {
Ok(settings) => {
// TODO: Invalidate ISR cache for homepage and affected routes when ISR is implemented
// TODO: Add event log entry for settings update when events table is implemented
tracing::info!("Site settings updated");
Json(settings).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to update site settings");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update settings"
})),
)
.into_response()
}
}
}
+328
View File
@@ -0,0 +1,328 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{
auth, db,
handlers::{CreateTagRequest, UpdateTagRequest},
state::AppState,
utils,
};
/// List all tags with project counts (public endpoint)
pub async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::get_all_tags_with_counts(&state.pool).await {
Ok(tags_with_counts) => {
let api_tags: Vec<db::ApiTagWithCount> = tags_with_counts
.into_iter()
.map(|(tag, count)| db::ApiTagWithCount {
tag: tag.to_api_tag(),
project_count: count,
})
.collect();
Json(api_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tags");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tags"
})),
)
.into_response()
}
}
}
/// Create a new tag (requires authentication)
pub async fn create_tag_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<CreateTagRequest>,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Tag name cannot be empty"
})),
)
.into_response();
}
// Validate color if provided
if let Some(ref color) = payload.color {
if !utils::validate_hex_color(color) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid color format. Must be 6-character hex (e.g., '3b82f6')"
})),
)
.into_response();
}
}
match db::create_tag(
&state.pool,
&payload.name,
payload.slug.as_deref(),
None, // icon - not yet supported in admin UI
payload.color.as_deref(),
)
.await
{
Ok(tag) => (StatusCode::CREATED, Json(tag.to_api_tag())).into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A tag with this name or slug already exists"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to create tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create tag"
})),
)
.into_response()
}
}
}
/// Get a tag by slug with associated projects
pub async fn get_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> impl IntoResponse {
match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => match db::get_projects_for_tag(&state.pool, tag.id).await {
Ok(projects) => {
let response = serde_json::json!({
"tag": tag.to_api_tag(),
"projects": projects.into_iter().map(|p| p.to_api_project()).collect::<Vec<_>>()
});
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch projects for tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
},
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response()
}
}
}
/// Update a tag (requires authentication)
pub async fn update_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<UpdateTagRequest>,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Tag name cannot be empty"
})),
)
.into_response();
}
// Validate color if provided
if let Some(ref color) = payload.color {
if !utils::validate_hex_color(color) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid color format. Must be 6-character hex (e.g., '3b82f6')"
})),
)
.into_response();
}
}
let tag = match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => tag,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response();
}
};
match db::update_tag(
&state.pool,
tag.id,
&payload.name,
payload.slug.as_deref(),
None, // icon - not yet supported in admin UI
payload.color.as_deref(),
)
.await
{
Ok(updated_tag) => Json(updated_tag.to_api_tag()).into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A tag with this name or slug already exists"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to update tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update tag"
})),
)
.into_response()
}
}
}
/// Get related tags by cooccurrence
pub async fn get_related_tags_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> impl IntoResponse {
let tag = match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => tag,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response();
}
};
match db::get_related_tags(&state.pool, tag.id, 10).await {
Ok(related_tags) => {
let api_related_tags: Vec<db::ApiRelatedTag> = related_tags
.into_iter()
.map(|(tag, count)| db::ApiRelatedTag {
tag: tag.to_api_tag(),
cooccurrence_count: count,
})
.collect();
Json(api_related_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch related tags");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch related tags"
})),
)
.into_response()
}
}
}
/// Recalculate tag cooccurrence matrix (requires authentication)
pub async fn recalculate_cooccurrence_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
match db::recalculate_tag_cooccurrence(&state.pool).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Tag cooccurrence recalculated successfully"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to recalculate cooccurrence");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to recalculate cooccurrence"
})),
)
.into_response()
}
}
}