mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 14:26:37 -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:
+23
@@ -241,3 +241,26 @@ pub async fn ensure_admin_user(pool: &PgPool) -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check if the request has a valid admin session (from AppState)
|
||||||
|
pub fn check_session(
|
||||||
|
state: &crate::state::AppState,
|
||||||
|
jar: &axum_extra::extract::CookieJar,
|
||||||
|
) -> Option<Session> {
|
||||||
|
let session_cookie = jar.get("admin_session")?;
|
||||||
|
let session_id = ulid::Ulid::from_string(session_cookie.value()).ok()?;
|
||||||
|
state.session_manager.validate_session(session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a 401 Unauthorized response for API endpoints
|
||||||
|
pub fn require_auth_response() -> impl axum::response::IntoResponse {
|
||||||
|
use axum::{Json, http::StatusCode};
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Unauthorized",
|
||||||
|
"message": "Authentication required"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
pub mod projects;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod tags;
|
||||||
|
|
||||||
|
// Re-export all types and functions
|
||||||
|
pub use projects::*;
|
||||||
|
pub use settings::*;
|
||||||
|
pub use tags::*;
|
||||||
|
|
||||||
|
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||||
|
|
||||||
|
/// Database connection pool creation
|
||||||
|
pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
|
||||||
|
PgPoolOptions::new()
|
||||||
|
.max_connections(20)
|
||||||
|
.acquire_timeout(std::time::Duration::from_secs(3))
|
||||||
|
.connect(database_url)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Health check query
|
||||||
|
pub async fn health_check(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("SELECT 1 as check")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slugify text for URL-safe identifiers
|
||||||
|
pub fn slugify(text: &str) -> String {
|
||||||
|
text.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() {
|
||||||
|
c
|
||||||
|
} else if c.is_whitespace() || c == '-' || c == '_' {
|
||||||
|
'-'
|
||||||
|
} else {
|
||||||
|
'\0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.split('-')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("-")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Project status enum
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, sqlx::Type, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[sqlx(type_name = "project_status", rename_all = "lowercase")]
|
||||||
|
pub enum ProjectStatus {
|
||||||
|
Active,
|
||||||
|
Maintained,
|
||||||
|
Archived,
|
||||||
|
Hidden,
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::{ProjectStatus, slugify};
|
||||||
|
|
||||||
|
// Database model
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbProject {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub short_description: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status: ProjectStatus,
|
||||||
|
pub github_repo: Option<String>,
|
||||||
|
pub demo_url: Option<String>,
|
||||||
|
pub last_github_activity: Option<OffsetDateTime>,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiProjectLink {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiProject {
|
||||||
|
pub id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "shortDescription")]
|
||||||
|
pub short_description: String,
|
||||||
|
pub links: Vec<ApiProjectLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiAdminProject {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub project: ApiProject,
|
||||||
|
pub tags: Vec<super::tags::ApiTag>,
|
||||||
|
pub status: String,
|
||||||
|
pub description: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub github_repo: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub demo_url: Option<String>,
|
||||||
|
#[serde(rename = "createdAt")]
|
||||||
|
pub created_at: String, // ISO 8601
|
||||||
|
#[serde(rename = "updatedAt")]
|
||||||
|
pub updated_at: String, // ISO 8601
|
||||||
|
#[serde(rename = "lastGithubActivity", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_github_activity: Option<String>, // ISO 8601
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbProject {
|
||||||
|
/// Convert database project to API response format
|
||||||
|
pub fn to_api_project(&self) -> ApiProject {
|
||||||
|
let mut links = Vec::new();
|
||||||
|
|
||||||
|
if let Some(ref repo) = self.github_repo {
|
||||||
|
links.push(ApiProjectLink {
|
||||||
|
url: format!("https://github.com/{}", repo),
|
||||||
|
title: Some("GitHub".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref demo) = self.demo_url {
|
||||||
|
links.push(ApiProjectLink {
|
||||||
|
url: demo.clone(),
|
||||||
|
title: Some("Demo".to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiProject {
|
||||||
|
id: self.id.to_string(),
|
||||||
|
slug: self.slug.clone(),
|
||||||
|
name: self.name.clone(),
|
||||||
|
short_description: self.short_description.clone(),
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_api_admin_project(&self, tags: Vec<super::tags::DbTag>) -> ApiAdminProject {
|
||||||
|
ApiAdminProject {
|
||||||
|
project: self.to_api_project(),
|
||||||
|
tags: tags.into_iter().map(|t| t.to_api_tag()).collect(),
|
||||||
|
status: format!("{:?}", self.status).to_lowercase(),
|
||||||
|
description: self.description.clone(),
|
||||||
|
github_repo: self.github_repo.clone(),
|
||||||
|
demo_url: self.demo_url.clone(),
|
||||||
|
created_at: self
|
||||||
|
.created_at
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap(),
|
||||||
|
updated_at: self
|
||||||
|
.updated_at
|
||||||
|
.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap(),
|
||||||
|
last_github_activity: self.last_github_activity.map(|dt| {
|
||||||
|
dt.format(&time::format_description::well_known::Rfc3339)
|
||||||
|
.unwrap()
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request types for CRUD operations
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateProjectRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub short_description: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status: ProjectStatus,
|
||||||
|
pub github_repo: Option<String>,
|
||||||
|
pub demo_url: Option<String>,
|
||||||
|
pub tag_ids: Vec<String>, // UUID strings
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateProjectRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub slug: Option<String>,
|
||||||
|
pub short_description: String,
|
||||||
|
pub description: String,
|
||||||
|
pub status: ProjectStatus,
|
||||||
|
pub github_repo: Option<String>,
|
||||||
|
pub demo_url: Option<String>,
|
||||||
|
pub tag_ids: Vec<String>, // UUID strings
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin stats response
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AdminStats {
|
||||||
|
#[serde(rename = "totalProjects")]
|
||||||
|
pub total_projects: i32,
|
||||||
|
#[serde(rename = "projectsByStatus")]
|
||||||
|
pub projects_by_status: serde_json::Value,
|
||||||
|
#[serde(rename = "totalTags")]
|
||||||
|
pub total_tags: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query functions
|
||||||
|
|
||||||
|
pub async fn get_public_projects(pool: &PgPool) -> Result<Vec<DbProject>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProject,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
short_description,
|
||||||
|
description,
|
||||||
|
status as "status: ProjectStatus",
|
||||||
|
github_repo,
|
||||||
|
demo_url,
|
||||||
|
last_github_activity,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE status != 'hidden'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_public_projects_with_tags(
|
||||||
|
pool: &PgPool,
|
||||||
|
) -> Result<Vec<(DbProject, Vec<super::tags::DbTag>)>, sqlx::Error> {
|
||||||
|
let projects = get_public_projects(pool).await?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for project in projects {
|
||||||
|
let tags = super::tags::get_tags_for_project(pool, project.id).await?;
|
||||||
|
result.push((project, tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all projects (admin view - includes hidden)
|
||||||
|
pub async fn get_all_projects_admin(pool: &PgPool) -> Result<Vec<DbProject>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProject,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
short_description,
|
||||||
|
description,
|
||||||
|
status as "status: ProjectStatus",
|
||||||
|
github_repo,
|
||||||
|
demo_url,
|
||||||
|
last_github_activity,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM projects
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get all projects with tags (admin view)
|
||||||
|
pub async fn get_all_projects_with_tags_admin(
|
||||||
|
pool: &PgPool,
|
||||||
|
) -> Result<Vec<(DbProject, Vec<super::tags::DbTag>)>, sqlx::Error> {
|
||||||
|
let projects = get_all_projects_admin(pool).await?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for project in projects {
|
||||||
|
let tags = super::tags::get_tags_for_project(pool, project.id).await?;
|
||||||
|
result.push((project, tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single project by ID
|
||||||
|
pub async fn get_project_by_id(pool: &PgPool, id: Uuid) -> Result<Option<DbProject>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProject,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
short_description,
|
||||||
|
description,
|
||||||
|
status as "status: ProjectStatus",
|
||||||
|
github_repo,
|
||||||
|
demo_url,
|
||||||
|
|
||||||
|
last_github_activity,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single project by ID with tags
|
||||||
|
pub async fn get_project_by_id_with_tags(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<(DbProject, Vec<super::tags::DbTag>)>, sqlx::Error> {
|
||||||
|
let project = get_project_by_id(pool, id).await?;
|
||||||
|
|
||||||
|
match project {
|
||||||
|
Some(p) => {
|
||||||
|
let tags = super::tags::get_tags_for_project(pool, p.id).await?;
|
||||||
|
Ok(Some((p, tags)))
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get single project by slug
|
||||||
|
pub async fn get_project_by_slug(
|
||||||
|
pool: &PgPool,
|
||||||
|
slug: &str,
|
||||||
|
) -> Result<Option<DbProject>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProject,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
short_description,
|
||||||
|
description,
|
||||||
|
status as "status: ProjectStatus",
|
||||||
|
github_repo,
|
||||||
|
demo_url,
|
||||||
|
|
||||||
|
last_github_activity,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
FROM projects
|
||||||
|
WHERE slug = $1
|
||||||
|
"#,
|
||||||
|
slug
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create project (without tags - tags handled separately)
|
||||||
|
pub async fn create_project(
|
||||||
|
pool: &PgPool,
|
||||||
|
name: &str,
|
||||||
|
slug_override: Option<&str>,
|
||||||
|
short_description: &str,
|
||||||
|
description: &str,
|
||||||
|
status: ProjectStatus,
|
||||||
|
github_repo: Option<&str>,
|
||||||
|
demo_url: Option<&str>,
|
||||||
|
) -> Result<DbProject, sqlx::Error> {
|
||||||
|
let slug = slug_override
|
||||||
|
.map(|s| slugify(s))
|
||||||
|
.unwrap_or_else(|| slugify(name));
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProject,
|
||||||
|
r#"
|
||||||
|
INSERT INTO projects (slug, name, short_description, description, status, github_repo, demo_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING id, slug, name, short_description, description, status as "status: ProjectStatus",
|
||||||
|
github_repo, demo_url, last_github_activity, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
short_description,
|
||||||
|
description,
|
||||||
|
status as ProjectStatus,
|
||||||
|
github_repo,
|
||||||
|
demo_url
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update project (without tags - tags handled separately)
|
||||||
|
pub async fn update_project(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
slug_override: Option<&str>,
|
||||||
|
short_description: &str,
|
||||||
|
description: &str,
|
||||||
|
status: ProjectStatus,
|
||||||
|
github_repo: Option<&str>,
|
||||||
|
demo_url: Option<&str>,
|
||||||
|
) -> Result<DbProject, sqlx::Error> {
|
||||||
|
let slug = slug_override
|
||||||
|
.map(|s| slugify(s))
|
||||||
|
.unwrap_or_else(|| slugify(name));
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbProject,
|
||||||
|
r#"
|
||||||
|
UPDATE projects
|
||||||
|
SET slug = $2, name = $3, short_description = $4, description = $5,
|
||||||
|
status = $6, github_repo = $7, demo_url = $8
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, slug, name, short_description, description, status as "status: ProjectStatus",
|
||||||
|
github_repo, demo_url, last_github_activity, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
short_description,
|
||||||
|
description,
|
||||||
|
status as ProjectStatus,
|
||||||
|
github_repo,
|
||||||
|
demo_url
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete project (CASCADE will handle tags)
|
||||||
|
pub async fn delete_project(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get admin stats
|
||||||
|
pub async fn get_admin_stats(pool: &PgPool) -> Result<AdminStats, sqlx::Error> {
|
||||||
|
// Get project counts by status
|
||||||
|
let status_counts = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
status as "status!: ProjectStatus",
|
||||||
|
COUNT(*)::int as "count!"
|
||||||
|
FROM projects
|
||||||
|
GROUP BY status
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut projects_by_status = serde_json::json!({
|
||||||
|
"active": 0,
|
||||||
|
"maintained": 0,
|
||||||
|
"archived": 0,
|
||||||
|
"hidden": 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut total_projects = 0;
|
||||||
|
for row in status_counts {
|
||||||
|
let status_str = format!("{:?}", row.status).to_lowercase();
|
||||||
|
projects_by_status[status_str] = serde_json::json!(row.count);
|
||||||
|
total_projects += row.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total tags
|
||||||
|
let tag_count = sqlx::query!("SELECT COUNT(*)::int as \"count!\" FROM tags")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(AdminStats {
|
||||||
|
total_projects,
|
||||||
|
projects_by_status,
|
||||||
|
total_tags: tag_count.count,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// Site settings models
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbSiteIdentity {
|
||||||
|
pub id: i32,
|
||||||
|
pub display_name: String,
|
||||||
|
pub occupation: String,
|
||||||
|
pub bio: String,
|
||||||
|
pub site_title: String,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbSocialLink {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub platform: String,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub visible: bool,
|
||||||
|
pub display_order: i32,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiSiteIdentity {
|
||||||
|
#[serde(rename = "displayName")]
|
||||||
|
pub display_name: String,
|
||||||
|
pub occupation: String,
|
||||||
|
pub bio: String,
|
||||||
|
#[serde(rename = "siteTitle")]
|
||||||
|
pub site_title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiSocialLink {
|
||||||
|
pub id: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub visible: bool,
|
||||||
|
#[serde(rename = "displayOrder")]
|
||||||
|
pub display_order: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiSiteSettings {
|
||||||
|
pub identity: ApiSiteIdentity,
|
||||||
|
#[serde(rename = "socialLinks")]
|
||||||
|
pub social_links: Vec<ApiSocialLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request types for updates
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateSiteIdentityRequest {
|
||||||
|
#[serde(rename = "displayName")]
|
||||||
|
pub display_name: String,
|
||||||
|
pub occupation: String,
|
||||||
|
pub bio: String,
|
||||||
|
#[serde(rename = "siteTitle")]
|
||||||
|
pub site_title: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateSocialLinkRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub icon: String,
|
||||||
|
pub visible: bool,
|
||||||
|
#[serde(rename = "displayOrder")]
|
||||||
|
pub display_order: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateSiteSettingsRequest {
|
||||||
|
pub identity: UpdateSiteIdentityRequest,
|
||||||
|
#[serde(rename = "socialLinks")]
|
||||||
|
pub social_links: Vec<UpdateSocialLinkRequest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversion implementations
|
||||||
|
impl DbSiteIdentity {
|
||||||
|
pub fn to_api(&self) -> ApiSiteIdentity {
|
||||||
|
ApiSiteIdentity {
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
occupation: self.occupation.clone(),
|
||||||
|
bio: self.bio.clone(),
|
||||||
|
site_title: self.site_title.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbSocialLink {
|
||||||
|
pub fn to_api(&self) -> ApiSocialLink {
|
||||||
|
ApiSocialLink {
|
||||||
|
id: self.id.to_string(),
|
||||||
|
platform: self.platform.clone(),
|
||||||
|
label: self.label.clone(),
|
||||||
|
value: self.value.clone(),
|
||||||
|
icon: self.icon.clone(),
|
||||||
|
visible: self.visible,
|
||||||
|
display_order: self.display_order,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query functions
|
||||||
|
pub async fn get_site_settings(pool: &PgPool) -> Result<ApiSiteSettings, sqlx::Error> {
|
||||||
|
// Get identity (single row)
|
||||||
|
let identity = sqlx::query_as!(
|
||||||
|
DbSiteIdentity,
|
||||||
|
r#"
|
||||||
|
SELECT id, display_name, occupation, bio, site_title, created_at, updated_at
|
||||||
|
FROM site_identity
|
||||||
|
WHERE id = 1
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Get social links (ordered)
|
||||||
|
let social_links = sqlx::query_as!(
|
||||||
|
DbSocialLink,
|
||||||
|
r#"
|
||||||
|
SELECT id, platform, label, value, icon, visible, display_order, created_at, updated_at
|
||||||
|
FROM social_links
|
||||||
|
ORDER BY display_order ASC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(ApiSiteSettings {
|
||||||
|
identity: identity.to_api(),
|
||||||
|
social_links: social_links.into_iter().map(|sl| sl.to_api()).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_site_identity(
|
||||||
|
pool: &PgPool,
|
||||||
|
req: &UpdateSiteIdentityRequest,
|
||||||
|
) -> Result<DbSiteIdentity, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbSiteIdentity,
|
||||||
|
r#"
|
||||||
|
UPDATE site_identity
|
||||||
|
SET display_name = $1, occupation = $2, bio = $3, site_title = $4
|
||||||
|
WHERE id = 1
|
||||||
|
RETURNING id, display_name, occupation, bio, site_title, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
req.display_name,
|
||||||
|
req.occupation,
|
||||||
|
req.bio,
|
||||||
|
req.site_title
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_social_link(
|
||||||
|
pool: &PgPool,
|
||||||
|
link_id: Uuid,
|
||||||
|
req: &UpdateSocialLinkRequest,
|
||||||
|
) -> Result<DbSocialLink, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbSocialLink,
|
||||||
|
r#"
|
||||||
|
UPDATE social_links
|
||||||
|
SET platform = $2, label = $3, value = $4, icon = $5, visible = $6, display_order = $7
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, platform, label, value, icon, visible, display_order, created_at, updated_at
|
||||||
|
"#,
|
||||||
|
link_id,
|
||||||
|
req.platform,
|
||||||
|
req.label,
|
||||||
|
req.value,
|
||||||
|
req.icon,
|
||||||
|
req.visible,
|
||||||
|
req.display_order
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_site_settings(
|
||||||
|
pool: &PgPool,
|
||||||
|
req: &UpdateSiteSettingsRequest,
|
||||||
|
) -> Result<ApiSiteSettings, sqlx::Error> {
|
||||||
|
// Update identity
|
||||||
|
let identity = update_site_identity(pool, &req.identity).await?;
|
||||||
|
|
||||||
|
// Update each social link
|
||||||
|
let mut updated_links = Vec::new();
|
||||||
|
for link_req in &req.social_links {
|
||||||
|
let link_id = Uuid::parse_str(&link_req.id).map_err(|_| {
|
||||||
|
sqlx::Error::Decode(Box::new(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"Invalid UUID format",
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
let link = update_social_link(pool, link_id, link_req).await?;
|
||||||
|
updated_links.push(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ApiSiteSettings {
|
||||||
|
identity: identity.to_api(),
|
||||||
|
social_links: updated_links.into_iter().map(|sl| sl.to_api()).collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
+432
@@ -0,0 +1,432 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::slugify;
|
||||||
|
|
||||||
|
// Tag database models
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbTag {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub created_at: OffsetDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbProjectTag {
|
||||||
|
pub project_id: Uuid,
|
||||||
|
pub tag_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, sqlx::FromRow)]
|
||||||
|
pub struct DbTagCooccurrence {
|
||||||
|
pub tag_a: Uuid,
|
||||||
|
pub tag_b: Uuid,
|
||||||
|
pub count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
// API response types
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiTag {
|
||||||
|
pub id: String,
|
||||||
|
pub slug: String,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiTagWithCount {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub tag: ApiTag,
|
||||||
|
pub project_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ApiRelatedTag {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub tag: ApiTag,
|
||||||
|
pub cooccurrence_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DbTag {
|
||||||
|
/// Convert database tag to API response format
|
||||||
|
pub fn to_api_tag(&self) -> ApiTag {
|
||||||
|
ApiTag {
|
||||||
|
id: self.id.to_string(),
|
||||||
|
slug: self.slug.clone(),
|
||||||
|
name: self.name.clone(),
|
||||||
|
icon: self.icon.clone(),
|
||||||
|
color: self.color.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag CRUD queries
|
||||||
|
|
||||||
|
pub async fn create_tag(
|
||||||
|
pool: &PgPool,
|
||||||
|
name: &str,
|
||||||
|
slug_override: Option<&str>,
|
||||||
|
icon: Option<&str>,
|
||||||
|
color: Option<&str>,
|
||||||
|
) -> Result<DbTag, sqlx::Error> {
|
||||||
|
let slug = slug_override
|
||||||
|
.map(|s| slugify(s))
|
||||||
|
.unwrap_or_else(|| slugify(name));
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbTag,
|
||||||
|
r#"
|
||||||
|
INSERT INTO tags (slug, name, icon, color)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, slug, name, icon, color, created_at
|
||||||
|
"#,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
color
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tag_by_id(pool: &PgPool, id: Uuid) -> Result<Option<DbTag>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbTag,
|
||||||
|
r#"
|
||||||
|
SELECT id, slug, name, icon, color, created_at
|
||||||
|
FROM tags
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
id
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tag_by_slug(pool: &PgPool, slug: &str) -> Result<Option<DbTag>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbTag,
|
||||||
|
r#"
|
||||||
|
SELECT id, slug, name, icon, color, created_at
|
||||||
|
FROM tags
|
||||||
|
WHERE slug = $1
|
||||||
|
"#,
|
||||||
|
slug
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_tags(pool: &PgPool) -> Result<Vec<DbTag>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbTag,
|
||||||
|
r#"
|
||||||
|
SELECT id, slug, name, icon, color, created_at
|
||||||
|
FROM tags
|
||||||
|
ORDER BY name ASC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_tags_with_counts(pool: &PgPool) -> Result<Vec<(DbTag, i32)>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.slug,
|
||||||
|
t.name,
|
||||||
|
t.icon,
|
||||||
|
t.color,
|
||||||
|
t.created_at,
|
||||||
|
COUNT(pt.project_id)::int as "project_count!"
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN project_tags pt ON t.id = pt.tag_id
|
||||||
|
GROUP BY t.id, t.slug, t.name, t.icon, t.color, t.created_at
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let tag = DbTag {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
icon: row.icon,
|
||||||
|
color: row.color,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
(tag, row.project_count)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_tag(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
slug_override: Option<&str>,
|
||||||
|
icon: Option<&str>,
|
||||||
|
color: Option<&str>,
|
||||||
|
) -> Result<DbTag, sqlx::Error> {
|
||||||
|
let slug = slug_override
|
||||||
|
.map(|s| slugify(s))
|
||||||
|
.unwrap_or_else(|| slugify(name));
|
||||||
|
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbTag,
|
||||||
|
r#"
|
||||||
|
UPDATE tags
|
||||||
|
SET slug = $2, name = $3, icon = $4, color = $5
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING id, slug, name, icon, color, created_at
|
||||||
|
"#,
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
icon,
|
||||||
|
color
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_tag(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!("DELETE FROM tags WHERE id = $1", id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tag_exists_by_name(pool: &PgPool, name: &str) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(SELECT 1 FROM tags WHERE LOWER(name) = LOWER($1)) as "exists!"
|
||||||
|
"#,
|
||||||
|
name
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn tag_exists_by_slug(pool: &PgPool, slug: &str) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT EXISTS(SELECT 1 FROM tags WHERE slug = $1) as "exists!"
|
||||||
|
"#,
|
||||||
|
slug
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result.exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project-Tag association queries
|
||||||
|
|
||||||
|
pub async fn add_tag_to_project(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
tag_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO project_tags (project_id, tag_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (project_id, tag_id) DO NOTHING
|
||||||
|
"#,
|
||||||
|
project_id,
|
||||||
|
tag_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_tag_from_project(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
tag_id: Uuid,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query!(
|
||||||
|
"DELETE FROM project_tags WHERE project_id = $1 AND tag_id = $2",
|
||||||
|
project_id,
|
||||||
|
tag_id
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_tags_for_project(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
) -> Result<Vec<DbTag>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
DbTag,
|
||||||
|
r#"
|
||||||
|
SELECT t.id, t.slug, t.name, t.icon, t.color, t.created_at
|
||||||
|
FROM tags t
|
||||||
|
JOIN project_tags pt ON t.id = pt.tag_id
|
||||||
|
WHERE pt.project_id = $1
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
"#,
|
||||||
|
project_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_projects_for_tag(
|
||||||
|
pool: &PgPool,
|
||||||
|
tag_id: Uuid,
|
||||||
|
) -> Result<Vec<super::projects::DbProject>, sqlx::Error> {
|
||||||
|
sqlx::query_as!(
|
||||||
|
super::projects::DbProject,
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.slug,
|
||||||
|
p.name,
|
||||||
|
p.short_description,
|
||||||
|
p.description,
|
||||||
|
p.status as "status: super::ProjectStatus",
|
||||||
|
p.github_repo,
|
||||||
|
p.demo_url,
|
||||||
|
p.last_github_activity,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at
|
||||||
|
FROM projects p
|
||||||
|
JOIN project_tags pt ON p.id = pt.project_id
|
||||||
|
WHERE pt.tag_id = $1
|
||||||
|
ORDER BY p.updated_at DESC
|
||||||
|
"#,
|
||||||
|
tag_id
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set project tags (smart diff implementation)
|
||||||
|
pub async fn set_project_tags(
|
||||||
|
pool: &PgPool,
|
||||||
|
project_id: Uuid,
|
||||||
|
tag_ids: &[Uuid],
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
// Get current tags
|
||||||
|
let current_tags = get_tags_for_project(pool, project_id).await?;
|
||||||
|
let current_ids: Vec<Uuid> = current_tags.iter().map(|t| t.id).collect();
|
||||||
|
|
||||||
|
// Find tags to add (in new list but not in current)
|
||||||
|
let to_add: Vec<Uuid> = tag_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|id| !current_ids.contains(id))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Find tags to remove (in current but not in new list)
|
||||||
|
let to_remove: Vec<Uuid> = current_ids
|
||||||
|
.iter()
|
||||||
|
.filter(|id| !tag_ids.contains(id))
|
||||||
|
.copied()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Add new tags
|
||||||
|
for tag_id in to_add {
|
||||||
|
add_tag_to_project(pool, project_id, tag_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old tags
|
||||||
|
for tag_id in to_remove {
|
||||||
|
remove_tag_from_project(pool, project_id, tag_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag cooccurrence queries
|
||||||
|
|
||||||
|
pub async fn recalculate_tag_cooccurrence(pool: &PgPool) -> Result<(), sqlx::Error> {
|
||||||
|
// Delete existing cooccurrence data
|
||||||
|
sqlx::query!("DELETE FROM tag_cooccurrence")
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Calculate and insert new cooccurrence data
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO tag_cooccurrence (tag_a, tag_b, count)
|
||||||
|
SELECT
|
||||||
|
LEAST(t1.tag_id, t2.tag_id) as tag_a,
|
||||||
|
GREATEST(t1.tag_id, t2.tag_id) as tag_b,
|
||||||
|
COUNT(*)::int as count
|
||||||
|
FROM project_tags t1
|
||||||
|
JOIN project_tags t2 ON t1.project_id = t2.project_id
|
||||||
|
WHERE t1.tag_id < t2.tag_id
|
||||||
|
GROUP BY tag_a, tag_b
|
||||||
|
HAVING COUNT(*) > 0
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_related_tags(
|
||||||
|
pool: &PgPool,
|
||||||
|
tag_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<(DbTag, i32)>, sqlx::Error> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.slug,
|
||||||
|
t.name,
|
||||||
|
t.icon,
|
||||||
|
t.color,
|
||||||
|
t.created_at,
|
||||||
|
tc.count
|
||||||
|
FROM tag_cooccurrence tc
|
||||||
|
JOIN tags t ON (tc.tag_a = t.id OR tc.tag_b = t.id)
|
||||||
|
WHERE (tc.tag_a = $1 OR tc.tag_b = $1) AND t.id != $1
|
||||||
|
ORDER BY tc.count DESC, t.name ASC
|
||||||
|
LIMIT $2
|
||||||
|
"#,
|
||||||
|
tag_id,
|
||||||
|
limit
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let tag = DbTag {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
name: row.name,
|
||||||
|
icon: row.icon,
|
||||||
|
color: row.color,
|
||||||
|
created_at: row.created_at,
|
||||||
|
};
|
||||||
|
(tag, row.count)
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-1956
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
|||||||
use crate::auth::{Session, SessionManager};
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
body::Body,
|
|
||||||
extract::{Request, State},
|
|
||||||
http::{StatusCode, Uri},
|
|
||||||
middleware::Next,
|
|
||||||
response::{IntoResponse, Redirect, Response},
|
|
||||||
};
|
|
||||||
use axum_extra::extract::CookieJar;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME: &str = "admin_session";
|
|
||||||
|
|
||||||
pub async fn require_admin_auth(
|
|
||||||
State(session_mgr): State<Arc<SessionManager>>,
|
|
||||||
jar: CookieJar,
|
|
||||||
uri: Uri,
|
|
||||||
mut req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, Response> {
|
|
||||||
let session_cookie = jar.get(SESSION_COOKIE_NAME);
|
|
||||||
|
|
||||||
let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok());
|
|
||||||
|
|
||||||
let session = session_id.and_then(|id| session_mgr.validate_session(id));
|
|
||||||
|
|
||||||
match session {
|
|
||||||
Some(session) => {
|
|
||||||
req.extensions_mut().insert(session);
|
|
||||||
Ok(next.run(req).await)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let next_param = urlencoding::encode(uri.path());
|
|
||||||
let redirect_url = format!("/admin/login?next={}", next_param);
|
|
||||||
Err(Redirect::to(&redirect_url).into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn require_api_auth(
|
|
||||||
State(session_mgr): State<Arc<SessionManager>>,
|
|
||||||
jar: CookieJar,
|
|
||||||
mut req: Request,
|
|
||||||
next: Next,
|
|
||||||
) -> Result<Response, Response> {
|
|
||||||
let session_cookie = jar.get(SESSION_COOKIE_NAME);
|
|
||||||
|
|
||||||
let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok());
|
|
||||||
|
|
||||||
let session = session_id.and_then(|id| session_mgr.validate_session(id));
|
|
||||||
|
|
||||||
match session {
|
|
||||||
Some(session) => {
|
|
||||||
req.extensions_mut().insert(session);
|
|
||||||
Ok(next.run(req).await)
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let error_response = (
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
Json(json!({
|
|
||||||
"error": "Unauthorized",
|
|
||||||
"message": "Authentication required"
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
Err(error_response.into_response())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn extract_session(req: &Request<Body>) -> Option<Session> {
|
|
||||||
req.extensions().get::<Session>().cloned()
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
pub mod auth;
|
|
||||||
pub mod request_id;
|
pub mod request_id;
|
||||||
|
|
||||||
pub use auth::{require_admin_auth, require_api_auth};
|
|
||||||
pub use request_id::RequestIdLayer;
|
pub use request_id::RequestIdLayer;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use crate::{AppState, r2::R2Client};
|
use crate::{r2::R2Client, state::AppState};
|
||||||
|
|
||||||
/// Discriminated union matching TypeScript's `OGImageSpec` in web/src/lib/og-types.ts
|
/// Discriminated union matching TypeScript's `OGImageSpec` in web/src/lib/og-types.ts
|
||||||
///
|
///
|
||||||
@@ -74,7 +74,6 @@ pub async fn generate_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if an OG image exists in R2
|
/// Check if an OG image exists in R2
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
|
pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
|
||||||
if let Some(r2) = R2Client::get().await {
|
if let Some(r2) = R2Client::get().await {
|
||||||
r2.object_exists(&spec.r2_key()).await
|
r2.object_exists(&spec.r2_key()).await
|
||||||
@@ -84,7 +83,6 @@ pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Ensure an OG image exists, generating if necessary
|
/// Ensure an OG image exists, generating if necessary
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
|
pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
|
||||||
if og_image_exists(spec).await {
|
if og_image_exists(spec).await {
|
||||||
tracing::debug!(r2_key = spec.r2_key(), "OG image already exists");
|
tracing::debug!(r2_key = spec.r2_key(), "OG image already exists");
|
||||||
@@ -94,23 +92,24 @@ pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Regenerate common OG images (index, projects) on server startup
|
/// Regenerate common OG images (index, projects) on server startup
|
||||||
|
/// Uses ensure_og_image to skip regeneration if images already exist
|
||||||
pub async fn regenerate_common_images(state: Arc<AppState>) {
|
pub async fn regenerate_common_images(state: Arc<AppState>) {
|
||||||
// Wait 2 seconds before starting
|
// Wait 2 seconds before starting
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
|
|
||||||
tracing::info!("Regenerating common OG images");
|
tracing::info!("Ensuring common OG images exist");
|
||||||
let specs = vec![OGImageSpec::Index, OGImageSpec::Projects];
|
let specs = vec![OGImageSpec::Index, OGImageSpec::Projects];
|
||||||
|
|
||||||
for spec in specs {
|
for spec in specs {
|
||||||
match generate_og_image(&spec, state.clone()).await {
|
match ensure_og_image(&spec, state.clone()).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
tracing::info!(r2_key = spec.r2_key(), "Successfully regenerated OG image");
|
tracing::info!(r2_key = spec.r2_key(), "Common OG image ready");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(r2_key = spec.r2_key(), error = %e, "Failed to regenerate OG image");
|
tracing::error!(r2_key = spec.r2_key(), error = %e, "Failed to ensure OG image");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::info!("Finished regenerating common OG images");
|
tracing::info!("Finished ensuring common OG images");
|
||||||
}
|
}
|
||||||
|
|||||||
+339
@@ -0,0 +1,339 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{ConnectInfo, Request, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
assets, db,
|
||||||
|
state::{AppState, ProxyError},
|
||||||
|
tarpit::{self, TarpitState},
|
||||||
|
utils,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// ISR handler - serves pages through Bun SSR with session validation
|
||||||
|
#[tracing::instrument(skip(state, req), fields(path = %req.uri().path(), method = %req.method()))]
|
||||||
|
pub async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Response {
|
||||||
|
let method = req.method().clone();
|
||||||
|
let uri = req.uri();
|
||||||
|
let path = uri.path();
|
||||||
|
let query = uri.query().unwrap_or("");
|
||||||
|
|
||||||
|
if method != axum::http::Method::GET && method != axum::http::Method::HEAD {
|
||||||
|
tracing::warn!(method = %method, path = %path, "Non-GET/HEAD request to non-API route");
|
||||||
|
|
||||||
|
if utils::accepts_html(req.headers()) {
|
||||||
|
return utils::serve_error_page(StatusCode::METHOD_NOT_ALLOWED);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::ALLOW,
|
||||||
|
axum::http::HeaderValue::from_static("GET, HEAD, OPTIONS"),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::METHOD_NOT_ALLOWED,
|
||||||
|
headers,
|
||||||
|
"Method not allowed",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_head = method == axum::http::Method::HEAD;
|
||||||
|
|
||||||
|
if path.starts_with("/api/") {
|
||||||
|
tracing::error!("API request reached ISR handler - routing bug!");
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal routing error").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block internal routes from external access
|
||||||
|
if path.starts_with("/internal/") {
|
||||||
|
tracing::warn!(path = %path, "Attempted access to internal route");
|
||||||
|
|
||||||
|
if utils::accepts_html(req.headers()) {
|
||||||
|
return utils::serve_error_page(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (StatusCode::NOT_FOUND, "Not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a static asset that exists in embedded CLIENT_ASSETS
|
||||||
|
if utils::is_static_asset(path) {
|
||||||
|
if let Some(response) = assets::try_serve_embedded_asset(path) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
// If not found in embedded assets, continue to proxy (might be in Bun's static dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a prerendered page
|
||||||
|
if let Some(response) = assets::try_serve_prerendered_page(path) {
|
||||||
|
tracing::debug!(path = %path, "Serving prerendered page");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
|
||||||
|
{
|
||||||
|
if query.is_empty() {
|
||||||
|
format!("http://localhost{path}")
|
||||||
|
} else {
|
||||||
|
format!("http://localhost{path}?{query}")
|
||||||
|
}
|
||||||
|
} else if query.is_empty() {
|
||||||
|
format!("{}{}", state.downstream_url, path)
|
||||||
|
} else {
|
||||||
|
format!("{}{}?{}", state.downstream_url, path, query)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build trusted headers to forward to downstream
|
||||||
|
let mut forward_headers = HeaderMap::new();
|
||||||
|
|
||||||
|
// SECURITY: Strip any X-Session-User header from incoming request to prevent spoofing
|
||||||
|
|
||||||
|
// Extract and validate session from cookie
|
||||||
|
if let Some(cookie_header) = req.headers().get(axum::http::header::COOKIE) {
|
||||||
|
if let Ok(cookie_str) = cookie_header.to_str() {
|
||||||
|
// Parse cookies manually to find admin_session
|
||||||
|
for cookie_pair in cookie_str.split(';') {
|
||||||
|
let cookie_pair = cookie_pair.trim();
|
||||||
|
if let Some((name, value)) = cookie_pair.split_once('=') {
|
||||||
|
if name == "admin_session" {
|
||||||
|
// Found session cookie, validate it
|
||||||
|
if let Ok(session_id) = ulid::Ulid::from_string(value) {
|
||||||
|
if let Some(session) =
|
||||||
|
state.session_manager.validate_session(session_id)
|
||||||
|
{
|
||||||
|
// Session is valid - add trusted header
|
||||||
|
if let Ok(username_value) =
|
||||||
|
axum::http::HeaderValue::from_str(&session.username)
|
||||||
|
{
|
||||||
|
forward_headers.insert("x-session-user", username_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match proxy_to_bun(&bun_url, state.clone(), forward_headers).await {
|
||||||
|
Ok((status, headers, body)) => {
|
||||||
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
|
let cache = "miss";
|
||||||
|
|
||||||
|
let is_static = utils::is_static_asset(path);
|
||||||
|
let is_page = utils::is_page_route(path);
|
||||||
|
|
||||||
|
match (status.as_u16(), is_static, is_page) {
|
||||||
|
(200..=299, true, _) => {
|
||||||
|
tracing::trace!(status = status.as_u16(), duration_ms, cache, "ISR request");
|
||||||
|
}
|
||||||
|
(404, true, _) => {
|
||||||
|
tracing::warn!(
|
||||||
|
status = status.as_u16(),
|
||||||
|
duration_ms,
|
||||||
|
cache,
|
||||||
|
"ISR request - missing asset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(500..=599, true, _) => {
|
||||||
|
tracing::error!(
|
||||||
|
status = status.as_u16(),
|
||||||
|
duration_ms,
|
||||||
|
cache,
|
||||||
|
"ISR request - server error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(200..=299, _, true) => {
|
||||||
|
tracing::debug!(status = status.as_u16(), duration_ms, cache, "ISR request");
|
||||||
|
}
|
||||||
|
(404, _, true) => {}
|
||||||
|
(500..=599, _, _) => {
|
||||||
|
tracing::error!(
|
||||||
|
status = status.as_u16(),
|
||||||
|
duration_ms,
|
||||||
|
cache,
|
||||||
|
"ISR request - server error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!(status = status.as_u16(), duration_ms, cache, "ISR request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intercept error responses for HTML requests
|
||||||
|
if (status.is_client_error() || status.is_server_error())
|
||||||
|
&& utils::accepts_html(req.headers())
|
||||||
|
{
|
||||||
|
return utils::serve_error_page(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if is_head {
|
||||||
|
(status, headers).into_response()
|
||||||
|
} else {
|
||||||
|
(status, headers, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let duration_ms = start.elapsed().as_millis() as u64;
|
||||||
|
tracing::error!(
|
||||||
|
error = %err,
|
||||||
|
url = %bun_url,
|
||||||
|
duration_ms,
|
||||||
|
"Failed to proxy to Bun"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Serve 502 error page instead of plaintext
|
||||||
|
if utils::accepts_html(req.headers()) {
|
||||||
|
return utils::serve_error_page(StatusCode::BAD_GATEWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("Failed to render page: {err}"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Proxy a request to Bun SSR
|
||||||
|
pub async fn proxy_to_bun(
|
||||||
|
url: &str,
|
||||||
|
state: Arc<AppState>,
|
||||||
|
forward_headers: HeaderMap,
|
||||||
|
) -> Result<(StatusCode, HeaderMap, axum::body::Bytes), ProxyError> {
|
||||||
|
let client = if state.unix_client.is_some() {
|
||||||
|
state.unix_client.as_ref().unwrap()
|
||||||
|
} else {
|
||||||
|
&state.http_client
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build request with forwarded headers
|
||||||
|
let mut request_builder = client.get(url);
|
||||||
|
for (name, value) in forward_headers.iter() {
|
||||||
|
request_builder = request_builder.header(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request_builder.send().await.map_err(ProxyError::Network)?;
|
||||||
|
|
||||||
|
let status = StatusCode::from_u16(response.status().as_u16())
|
||||||
|
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for (name, value) in response.headers() {
|
||||||
|
let name_str = name.as_str();
|
||||||
|
if name_str == "transfer-encoding"
|
||||||
|
|| name_str == "connection"
|
||||||
|
|| name_str == "content-length"
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(header_name) = axum::http::HeaderName::try_from(name.as_str())
|
||||||
|
&& let Ok(header_value) = axum::http::HeaderValue::try_from(value.as_bytes())
|
||||||
|
{
|
||||||
|
headers.insert(header_name, header_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = response.bytes().await.map_err(ProxyError::Network)?;
|
||||||
|
Ok((status, headers, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perform health check on Bun SSR and database
|
||||||
|
pub async fn perform_health_check(
|
||||||
|
downstream_url: String,
|
||||||
|
http_client: reqwest::Client,
|
||||||
|
unix_client: Option<reqwest::Client>,
|
||||||
|
pool: Option<sqlx::PgPool>,
|
||||||
|
) -> bool {
|
||||||
|
let url = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
|
||||||
|
"http://localhost/internal/health".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{downstream_url}/internal/health")
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = if unix_client.is_some() {
|
||||||
|
unix_client.as_ref().unwrap()
|
||||||
|
} else {
|
||||||
|
&http_client
|
||||||
|
};
|
||||||
|
|
||||||
|
let bun_healthy =
|
||||||
|
match tokio::time::timeout(Duration::from_secs(5), client.get(&url).send()).await {
|
||||||
|
Ok(Ok(response)) => {
|
||||||
|
let is_success = response.status().is_success();
|
||||||
|
if !is_success {
|
||||||
|
tracing::warn!(
|
||||||
|
status = response.status().as_u16(),
|
||||||
|
"Health check failed: Bun returned non-success status"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
is_success
|
||||||
|
}
|
||||||
|
Ok(Err(err)) => {
|
||||||
|
tracing::error!(error = %err, "Health check failed: cannot reach Bun");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::error!("Health check failed: timeout after 5s");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check database
|
||||||
|
let db_healthy = if let Some(pool) = pool {
|
||||||
|
match db::health_check(&pool).await {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(error = %err, "Database health check failed");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
};
|
||||||
|
|
||||||
|
bun_healthy && db_healthy
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if path should trigger tarpit
|
||||||
|
fn should_tarpit(state: &TarpitState, path: &str) -> bool {
|
||||||
|
state.config.enabled && tarpit::is_malicious_path(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback handler for TCP connections (has access to peer IP)
|
||||||
|
pub async fn fallback_handler_tcp(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
|
req: Request,
|
||||||
|
) -> Response {
|
||||||
|
let path = req.uri().path();
|
||||||
|
|
||||||
|
if should_tarpit(&state.tarpit_state, path) {
|
||||||
|
tarpit::tarpit_handler(
|
||||||
|
State(state.tarpit_state.clone()),
|
||||||
|
Some(ConnectInfo(peer)),
|
||||||
|
req,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
isr_handler(State(state), req).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fallback handler for Unix sockets (no peer IP available)
|
||||||
|
pub async fn fallback_handler_unix(State(state): State<Arc<AppState>>, req: Request) -> Response {
|
||||||
|
let path = req.uri().path();
|
||||||
|
|
||||||
|
if should_tarpit(&state.tarpit_state, path) {
|
||||||
|
tarpit::tarpit_handler(State(state.tarpit_state.clone()), None, req).await
|
||||||
|
} else {
|
||||||
|
isr_handler(State(state), req).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ impl R2Client {
|
|||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, String> {
|
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, String> {
|
||||||
let result = self
|
let result = self
|
||||||
.client
|
.client
|
||||||
@@ -93,7 +92,6 @@ impl R2Client {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn object_exists(&self, key: &str) -> bool {
|
pub async fn object_exists(&self, key: &str) -> bool {
|
||||||
self.client
|
self.client
|
||||||
.head_object()
|
.head_object()
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
use axum::{Router, extract::Request, http::Uri, response::IntoResponse, routing::any};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{assets, handlers, state::AppState};
|
||||||
|
|
||||||
|
/// Build API routes
|
||||||
|
pub fn api_routes() -> Router<Arc<AppState>> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", any(api_root_404_handler))
|
||||||
|
.route(
|
||||||
|
"/health",
|
||||||
|
axum::routing::get(handlers::health_handler).head(handlers::health_handler),
|
||||||
|
)
|
||||||
|
// Authentication endpoints (public)
|
||||||
|
.route("/login", axum::routing::post(handlers::api_login_handler))
|
||||||
|
.route("/logout", axum::routing::post(handlers::api_logout_handler))
|
||||||
|
.route(
|
||||||
|
"/session",
|
||||||
|
axum::routing::get(handlers::api_session_handler),
|
||||||
|
)
|
||||||
|
// Projects - GET is public (shows all for admin, only non-hidden for public)
|
||||||
|
// POST/PUT/DELETE require authentication
|
||||||
|
.route(
|
||||||
|
"/projects",
|
||||||
|
axum::routing::get(handlers::projects_handler).post(handlers::create_project_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}",
|
||||||
|
axum::routing::get(handlers::get_project_handler)
|
||||||
|
.put(handlers::update_project_handler)
|
||||||
|
.delete(handlers::delete_project_handler),
|
||||||
|
)
|
||||||
|
// Project tags - authentication checked in handlers
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/tags",
|
||||||
|
axum::routing::get(handlers::get_project_tags_handler)
|
||||||
|
.post(handlers::add_project_tag_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/projects/{id}/tags/{tag_id}",
|
||||||
|
axum::routing::delete(handlers::remove_project_tag_handler),
|
||||||
|
)
|
||||||
|
// Tags - authentication checked in handlers
|
||||||
|
.route(
|
||||||
|
"/tags",
|
||||||
|
axum::routing::get(handlers::list_tags_handler).post(handlers::create_tag_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/tags/{slug}",
|
||||||
|
axum::routing::get(handlers::get_tag_handler).put(handlers::update_tag_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/tags/{slug}/related",
|
||||||
|
axum::routing::get(handlers::get_related_tags_handler),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/tags/recalculate-cooccurrence",
|
||||||
|
axum::routing::post(handlers::recalculate_cooccurrence_handler),
|
||||||
|
)
|
||||||
|
// Admin stats - requires authentication
|
||||||
|
.route(
|
||||||
|
"/stats",
|
||||||
|
axum::routing::get(handlers::get_admin_stats_handler),
|
||||||
|
)
|
||||||
|
// Site settings - GET is public, PUT requires authentication
|
||||||
|
.route(
|
||||||
|
"/settings",
|
||||||
|
axum::routing::get(handlers::get_settings_handler)
|
||||||
|
.put(handlers::update_settings_handler),
|
||||||
|
)
|
||||||
|
// Icon API - proxy to SvelteKit (authentication handled by SvelteKit)
|
||||||
|
.route(
|
||||||
|
"/icons/{*path}",
|
||||||
|
axum::routing::get(handlers::proxy_icons_handler),
|
||||||
|
)
|
||||||
|
.fallback(api_404_and_method_handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build base router (shared routes for all listen addresses)
|
||||||
|
pub fn build_base_router() -> Router<Arc<AppState>> {
|
||||||
|
Router::new()
|
||||||
|
.nest("/api", api_routes())
|
||||||
|
.route("/api/", any(api_root_404_handler))
|
||||||
|
.route(
|
||||||
|
"/_app/{*path}",
|
||||||
|
axum::routing::get(assets::serve_embedded_asset).head(assets::serve_embedded_asset),
|
||||||
|
)
|
||||||
|
.route("/pgp", axum::routing::get(handlers::handle_pgp_route))
|
||||||
|
.route(
|
||||||
|
"/publickey.asc",
|
||||||
|
axum::routing::get(handlers::serve_pgp_key),
|
||||||
|
)
|
||||||
|
.route("/pgp.asc", axum::routing::get(handlers::serve_pgp_key))
|
||||||
|
.route(
|
||||||
|
"/.well-known/pgpkey.asc",
|
||||||
|
axum::routing::get(handlers::serve_pgp_key),
|
||||||
|
)
|
||||||
|
.route("/keys", axum::routing::get(handlers::redirect_to_pgp))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_root_404_handler(uri: Uri) -> impl IntoResponse {
|
||||||
|
api_404_handler(uri).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_404_and_method_handler(req: Request) -> impl IntoResponse {
|
||||||
|
use axum::{Json, http::StatusCode};
|
||||||
|
|
||||||
|
let method = req.method();
|
||||||
|
let uri = req.uri();
|
||||||
|
let path = uri.path();
|
||||||
|
|
||||||
|
if method != axum::http::Method::GET
|
||||||
|
&& method != axum::http::Method::HEAD
|
||||||
|
&& method != axum::http::Method::OPTIONS
|
||||||
|
{
|
||||||
|
let content_type = req
|
||||||
|
.headers()
|
||||||
|
.get(axum::http::header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok());
|
||||||
|
|
||||||
|
if let Some(ct) = content_type {
|
||||||
|
if !ct.starts_with("application/json") {
|
||||||
|
return (
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Unsupported media type",
|
||||||
|
"message": "API endpoints only accept application/json"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
} else if method == axum::http::Method::POST
|
||||||
|
|| method == axum::http::Method::PUT
|
||||||
|
|| method == axum::http::Method::PATCH
|
||||||
|
{
|
||||||
|
// POST/PUT/PATCH require Content-Type header
|
||||||
|
return (
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Missing Content-Type header",
|
||||||
|
"message": "Content-Type: application/json is required"
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route not found
|
||||||
|
tracing::warn!(path = %path, method = %method, "API route not found");
|
||||||
|
(
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"error": "Not found",
|
||||||
|
"path": path
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_404_handler(uri: Uri) -> impl IntoResponse {
|
||||||
|
let req = Request::builder()
|
||||||
|
.uri(uri)
|
||||||
|
.body(axum::body::Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
api_404_and_method_handler(req).await
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::{auth::SessionManager, health::HealthChecker, tarpit::TarpitState};
|
||||||
|
|
||||||
|
/// Application state shared across all handlers
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub downstream_url: String,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
|
pub unix_client: Option<reqwest::Client>,
|
||||||
|
pub health_checker: Arc<HealthChecker>,
|
||||||
|
pub tarpit_state: Arc<TarpitState>,
|
||||||
|
pub pool: sqlx::PgPool,
|
||||||
|
pub session_manager: Arc<SessionManager>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur during proxying to Bun
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ProxyError {
|
||||||
|
Network(reqwest::Error),
|
||||||
|
Other(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ProxyError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
ProxyError::Network(e) => write!(f, "Network error: {e}"),
|
||||||
|
ProxyError::Other(s) => write!(f, "{s}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ProxyError {}
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
use axum::{
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::assets;
|
||||||
|
|
||||||
|
/// Check if a path represents a static asset
|
||||||
|
pub fn is_static_asset(path: &str) -> bool {
|
||||||
|
path.starts_with("/node_modules/")
|
||||||
|
|| path.starts_with("/@") // Vite internals like /@vite/client, /@fs/, /@id/
|
||||||
|
|| path.starts_with("/.svelte-kit/")
|
||||||
|
|| path.starts_with("/.well-known/")
|
||||||
|
|| path.ends_with(".woff2")
|
||||||
|
|| path.ends_with(".woff")
|
||||||
|
|| path.ends_with(".ttf")
|
||||||
|
|| path.ends_with(".ico")
|
||||||
|
|| path.ends_with(".png")
|
||||||
|
|| path.ends_with(".jpg")
|
||||||
|
|| path.ends_with(".svg")
|
||||||
|
|| path.ends_with(".webp")
|
||||||
|
|| path.ends_with(".css")
|
||||||
|
|| path.ends_with(".js")
|
||||||
|
|| path.ends_with(".map")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a path represents a page route (not an asset)
|
||||||
|
pub fn is_page_route(path: &str) -> bool {
|
||||||
|
!path.starts_with("/node_modules/")
|
||||||
|
&& !path.starts_with("/@")
|
||||||
|
&& !path.starts_with("/.svelte-kit/")
|
||||||
|
&& !path.contains('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if the request accepts HTML responses
|
||||||
|
pub fn accepts_html(headers: &HeaderMap) -> bool {
|
||||||
|
if let Some(accept) = headers.get(axum::http::header::ACCEPT) {
|
||||||
|
if let Ok(accept_str) = accept.to_str() {
|
||||||
|
return accept_str.contains("text/html") || accept_str.contains("*/*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Default to true for requests without Accept header (browsers typically send it)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines if request prefers raw content (CLI tools) over HTML
|
||||||
|
pub fn prefers_raw_content(headers: &HeaderMap) -> bool {
|
||||||
|
// Check User-Agent for known CLI tools first (most reliable)
|
||||||
|
if let Some(ua) = headers.get(axum::http::header::USER_AGENT) {
|
||||||
|
if let Ok(ua_str) = ua.to_str() {
|
||||||
|
let ua_lower = ua_str.to_lowercase();
|
||||||
|
if ua_lower.starts_with("curl/")
|
||||||
|
|| ua_lower.starts_with("wget/")
|
||||||
|
|| ua_lower.starts_with("httpie/")
|
||||||
|
|| ua_lower.contains("curlie")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Accept header - if it explicitly prefers text/html, serve HTML
|
||||||
|
if let Some(accept) = headers.get(axum::http::header::ACCEPT) {
|
||||||
|
if let Ok(accept_str) = accept.to_str() {
|
||||||
|
// If text/html appears before */* in the list, they prefer HTML
|
||||||
|
if let Some(html_pos) = accept_str.find("text/html") {
|
||||||
|
if let Some(wildcard_pos) = accept_str.find("*/*") {
|
||||||
|
return html_pos > wildcard_pos;
|
||||||
|
}
|
||||||
|
// Has text/html but no */* → prefers HTML
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Has */* but no text/html → probably a CLI tool
|
||||||
|
if accept_str.contains("*/*") && !accept_str.contains("text/html") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Accept header → assume browser (safer default)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serve a prerendered error page for the given status code
|
||||||
|
pub fn serve_error_page(status: StatusCode) -> Response {
|
||||||
|
let status_code = status.as_u16();
|
||||||
|
|
||||||
|
if let Some(html) = assets::get_error_page(status_code) {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CACHE_CONTROL,
|
||||||
|
axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||||
|
);
|
||||||
|
|
||||||
|
(status, headers, html).into_response()
|
||||||
|
} else {
|
||||||
|
// Fallback for undefined error codes (500 generic page)
|
||||||
|
tracing::warn!(
|
||||||
|
status_code,
|
||||||
|
"No prerendered error page found for status code - using fallback"
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(fallback_html) = assets::get_error_page(500) {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CACHE_CONTROL,
|
||||||
|
axum::http::HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||||
|
);
|
||||||
|
|
||||||
|
(status, headers, fallback_html).into_response()
|
||||||
|
} else {
|
||||||
|
// Last resort: plaintext (should never happen if 500.html exists)
|
||||||
|
(status, format!("Error {}", status_code)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate hex color format (6 characters, no hash, no alpha)
|
||||||
|
pub fn validate_hex_color(color: &str) -> bool {
|
||||||
|
color.len() == 6 && color.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
|
}
|
||||||
+3
-1
@@ -170,7 +170,9 @@ html,
|
|||||||
body {
|
body {
|
||||||
@apply font-inter overflow-x-hidden;
|
@apply font-inter overflow-x-hidden;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
transition: background-color 0.3s ease-in-out, color 0.3s ease-in-out;
|
transition:
|
||||||
|
background-color 0.3s ease-in-out,
|
||||||
|
color 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
+7
-4
@@ -4,11 +4,14 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function () {
|
||||||
const stored = localStorage.getItem('theme');
|
const stored = localStorage.getItem("theme");
|
||||||
const isDark = stored === 'dark' || (stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
const isDark =
|
||||||
|
stored === "dark" ||
|
||||||
|
(stored !== "light" &&
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add("dark");
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+5
-7
@@ -16,10 +16,7 @@ import type {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// Client-side fetch wrapper for browser requests
|
// Client-side fetch wrapper for browser requests
|
||||||
async function clientApiFetch<T>(
|
async function clientApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
path: string,
|
|
||||||
init?: RequestInit,
|
|
||||||
): Promise<T> {
|
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
...init,
|
...init,
|
||||||
credentials: "same-origin", // Include cookies for auth
|
credentials: "same-origin", // Include cookies for auth
|
||||||
@@ -83,9 +80,10 @@ export async function deleteAdminProject(id: string): Promise<AdminProject> {
|
|||||||
|
|
||||||
// Admin Tags API
|
// Admin Tags API
|
||||||
export async function getAdminTags(): Promise<AdminTagWithCount[]> {
|
export async function getAdminTags(): Promise<AdminTagWithCount[]> {
|
||||||
const tags = await clientApiFetch<
|
const tags =
|
||||||
Array<AdminTag & { project_count: number }>
|
await clientApiFetch<Array<AdminTag & { project_count: number }>>(
|
||||||
>("/api/tags");
|
"/api/tags",
|
||||||
|
);
|
||||||
|
|
||||||
// Transform snake_case to camelCase
|
// Transform snake_case to camelCase
|
||||||
return tags.map((item) => ({
|
return tags.map((item) => ({
|
||||||
|
|||||||
@@ -19,9 +19,19 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn("pointer-events-none fixed inset-0 -z-20 bg-white dark:bg-black transition-colors duration-300", bgColor)}></div>
|
<div
|
||||||
|
class={cn(
|
||||||
|
"pointer-events-none fixed inset-0 -z-20 bg-white dark:bg-black transition-colors duration-300",
|
||||||
|
bgColor,
|
||||||
|
)}
|
||||||
|
></div>
|
||||||
<Dots class={[backgroundClass]} />
|
<Dots class={[backgroundClass]} />
|
||||||
<main class={cn("relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300", className)}>
|
<main
|
||||||
|
class={cn(
|
||||||
|
"relative min-h-screen text-zinc-900 dark:text-zinc-50 transition-colors duration-300",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{#if showThemeToggle}
|
{#if showThemeToggle}
|
||||||
<div class="absolute top-5 right-6 z-50">
|
<div class="absolute top-5 right-6 z-50">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|||||||
@@ -83,7 +83,10 @@
|
|||||||
transition:scale={{ duration: 200, start: 0.95 }}
|
transition:scale={{ duration: 200, start: 0.95 }}
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-4">
|
<div class="flex items-start justify-between mb-4">
|
||||||
<h2 id="pgp-modal-title" class="text-xl font-semibold text-zinc-900 dark:text-white">
|
<h2
|
||||||
|
id="pgp-modal-title"
|
||||||
|
class="text-xl font-semibold text-zinc-900 dark:text-white"
|
||||||
|
>
|
||||||
PGP Public Key
|
PGP Public Key
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@@ -109,17 +112,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fingerprint -->
|
<!-- Fingerprint -->
|
||||||
<div class="mb-4 p-3 bg-zinc-100 dark:bg-zinc-800 rounded border border-zinc-200 dark:border-zinc-700">
|
<div
|
||||||
|
class="mb-4 p-3 bg-zinc-100 dark:bg-zinc-800 rounded border border-zinc-200 dark:border-zinc-700"
|
||||||
|
>
|
||||||
<div class="text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">
|
<div class="text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">
|
||||||
Fingerprint
|
Fingerprint
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono text-sm text-zinc-900 dark:text-zinc-100 break-all">
|
<div
|
||||||
|
class="font-mono text-sm text-zinc-900 dark:text-zinc-100 break-all"
|
||||||
|
>
|
||||||
{PGP_KEY_METADATA.fingerprint}
|
{PGP_KEY_METADATA.fingerprint}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Key Content -->
|
<!-- Key Content -->
|
||||||
<div class="mb-4 border border-zinc-200 dark:border-zinc-700 rounded overflow-hidden">
|
<div
|
||||||
|
class="mb-4 border border-zinc-200 dark:border-zinc-700 rounded overflow-hidden"
|
||||||
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="p-4 text-center text-zinc-600 dark:text-zinc-400">
|
<div class="p-4 text-center text-zinc-600 dark:text-zinc-400">
|
||||||
Loading key...
|
Loading key...
|
||||||
@@ -156,7 +165,9 @@
|
|||||||
class="flex items-center gap-2 px-4 py-2 rounded-sm bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
class="flex items-center gap-2 px-4 py-2 rounded-sm bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||||
>
|
>
|
||||||
<IconCopy class="size-4" />
|
<IconCopy class="size-4" />
|
||||||
<span class="text-sm font-medium">{copySuccess ? "Copied!" : "Copy to Clipboard"}</span>
|
<span class="text-sm font-medium"
|
||||||
|
>{copySuccess ? "Copied!" : "Copy to Clipboard"}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
let { project, class: className }: Props = $props();
|
let { project, class: className }: Props = $props();
|
||||||
|
|
||||||
// Prefer demo URL, fallback to GitHub repo
|
// Prefer demo URL, fallback to GitHub repo
|
||||||
const projectUrl = project.demoUrl || (project.githubRepo ? `https://github.com/${project.githubRepo}` : null);
|
const projectUrl =
|
||||||
|
project.demoUrl ||
|
||||||
|
(project.githubRepo ? `https://github.com/${project.githubRepo}` : null);
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@@ -34,87 +36,95 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if projectUrl}
|
{#if projectUrl}
|
||||||
<a
|
<a
|
||||||
href={projectUrl}
|
href={projectUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class={cn(
|
class={cn(
|
||||||
"group flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800/70",
|
"group flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3 transition-all hover:border-zinc-300 dark:hover:border-zinc-700 hover:bg-zinc-100 dark:hover:bg-zinc-800/70",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<h3
|
<h3
|
||||||
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100 transition-colors group-hover:text-zinc-950 dark:group-hover:text-white"
|
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100 transition-colors group-hover:text-zinc-950 dark:group-hover:text-white"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{formatDate(project.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="line-clamp-3 sm:text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.shortDescription}
|
||||||
</h3>
|
</p>
|
||||||
<span class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300">
|
|
||||||
{formatDate(project.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="line-clamp-3 sm:text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
||||||
{project.shortDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto flex flex-wrap gap-1">
|
<div class="mt-auto flex flex-wrap gap-1">
|
||||||
{#each project.tags as tag (tag.name)}
|
{#each project.tags as tag (tag.name)}
|
||||||
<!-- TODO: Add link to project search with tag filtering -->
|
<!-- TODO: Add link to project search with tag filtering -->
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"
|
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"
|
||||||
style="border-left-color: #{tag.color || '06b6d4'}"
|
style="border-left-color: #{tag.color || '06b6d4'}"
|
||||||
>
|
>
|
||||||
{#if tag.iconSvg}
|
{#if tag.iconSvg}
|
||||||
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
|
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
{@html tag.iconSvg}
|
{@html tag.iconSvg}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{tag.name}</span>
|
<span>{tag.name}</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
"flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3",
|
"flex h-44 flex-col gap-2.5 rounded-lg border border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-zinc-900/50 p-3",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<h3
|
<h3
|
||||||
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100"
|
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<span
|
||||||
|
class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{formatDate(project.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="line-clamp-3 sm:text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.shortDescription}
|
||||||
</h3>
|
</p>
|
||||||
<span class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300">
|
|
||||||
{formatDate(project.updatedAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="line-clamp-3 sm:text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
|
|
||||||
{project.shortDescription}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-auto flex flex-wrap gap-1">
|
<div class="mt-auto flex flex-wrap gap-1">
|
||||||
{#each project.tags as tag (tag.name)}
|
{#each project.tags as tag (tag.name)}
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"
|
class="inline-flex items-center gap-1.25 rounded-r-sm rounded-l-xs bg-zinc-200/80 dark:bg-zinc-700/50 px-2 sm:px-1.5 py-1 sm:py-0.75 text-sm sm:text-xs text-zinc-700 dark:text-zinc-300 border-l-3"
|
||||||
style="border-left-color: #{tag.color || '06b6d4'}"
|
style="border-left-color: #{tag.color || '06b6d4'}"
|
||||||
>
|
>
|
||||||
{#if tag.iconSvg}
|
{#if tag.iconSvg}
|
||||||
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
|
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
{@html tag.iconSvg}
|
{@html tag.iconSvg}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{tag.name}</span>
|
<span>{tag.name}</span>
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => themeStore.toggle()}
|
onclick={() => themeStore.toggle()}
|
||||||
aria-label={themeStore.isDark ? "Switch to light mode" : "Switch to dark mode"}
|
aria-label={themeStore.isDark
|
||||||
|
? "Switch to light mode"
|
||||||
|
: "Switch to dark mode"}
|
||||||
class="relative size-9 rounded-md border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/50 hover:bg-zinc-200 dark:hover:bg-zinc-800/70 transition-all duration-200"
|
class="relative size-9 rounded-md border border-zinc-300 dark:border-zinc-700 bg-zinc-100 dark:bg-zinc-900/50 hover:bg-zinc-200 dark:hover:bg-zinc-800/70 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
|||||||
@@ -33,7 +33,8 @@
|
|||||||
"bg-transparent text-admin-text border border-admin-border hover:border-admin-border-hover hover:bg-admin-surface-hover/50 focus-visible:ring-admin-accent",
|
"bg-transparent text-admin-text border border-admin-border hover:border-admin-border-hover hover:bg-admin-surface-hover/50 focus-visible:ring-admin-accent",
|
||||||
danger:
|
danger:
|
||||||
"bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500 shadow-sm hover:shadow",
|
"bg-red-600 text-white hover:bg-red-500 focus-visible:ring-red-500 shadow-sm hover:shadow",
|
||||||
ghost: "text-admin-text hover:bg-admin-surface-hover focus-visible:ring-admin-accent",
|
ghost:
|
||||||
|
"text-admin-text hover:bg-admin-surface-hover focus-visible:ring-admin-accent",
|
||||||
};
|
};
|
||||||
|
|
||||||
const sizeStyles = {
|
const sizeStyles = {
|
||||||
|
|||||||
@@ -117,7 +117,8 @@
|
|||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-admin-text-muted"
|
<span
|
||||||
|
class="absolute left-3 top-1/2 -translate-y-1/2 text-admin-text-muted"
|
||||||
>#</span
|
>#</span
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
{event.message}
|
{event.message}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-admin-text-muted shrink-0">
|
<span class="text-admin-text-muted shrink-0">
|
||||||
target=<span class="text-admin-text-secondary">{event.target}</span>
|
target=<span class="text-admin-text-secondary"
|
||||||
|
>{event.target}</span
|
||||||
|
>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
@@ -88,7 +90,8 @@
|
|||||||
class="bg-admin-surface border border-admin-border rounded p-3 text-[11px]"
|
class="bg-admin-surface border border-admin-border rounded p-3 text-[11px]"
|
||||||
>
|
>
|
||||||
<p class="text-admin-text-muted mb-2 font-medium">Metadata:</p>
|
<p class="text-admin-text-muted mb-2 font-medium">Metadata:</p>
|
||||||
<pre class="text-admin-text-secondary overflow-x-auto">{JSON.stringify(
|
<pre
|
||||||
|
class="text-admin-text-secondary overflow-x-auto">{JSON.stringify(
|
||||||
event.metadata,
|
event.metadata,
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -211,7 +211,9 @@
|
|||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
{@html selectedIconSvg}
|
{@html selectedIconSvg}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="size-6 animate-pulse rounded bg-admin-surface-hover"></div>
|
<div
|
||||||
|
class="size-6 animate-pulse rounded bg-admin-surface-hover"
|
||||||
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|||||||
@@ -78,10 +78,14 @@
|
|||||||
>
|
>
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="border-b border-admin-border px-4 py-5 flex items-center justify-between">
|
<div
|
||||||
|
class="border-b border-admin-border px-4 py-5 flex items-center justify-between"
|
||||||
|
>
|
||||||
<h1 class="text-base font-semibold text-admin-text">
|
<h1 class="text-base font-semibold text-admin-text">
|
||||||
xevion.dev
|
xevion.dev
|
||||||
<span class="text-xs font-normal text-admin-text-muted ml-1.5">Admin</span>
|
<span class="text-xs font-normal text-admin-text-muted ml-1.5"
|
||||||
|
>Admin</span
|
||||||
|
>
|
||||||
</h1>
|
</h1>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +114,9 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Bottom actions -->
|
<!-- Bottom actions -->
|
||||||
<div class="space-y-0.5 border-t border-admin-border bg-admin-surface/50 p-3">
|
<div
|
||||||
|
class="space-y-0.5 border-t border-admin-border bg-admin-surface/50 p-3"
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-admin-text-muted transition-all hover:text-admin-text hover:bg-admin-surface-hover/50"
|
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-admin-text-muted transition-all hover:text-admin-text hover:bg-admin-surface-hover/50"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export const PGP_KEY_METADATA = {
|
export const PGP_KEY_METADATA = {
|
||||||
fingerprint: '211D 7157 249B F07D 81C8 B9DE C217 005C F3C0 0672',
|
fingerprint: "211D 7157 249B F07D 81C8 B9DE C217 005C F3C0 0672",
|
||||||
keyId: 'C217005CF3C00672',
|
keyId: "C217005CF3C00672",
|
||||||
email: 'xevion@xevion.dev',
|
email: "xevion@xevion.dev",
|
||||||
name: 'Ryan Walters',
|
name: "Ryan Walters",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -34,10 +34,13 @@
|
|||||||
class="max-w-2xl mx-4 border-b border-zinc-200 dark:border-zinc-700 divide-y divide-zinc-200 dark:divide-zinc-700 sm:mx-6"
|
class="max-w-2xl mx-4 border-b border-zinc-200 dark:border-zinc-700 divide-y divide-zinc-200 dark:divide-zinc-700 sm:mx-6"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col pb-4">
|
<div class="flex flex-col pb-4">
|
||||||
<span class="text-2xl font-bold text-zinc-900 dark:text-white sm:text-3xl"
|
<span
|
||||||
|
class="text-2xl font-bold text-zinc-900 dark:text-white sm:text-3xl"
|
||||||
>{settings.identity.displayName},</span
|
>{settings.identity.displayName},</span
|
||||||
>
|
>
|
||||||
<span class="text-xl font-normal text-zinc-600 dark:text-zinc-400 sm:text-2xl">
|
<span
|
||||||
|
class="text-xl font-normal text-zinc-600 dark:text-zinc-400 sm:text-2xl"
|
||||||
|
>
|
||||||
{settings.identity.occupation}
|
{settings.identity.occupation}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,7 +64,8 @@
|
|||||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
||||||
{@html link.iconSvg}
|
{@html link.iconSvg}
|
||||||
</span>
|
</span>
|
||||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
<span
|
||||||
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>{link.label}</span
|
>{link.label}</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
@@ -75,7 +79,8 @@
|
|||||||
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
<span class="size-4 text-zinc-600 dark:text-zinc-300">
|
||||||
{@html link.iconSvg}
|
{@html link.iconSvg}
|
||||||
</span>
|
</span>
|
||||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
<span
|
||||||
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>{link.label}</span
|
>{link.label}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@@ -88,7 +93,8 @@
|
|||||||
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
|
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
|
||||||
{@html link.iconSvg}
|
{@html link.iconSvg}
|
||||||
</span>
|
</span>
|
||||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
<span
|
||||||
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
>{link.label}</span
|
>{link.label}</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
@@ -100,8 +106,13 @@
|
|||||||
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
class="flex items-center gap-x-1.5 px-1.5 py-1 rounded-sm bg-zinc-100 dark:bg-zinc-900 shadow-sm hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors"
|
||||||
onclick={() => (pgpModalOpen = true)}
|
onclick={() => (pgpModalOpen = true)}
|
||||||
>
|
>
|
||||||
<MaterialSymbolsVpnKey class="size-4.5 text-zinc-600 dark:text-zinc-300" />
|
<MaterialSymbolsVpnKey
|
||||||
<span class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100">PGP Key</span>
|
class="size-4.5 text-zinc-600 dark:text-zinc-300"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="whitespace-nowrap text-sm text-zinc-800 dark:text-zinc-100"
|
||||||
|
>PGP Key</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -63,7 +63,9 @@
|
|||||||
<div
|
<div
|
||||||
class="flex items-center justify-between px-6 py-3.5 bg-admin-surface-hover/30 border-b border-admin-border"
|
class="flex items-center justify-between px-6 py-3.5 bg-admin-surface-hover/30 border-b border-admin-border"
|
||||||
>
|
>
|
||||||
<h2 class="text-sm font-medium text-admin-text-secondary">Recent Events</h2>
|
<h2 class="text-sm font-medium text-admin-text-secondary">
|
||||||
|
Recent Events
|
||||||
|
</h2>
|
||||||
<a
|
<a
|
||||||
href={resolve("/admin/events")}
|
href={resolve("/admin/events")}
|
||||||
class="text-sm text-admin-accent hover:text-admin-accent-hover transition-colors"
|
class="text-sm text-admin-accent hover:text-admin-accent-hover transition-colors"
|
||||||
@@ -73,7 +75,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if recentEvents.length === 0}
|
{#if recentEvents.length === 0}
|
||||||
<p class="text-sm text-admin-text-muted text-center py-8">No events yet</p>
|
<p class="text-sm text-admin-text-muted text-center py-8">
|
||||||
|
No events yet
|
||||||
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<EventLog events={recentEvents} maxHeight="400px" />
|
<EventLog events={recentEvents} maxHeight="400px" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
<div
|
<div
|
||||||
class="rounded-xl border border-admin-border bg-admin-surface/50 overflow-hidden shadow-sm shadow-black/10 dark:shadow-black/20"
|
class="rounded-xl border border-admin-border bg-admin-surface/50 overflow-hidden shadow-sm shadow-black/10 dark:shadow-black/20"
|
||||||
>
|
>
|
||||||
<div class="px-6 py-3.5 bg-admin-surface-hover/30 border-b border-admin-border">
|
<div
|
||||||
|
class="px-6 py-3.5 bg-admin-surface-hover/30 border-b border-admin-border"
|
||||||
|
>
|
||||||
<h2 class="text-sm font-medium text-admin-text-secondary">
|
<h2 class="text-sm font-medium text-admin-text-secondary">
|
||||||
Event Log
|
Event Log
|
||||||
<span class="text-admin-text-muted font-normal ml-2">
|
<span class="text-admin-text-muted font-normal ml-2">
|
||||||
|
|||||||
@@ -43,7 +43,9 @@
|
|||||||
<div class="flex min-h-screen items-center justify-center px-4">
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
<div class="w-full max-w-md space-y-4">
|
<div class="w-full max-w-md space-y-4">
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<div class="rounded-lg bg-admin-surface border border-admin-border p-8 shadow-2xl shadow-black/10 dark:shadow-zinc-500/20">
|
<div
|
||||||
|
class="rounded-lg bg-admin-surface border border-admin-border p-8 shadow-2xl shadow-black/10 dark:shadow-zinc-500/20"
|
||||||
|
>
|
||||||
<form onsubmit={handleSubmit} class="space-y-6">
|
<form onsubmit={handleSubmit} class="space-y-6">
|
||||||
<Input
|
<Input
|
||||||
label="Username"
|
label="Username"
|
||||||
|
|||||||
@@ -84,7 +84,9 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-semibold text-admin-text">Projects</h1>
|
<h1 class="text-xl font-semibold text-admin-text">Projects</h1>
|
||||||
<p class="mt-1 text-sm text-admin-text-muted">Manage your project portfolio</p>
|
<p class="mt-1 text-sm text-admin-text-muted">
|
||||||
|
Manage your project portfolio
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="primary" href="/admin/projects/new">
|
<Button variant="primary" href="/admin/projects/new">
|
||||||
<IconPlus class="w-4 h-4 mr-2" />
|
<IconPlus class="w-4 h-4 mr-2" />
|
||||||
@@ -94,7 +96,9 @@
|
|||||||
|
|
||||||
<!-- Projects Table -->
|
<!-- Projects Table -->
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="text-center py-12 text-admin-text-muted">Loading projects...</div>
|
<div class="text-center py-12 text-admin-text-muted">
|
||||||
|
Loading projects...
|
||||||
|
</div>
|
||||||
{:else if projects.length === 0}
|
{:else if projects.length === 0}
|
||||||
<div class="text-center py-12">
|
<div class="text-center py-12">
|
||||||
<p class="text-admin-text-muted mb-4">No projects yet</p>
|
<p class="text-admin-text-muted mb-4">No projects yet</p>
|
||||||
@@ -106,19 +110,29 @@
|
|||||||
<Table>
|
<Table>
|
||||||
<thead class="bg-admin-surface/50">
|
<thead class="bg-admin-surface/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Tags
|
Tags
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Updated
|
Updated
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -192,7 +206,9 @@
|
|||||||
oncancel={cancelDelete}
|
oncancel={cancelDelete}
|
||||||
>
|
>
|
||||||
{#if deleteTarget}
|
{#if deleteTarget}
|
||||||
<div class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3">
|
<div
|
||||||
|
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
|
||||||
|
>
|
||||||
<p class="font-medium text-admin-text">{deleteTarget.name}</p>
|
<p class="font-medium text-admin-text">{deleteTarget.name}</p>
|
||||||
<p class="text-sm text-admin-text-secondary">{deleteTarget.slug}</p>
|
<p class="text-sm text-admin-text-secondary">{deleteTarget.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -257,7 +257,11 @@
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" onclick={handleSave} disabled={!hasChanges || saving}>
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onclick={handleSave}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
>
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -231,19 +231,29 @@
|
|||||||
<Table>
|
<Table>
|
||||||
<thead class="bg-admin-surface/50">
|
<thead class="bg-admin-surface/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Slug
|
Slug
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Color
|
Color
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-left text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Projects
|
Projects
|
||||||
</th>
|
</th>
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted">
|
<th
|
||||||
|
class="px-4 py-3 text-right text-xs font-medium text-admin-text-muted"
|
||||||
|
>
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -274,7 +284,9 @@
|
|||||||
class="size-6 rounded border border-admin-border"
|
class="size-6 rounded border border-admin-border"
|
||||||
style="background-color: #{editColor}"
|
style="background-color: #{editColor}"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-admin-text-muted">#{editColor}</span>
|
<span class="text-xs text-admin-text-muted"
|
||||||
|
>#{editColor}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs text-admin-text-muted">No color</span>
|
<span class="text-xs text-admin-text-muted">No color</span>
|
||||||
@@ -318,7 +330,9 @@
|
|||||||
class="size-6 rounded border border-admin-border"
|
class="size-6 rounded border border-admin-border"
|
||||||
style="background-color: #{tag.color}"
|
style="background-color: #{tag.color}"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-admin-text-muted">#{tag.color}</span>
|
<span class="text-xs text-admin-text-muted"
|
||||||
|
>#{tag.color}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-xs text-admin-text-muted">No color</span>
|
<span class="text-xs text-admin-text-muted">No color</span>
|
||||||
@@ -364,7 +378,9 @@
|
|||||||
oncancel={cancelDelete}
|
oncancel={cancelDelete}
|
||||||
>
|
>
|
||||||
{#if deleteTarget}
|
{#if deleteTarget}
|
||||||
<div class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3">
|
<div
|
||||||
|
class="rounded-md bg-admin-surface-hover/50 border border-admin-border p-3"
|
||||||
|
>
|
||||||
<p class="font-medium text-admin-text">{deleteTarget.name}</p>
|
<p class="font-medium text-admin-text">{deleteTarget.name}</p>
|
||||||
<p class="text-sm text-admin-text-secondary">
|
<p class="text-sm text-admin-text-secondary">
|
||||||
Used in {deleteTarget.projectCount} project{deleteTarget.projectCount ===
|
Used in {deleteTarget.projectCount} project{deleteTarget.projectCount ===
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { PGP_KEY_METADATA } from '$lib/pgp/key-info';
|
import { PGP_KEY_METADATA } from "$lib/pgp/key-info";
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from "fs";
|
||||||
import { join } from 'path';
|
import { join } from "path";
|
||||||
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
export const load = () => {
|
export const load = () => {
|
||||||
// Read the PGP key from static directory at build time
|
// Read the PGP key from static directory at build time
|
||||||
const keyPath = join(process.cwd(), 'static', 'publickey.asc');
|
const keyPath = join(process.cwd(), "static", "publickey.asc");
|
||||||
const content = readFileSync(keyPath, 'utf-8');
|
const content = readFileSync(keyPath, "utf-8");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: {
|
key: {
|
||||||
|
|||||||
@@ -26,7 +26,9 @@
|
|||||||
|
|
||||||
async function copyCommand() {
|
async function copyCommand() {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText("curl https://xevion.dev/pgp | gpg --import");
|
await navigator.clipboard.writeText(
|
||||||
|
"curl https://xevion.dev/pgp | gpg --import",
|
||||||
|
);
|
||||||
copyCommandSuccess = true;
|
copyCommandSuccess = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
copyCommandSuccess = false;
|
copyCommandSuccess = false;
|
||||||
@@ -48,7 +50,10 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>PGP Public Key - Ryan Walters</title>
|
<title>PGP Public Key - Ryan Walters</title>
|
||||||
<meta name="description" content="Download or copy Ryan Walters' PGP public key" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Download or copy Ryan Walters' PGP public key"
|
||||||
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<AppWrapper class="overflow-x-hidden font-schibsted">
|
<AppWrapper class="overflow-x-hidden font-schibsted">
|
||||||
@@ -56,11 +61,14 @@
|
|||||||
<div class="max-w-2xl w-full">
|
<div class="max-w-2xl w-full">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white mb-2">
|
<h1
|
||||||
|
class="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white mb-2"
|
||||||
|
>
|
||||||
PGP Public Key
|
PGP Public Key
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm sm:text-base text-zinc-600 dark:text-zinc-400">
|
<p class="text-sm sm:text-base text-zinc-600 dark:text-zinc-400">
|
||||||
Use this key to send me encrypted messages or verify my signed content.
|
Use this key to send me encrypted messages or verify my signed
|
||||||
|
content.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,13 +76,19 @@
|
|||||||
<div
|
<div
|
||||||
class="mb-6 p-3 sm:p-4 bg-zinc-100 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700"
|
class="mb-6 p-3 sm:p-4 bg-zinc-100 dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700"
|
||||||
>
|
>
|
||||||
<div class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">
|
<div
|
||||||
|
class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2"
|
||||||
|
>
|
||||||
Key Fingerprint
|
Key Fingerprint
|
||||||
</div>
|
</div>
|
||||||
<div class="font-mono text-sm sm:text-base text-zinc-900 dark:text-zinc-100 break-all">
|
<div
|
||||||
|
class="font-mono text-sm sm:text-base text-zinc-900 dark:text-zinc-100 break-all"
|
||||||
|
>
|
||||||
{data.key.fingerprint}
|
{data.key.fingerprint}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700 space-y-1">
|
<div
|
||||||
|
class="mt-3 pt-3 border-t border-zinc-200 dark:border-zinc-700 space-y-1"
|
||||||
|
>
|
||||||
<div class="text-xs sm:text-sm text-zinc-600 dark:text-zinc-400">
|
<div class="text-xs sm:text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
<span class="font-medium">Key ID:</span>
|
<span class="font-medium">Key ID:</span>
|
||||||
<span class="font-mono ml-2">{data.key.keyId}</span>
|
<span class="font-mono ml-2">{data.key.keyId}</span>
|
||||||
@@ -90,8 +104,12 @@
|
|||||||
<div
|
<div
|
||||||
class="mb-6 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
|
class="mb-6 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden bg-white dark:bg-zinc-900"
|
||||||
>
|
>
|
||||||
<div class="px-3 sm:px-4 py-2 sm:py-3 bg-zinc-50 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
|
<div
|
||||||
<div class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300">
|
class="px-3 sm:px-4 py-2 sm:py-3 bg-zinc-50 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300"
|
||||||
|
>
|
||||||
Public Key
|
Public Key
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +121,8 @@
|
|||||||
style="max-height: 400px"
|
style="max-height: 400px"
|
||||||
>
|
>
|
||||||
<pre
|
<pre
|
||||||
class="p-3 sm:p-4 text-xs font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-900/50 overflow-x-auto">{data.key.content}</pre>
|
class="p-3 sm:p-4 text-xs font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-900/50 overflow-x-auto">{data
|
||||||
|
.key.content}</pre>
|
||||||
</OverlayScrollbarsComponent>
|
</OverlayScrollbarsComponent>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -114,7 +133,9 @@
|
|||||||
class="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-sm bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors shadow-sm"
|
class="flex items-center justify-center gap-2 px-3 sm:px-4 py-2 sm:py-2.5 rounded-sm bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors shadow-sm"
|
||||||
>
|
>
|
||||||
<IconCopy class="size-4 sm:size-5" />
|
<IconCopy class="size-4 sm:size-5" />
|
||||||
<span class="text-sm sm:text-base font-medium">{copySuccess ? "Copied!" : "Copy to Clipboard"}</span>
|
<span class="text-sm sm:text-base font-medium"
|
||||||
|
>{copySuccess ? "Copied!" : "Copy to Clipboard"}</span
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={downloadKey}
|
onclick={downloadKey}
|
||||||
@@ -126,31 +147,44 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Additional Info -->
|
<!-- Additional Info -->
|
||||||
<div class="mt-8 p-3 sm:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
<div
|
||||||
<h2 class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">
|
class="mt-8 p-3 sm:p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-lg border border-zinc-200 dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2"
|
||||||
|
>
|
||||||
How to use this key
|
How to use this key
|
||||||
</h2>
|
</h2>
|
||||||
<div class="text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 space-y-2">
|
<div
|
||||||
|
class="text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 space-y-2"
|
||||||
|
>
|
||||||
<p>
|
<p>
|
||||||
Import this key into your GPG keyring to encrypt messages for me or verify my signatures:
|
Import this key into your GPG keyring to encrypt messages for me or
|
||||||
|
verify my signatures:
|
||||||
</p>
|
</p>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<pre class="p-2 sm:p-3 pr-12 bg-white dark:bg-zinc-900 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-xs overflow-x-auto">curl https://xevion.dev/pgp | gpg --import</pre>
|
<pre
|
||||||
|
class="p-2 sm:p-3 pr-12 bg-white dark:bg-zinc-900 rounded border border-zinc-200 dark:border-zinc-700 font-mono text-xs overflow-x-auto">curl https://xevion.dev/pgp | gpg --import</pre>
|
||||||
<button
|
<button
|
||||||
onclick={copyCommand}
|
onclick={copyCommand}
|
||||||
disabled={copyCommandSuccess}
|
disabled={copyCommandSuccess}
|
||||||
class="absolute top-1/2 -translate-y-1/2 right-2 p-1 rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 transition-all {copyCommandSuccess ? 'cursor-default' : 'cursor-pointer'}"
|
class="absolute top-1/2 -translate-y-1/2 right-2 p-1 rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-500 transition-all {copyCommandSuccess
|
||||||
|
? 'cursor-default'
|
||||||
|
: 'cursor-pointer'}"
|
||||||
title={copyCommandSuccess ? "Copied!" : "Copy command"}
|
title={copyCommandSuccess ? "Copied!" : "Copy command"}
|
||||||
>
|
>
|
||||||
{#if copyCommandSuccess}
|
{#if copyCommandSuccess}
|
||||||
<IconCheck class="size-3.5 text-green-600 dark:text-green-500" />
|
<IconCheck
|
||||||
|
class="size-3.5 text-green-600 dark:text-green-500"
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<IconCopy class="size-3.5 text-zinc-600 dark:text-zinc-400" />
|
<IconCopy class="size-3.5 text-zinc-600 dark:text-zinc-400" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-zinc-500 dark:text-zinc-500">
|
<p class="text-xs text-zinc-500 dark:text-zinc-500">
|
||||||
You can also find this key on public keyservers by searching for the fingerprint above.
|
You can also find this key on public keyservers by searching for the
|
||||||
|
fingerprint above.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user