mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 22:26:33 -06:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user