refactor: reorganize Rust codebase into modular handlers and database layers

- Split monolithic src/db.rs (1122 lines) into domain modules: projects, tags, settings
- Extract API handlers from main.rs into separate handler modules by domain
- Add proxy module for ISR/SSR coordination with Bun process
- Introduce AppState for shared application context
- Add utility functions for asset serving and request classification
- Remove obsolete middleware/auth.rs in favor of session checks in handlers
This commit is contained in:
2026-01-07 13:55:23 -06:00
parent 4663b00942
commit cf599d09d6
45 changed files with 3525 additions and 3326 deletions
+23
View File
@@ -241,3 +241,26 @@ pub async fn ensure_admin_user(pool: &PgPool) -> Result<(), Box<dyn std::error::
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"
})),
)
}
-1122
View File
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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,
}
+425
View File
@@ -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,
})
}
+220
View File
@@ -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
View File
@@ -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())
}
+102
View File
@@ -0,0 +1,102 @@
use axum::{
Json,
extract::{Request, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use crate::{assets, proxy, state::AppState, utils};
/// Serve PGP public key
pub async fn serve_pgp_key() -> impl IntoResponse {
if let Some(content) = assets::get_static_file("publickey.asc") {
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::CONTENT_TYPE,
axum::http::HeaderValue::from_static("application/pgp-keys"),
);
headers.insert(
axum::http::header::CONTENT_DISPOSITION,
axum::http::HeaderValue::from_static("attachment; filename=\"publickey.asc\""),
);
headers.insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("public, max-age=86400"),
);
(StatusCode::OK, headers, content).into_response()
} else {
(StatusCode::NOT_FOUND, "PGP key not found").into_response()
}
}
/// Redirect /keys to /pgp
pub async fn redirect_to_pgp() -> impl IntoResponse {
axum::response::Redirect::permanent("/pgp")
}
/// Handle /pgp route - serve HTML page or raw key based on User-Agent
pub async fn handle_pgp_route(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
req: Request,
) -> Response {
if utils::prefers_raw_content(&headers) {
// Serve raw .asc file for CLI tools
serve_pgp_key().await.into_response()
} else {
// Proxy to Bun for HTML page
proxy::isr_handler(State(state), req).await
}
}
/// Proxy icon requests to SvelteKit
pub async fn proxy_icons_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
axum::extract::Path(path): axum::extract::Path<String>,
req: Request,
) -> impl IntoResponse {
let full_path = format!("/api/icons/{}", path);
let query = req.uri().query().unwrap_or("");
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
{
if query.is_empty() {
format!("http://localhost{}", full_path)
} else {
format!("http://localhost{}?{}", full_path, query)
}
} else if query.is_empty() {
format!("{}{}", state.downstream_url, full_path)
} else {
format!("{}{}?{}", state.downstream_url, full_path, query)
};
// Build trusted headers with session info
let mut forward_headers = HeaderMap::new();
if let Some(cookie) = jar.get("admin_session") {
if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) {
if let Some(session) = state.session_manager.validate_session(session_id) {
if let Ok(username_value) = axum::http::HeaderValue::from_str(&session.username) {
forward_headers.insert("x-session-user", username_value);
}
}
}
}
match proxy::proxy_to_bun(&bun_url, state, forward_headers).await {
Ok((status, headers, body)) => (status, headers, body).into_response(),
Err(err) => {
tracing::error!(error = %err, path = %full_path, "Failed to proxy icon request");
(
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({
"error": "Failed to fetch icon data"
})),
)
.into_response()
}
}
}
+166
View File
@@ -0,0 +1,166 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{auth, state::AppState};
#[derive(serde::Deserialize)]
pub struct LoginRequest {
pub username: String,
pub password: String,
}
#[derive(serde::Serialize)]
pub struct LoginResponse {
pub success: bool,
pub username: String,
}
#[derive(serde::Serialize)]
pub struct SessionResponse {
pub authenticated: bool,
pub username: String,
}
/// Login handler - creates a new session
pub async fn api_login_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<LoginRequest>,
) -> Result<(axum_extra::extract::CookieJar, Json<LoginResponse>), impl IntoResponse> {
let user = match auth::get_admin_user(&state.pool, &payload.username).await {
Ok(Some(user)) => user,
Ok(None) => {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid credentials",
"message": "Username or password incorrect"
})),
));
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch admin user");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to authenticate"
})),
));
}
};
let password_valid = match auth::verify_password(&payload.password, &user.password_hash) {
Ok(valid) => valid,
Err(err) => {
tracing::error!(error = %err, "Failed to verify password");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to authenticate"
})),
));
}
};
if !password_valid {
return Err((
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Invalid credentials",
"message": "Username or password incorrect"
})),
));
}
let session = match state
.session_manager
.create_session(user.id, user.username.clone())
.await
{
Ok(session) => session,
Err(err) => {
tracing::error!(error = %err, "Failed to create session");
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create session"
})),
));
}
};
let cookie =
axum_extra::extract::cookie::Cookie::build(("admin_session", session.id.to_string()))
.path("/")
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Lax)
.max_age(time::Duration::days(7))
.build();
let jar = jar.add(cookie);
tracing::info!(username = %user.username, "User logged in");
Ok((
jar,
Json(LoginResponse {
success: true,
username: user.username,
}),
))
}
/// Logout handler - deletes the session
pub async fn api_logout_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> (axum_extra::extract::CookieJar, StatusCode) {
if let Some(cookie) = jar.get("admin_session") {
if let Ok(session_id) = ulid::Ulid::from_string(cookie.value()) {
if let Err(e) = state.session_manager.delete_session(session_id).await {
tracing::error!(error = %e, "Failed to delete session during logout");
}
}
}
let cookie = axum_extra::extract::cookie::Cookie::build(("admin_session", ""))
.path("/")
.max_age(time::Duration::ZERO)
.build();
(jar.add(cookie), StatusCode::OK)
}
/// Session check handler - returns current session status
pub async fn api_session_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
let session_cookie = jar.get("admin_session");
let session_id = session_cookie.and_then(|cookie| ulid::Ulid::from_string(cookie.value()).ok());
let session = session_id.and_then(|id| state.session_manager.validate_session(id));
match session {
Some(session) => (
StatusCode::OK,
Json(SessionResponse {
authenticated: true,
username: session.username,
}),
)
.into_response(),
None => (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": "Unauthorized",
"message": "No valid session"
})),
)
.into_response(),
}
}
+15
View File
@@ -0,0 +1,15 @@
use axum::{extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::state::AppState;
/// Health check endpoint - returns 200 if both DB and Bun are healthy
pub async fn health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let healthy = state.health_checker.check().await;
if healthy {
(StatusCode::OK, "OK")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "Unhealthy")
}
}
+35
View File
@@ -0,0 +1,35 @@
pub mod assets;
pub mod auth;
pub mod health;
pub mod projects;
pub mod settings;
pub mod tags;
// Re-export handlers for easier imports
pub use assets::*;
pub use auth::*;
pub use health::*;
pub use projects::*;
pub use settings::*;
pub use tags::*;
// Request/Response types used by handlers
#[derive(serde::Deserialize)]
pub struct CreateTagRequest {
pub name: String,
pub slug: Option<String>,
pub color: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct UpdateTagRequest {
pub name: String,
pub slug: Option<String>,
pub color: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct AddProjectTagRequest {
pub tag_id: String,
}
+677
View File
@@ -0,0 +1,677 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{auth, db, handlers::AddProjectTagRequest, state::AppState};
/// List all projects - returns filtered data based on auth status
pub async fn projects_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
let is_admin = auth::check_session(&state, &jar).is_some();
if is_admin {
// Admin view: return all projects with tags
match db::get_all_projects_with_tags_admin(&state.pool).await {
Ok(projects_with_tags) => {
let response: Vec<db::ApiAdminProject> = projects_with_tags
.into_iter()
.map(|(project, tags)| project.to_api_admin_project(tags))
.collect();
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch admin projects");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
}
} else {
// Public view: return non-hidden projects with tags
match db::get_public_projects_with_tags(&state.pool).await {
Ok(projects_with_tags) => {
let response: Vec<db::ApiAdminProject> = projects_with_tags
.into_iter()
.map(|(project, tags)| project.to_api_admin_project(tags))
.collect();
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch public projects");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
}
}
}
/// Get a single project by ID
pub async fn get_project_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let is_admin = auth::check_session(&state, &jar).is_some();
match db::get_project_by_id_with_tags(&state.pool, project_id).await {
Ok(Some((project, tags))) => {
// If project is hidden and user is not admin, return 404
if project.status == db::ProjectStatus::Hidden && !is_admin {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Json(project.to_api_admin_project(tags)).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch project"
})),
)
.into_response()
}
}
}
/// Create a new project (requires authentication)
pub async fn create_project_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::CreateProjectRequest>,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Validate request
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project name cannot be empty"
})),
)
.into_response();
}
if payload.short_description.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project short description cannot be empty"
})),
)
.into_response();
}
// Parse tag UUIDs
let tag_ids: Result<Vec<uuid::Uuid>, _> = payload
.tag_ids
.iter()
.map(|id| uuid::Uuid::parse_str(id))
.collect();
let tag_ids = match tag_ids {
Ok(ids) => ids,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid tag UUID format"
})),
)
.into_response();
}
};
// Create project
let project = match db::create_project(
&state.pool,
&payload.name,
payload.slug.as_deref(),
&payload.short_description,
&payload.description,
payload.status,
payload.github_repo.as_deref(),
payload.demo_url.as_deref(),
)
.await
{
Ok(p) => p,
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A project with this slug already exists"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to create project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create project"
})),
)
.into_response();
}
};
// Set tags
if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await {
tracing::error!(error = %err, project_id = %project.id, "Failed to set project tags");
}
// Fetch project with tags to return
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await {
Ok(Some(data)) => data,
Ok(None) => {
tracing::error!(project_id = %project.id, "Project not found after creation");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch created project"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, project_id = %project.id, "Failed to fetch created project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch created project"
})),
)
.into_response();
}
};
tracing::info!(project_id = %project.id, project_name = %project.name, "Project created");
(
StatusCode::CREATED,
Json(project.to_api_admin_project(tags)),
)
.into_response()
}
/// Update an existing project (requires authentication)
pub async fn update_project_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateProjectRequest>,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Parse project ID
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Validate exists
if db::get_project_by_id(&state.pool, project_id)
.await
.ok()
.flatten()
.is_none()
{
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
// Validate request
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project name cannot be empty"
})),
)
.into_response();
}
if payload.short_description.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Project short description cannot be empty"
})),
)
.into_response();
}
// Parse tag UUIDs
let tag_ids: Result<Vec<uuid::Uuid>, _> = payload
.tag_ids
.iter()
.map(|id| uuid::Uuid::parse_str(id))
.collect();
let tag_ids = match tag_ids {
Ok(ids) => ids,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid tag UUID format"
})),
)
.into_response();
}
};
// Update project
let project = match db::update_project(
&state.pool,
project_id,
&payload.name,
payload.slug.as_deref(),
&payload.short_description,
&payload.description,
payload.status,
payload.github_repo.as_deref(),
payload.demo_url.as_deref(),
)
.await
{
Ok(p) => p,
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A project with this slug already exists"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to update project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update project"
})),
)
.into_response();
}
};
// Update tags (smart diff)
if let Err(err) = db::set_project_tags(&state.pool, project.id, &tag_ids).await {
tracing::error!(error = %err, project_id = %project.id, "Failed to update project tags");
}
// Fetch updated project with tags
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project.id).await {
Ok(Some(data)) => data,
Ok(None) => {
tracing::error!(project_id = %project.id, "Project not found after update");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch updated project"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, project_id = %project.id, "Failed to fetch updated project");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch updated project"
})),
)
.into_response();
}
};
tracing::info!(project_id = %project.id, project_name = %project.name, "Project updated");
Json(project.to_api_admin_project(tags)).into_response()
}
/// Delete a project (requires authentication)
pub async fn delete_project_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
// Parse project ID
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
// Fetch project before deletion to return it
let (project, tags) = match db::get_project_by_id_with_tags(&state.pool, project_id).await {
Ok(Some(data)) => data,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch project before deletion");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete project"
})),
)
.into_response();
}
};
// Delete project (CASCADE handles tags)
match db::delete_project(&state.pool, project_id).await {
Ok(()) => {
tracing::info!(project_id = %project_id, project_name = %project.name, "Project deleted");
Json(project.to_api_admin_project(tags)).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to delete project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to delete project"
})),
)
.into_response()
}
}
}
/// Get admin stats (requires authentication)
pub async fn get_admin_stats_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
// Check auth
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
match db::get_admin_stats(&state.pool).await {
Ok(stats) => Json(stats).into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch admin stats");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch statistics"
})),
)
.into_response()
}
}
}
/// Get tags for a project
pub async fn get_project_tags_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> impl IntoResponse {
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::get_tags_for_project(&state.pool, project_id).await {
Ok(tags) => {
let api_tags: Vec<db::ApiTag> = tags.into_iter().map(|t| t.to_api_tag()).collect();
Json(api_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tags for project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tags"
})),
)
.into_response()
}
}
}
/// Add a tag to a project (requires authentication)
pub async fn add_project_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<AddProjectTagRequest>,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let tag_id = match uuid::Uuid::parse_str(&payload.tag_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid tag ID",
"message": "Tag ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::add_tag_to_project(&state.pool, project_id, tag_id).await {
Ok(()) => (
StatusCode::CREATED,
Json(serde_json::json!({
"message": "Tag added to project"
})),
)
.into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_foreign_key_violation() => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Project or tag not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to add tag to project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to add tag to project"
})),
)
.into_response()
}
}
}
/// Remove a tag from a project (requires authentication)
pub async fn remove_project_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path((id, tag_id)): axum::extract::Path<(String, String)>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
let project_id = match uuid::Uuid::parse_str(&id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid project ID",
"message": "Project ID must be a valid UUID"
})),
)
.into_response();
}
};
let tag_id = match uuid::Uuid::parse_str(&tag_id) {
Ok(id) => id,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid tag ID",
"message": "Tag ID must be a valid UUID"
})),
)
.into_response();
}
};
match db::remove_tag_from_project(&state.pool, project_id, tag_id).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Tag removed from project"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to remove tag from project");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to remove tag from project"
})),
)
.into_response()
}
}
}
+54
View File
@@ -0,0 +1,54 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{auth, db, state::AppState};
/// Get site settings (public endpoint)
pub async fn get_settings_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::get_site_settings(&state.pool).await {
Ok(settings) => Json(settings).into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch site settings");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch settings"
})),
)
.into_response()
}
}
}
/// Update site settings (requires authentication)
pub async fn update_settings_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<db::UpdateSiteSettingsRequest>,
) -> impl IntoResponse {
// Check authentication
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
match db::update_site_settings(&state.pool, &payload).await {
Ok(settings) => {
// TODO: Invalidate ISR cache for homepage and affected routes when ISR is implemented
// TODO: Add event log entry for settings update when events table is implemented
tracing::info!("Site settings updated");
Json(settings).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to update site settings");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update settings"
})),
)
.into_response()
}
}
}
+328
View File
@@ -0,0 +1,328 @@
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use std::sync::Arc;
use crate::{
auth, db,
handlers::{CreateTagRequest, UpdateTagRequest},
state::AppState,
utils,
};
/// List all tags with project counts (public endpoint)
pub async fn list_tags_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
match db::get_all_tags_with_counts(&state.pool).await {
Ok(tags_with_counts) => {
let api_tags: Vec<db::ApiTagWithCount> = tags_with_counts
.into_iter()
.map(|(tag, count)| db::ApiTagWithCount {
tag: tag.to_api_tag(),
project_count: count,
})
.collect();
Json(api_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tags");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tags"
})),
)
.into_response()
}
}
}
/// Create a new tag (requires authentication)
pub async fn create_tag_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<CreateTagRequest>,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Tag name cannot be empty"
})),
)
.into_response();
}
// Validate color if provided
if let Some(ref color) = payload.color {
if !utils::validate_hex_color(color) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid color format. Must be 6-character hex (e.g., '3b82f6')"
})),
)
.into_response();
}
}
match db::create_tag(
&state.pool,
&payload.name,
payload.slug.as_deref(),
None, // icon - not yet supported in admin UI
payload.color.as_deref(),
)
.await
{
Ok(tag) => (StatusCode::CREATED, Json(tag.to_api_tag())).into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A tag with this name or slug already exists"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to create tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to create tag"
})),
)
.into_response()
}
}
}
/// Get a tag by slug with associated projects
pub async fn get_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> impl IntoResponse {
match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => match db::get_projects_for_tag(&state.pool, tag.id).await {
Ok(projects) => {
let response = serde_json::json!({
"tag": tag.to_api_tag(),
"projects": projects.into_iter().map(|p| p.to_api_project()).collect::<Vec<_>>()
});
Json(response).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch projects for tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch projects"
})),
)
.into_response()
}
},
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response()
}
}
}
/// Update a tag (requires authentication)
pub async fn update_tag_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
jar: axum_extra::extract::CookieJar,
Json(payload): Json<UpdateTagRequest>,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
if payload.name.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Tag name cannot be empty"
})),
)
.into_response();
}
// Validate color if provided
if let Some(ref color) = payload.color {
if !utils::validate_hex_color(color) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Validation error",
"message": "Invalid color format. Must be 6-character hex (e.g., '3b82f6')"
})),
)
.into_response();
}
}
let tag = match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => tag,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response();
}
};
match db::update_tag(
&state.pool,
tag.id,
&payload.name,
payload.slug.as_deref(),
None, // icon - not yet supported in admin UI
payload.color.as_deref(),
)
.await
{
Ok(updated_tag) => Json(updated_tag.to_api_tag()).into_response(),
Err(sqlx::Error::Database(db_err)) if db_err.is_unique_violation() => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "Conflict",
"message": "A tag with this name or slug already exists"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to update tag");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to update tag"
})),
)
.into_response()
}
}
}
/// Get related tags by cooccurrence
pub async fn get_related_tags_handler(
State(state): State<Arc<AppState>>,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> impl IntoResponse {
let tag = match db::get_tag_by_slug(&state.pool, &slug).await {
Ok(Some(tag)) => tag,
Ok(None) => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": "Not found",
"message": "Tag not found"
})),
)
.into_response();
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch tag");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch tag"
})),
)
.into_response();
}
};
match db::get_related_tags(&state.pool, tag.id, 10).await {
Ok(related_tags) => {
let api_related_tags: Vec<db::ApiRelatedTag> = related_tags
.into_iter()
.map(|(tag, count)| db::ApiRelatedTag {
tag: tag.to_api_tag(),
cooccurrence_count: count,
})
.collect();
Json(api_related_tags).into_response()
}
Err(err) => {
tracing::error!(error = %err, "Failed to fetch related tags");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to fetch related tags"
})),
)
.into_response()
}
}
}
/// Recalculate tag cooccurrence matrix (requires authentication)
pub async fn recalculate_cooccurrence_handler(
State(state): State<Arc<AppState>>,
jar: axum_extra::extract::CookieJar,
) -> impl IntoResponse {
if auth::check_session(&state, &jar).is_none() {
return auth::require_auth_response().into_response();
}
match db::recalculate_tag_cooccurrence(&state.pool).await {
Ok(()) => (
StatusCode::OK,
Json(serde_json::json!({
"message": "Tag cooccurrence recalculated successfully"
})),
)
.into_response(),
Err(err) => {
tracing::error!(error = %err, "Failed to recalculate cooccurrence");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Internal server error",
"message": "Failed to recalculate cooccurrence"
})),
)
.into_response()
}
}
}
+16 -1956
View File
File diff suppressed because it is too large Load Diff
-74
View File
@@ -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()
}
-2
View File
@@ -1,5 +1,3 @@
pub mod auth;
pub mod request_id;
pub use auth::{require_admin_auth, require_api_auth};
pub use request_id::RequestIdLayer;
+7 -8
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
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
///
@@ -74,7 +74,6 @@ pub async fn generate_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Resu
}
/// Check if an OG image exists in R2
#[allow(dead_code)]
pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
if let Some(r2) = R2Client::get().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
#[allow(dead_code)]
pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
if og_image_exists(spec).await {
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
/// Uses ensure_og_image to skip regeneration if images already exist
pub async fn regenerate_common_images(state: Arc<AppState>) {
// Wait 2 seconds before starting
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];
for spec in specs {
match generate_og_image(&spec, state.clone()).await {
match ensure_og_image(&spec, state.clone()).await {
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) => {
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
View File
@@ -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
}
}
-2
View File
@@ -57,7 +57,6 @@ impl R2Client {
.cloned()
}
#[allow(dead_code)]
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, String> {
let result = self
.client
@@ -93,7 +92,6 @@ impl R2Client {
Ok(())
}
#[allow(dead_code)]
pub async fn object_exists(&self, key: &str) -> bool {
self.client
.head_object()
+167
View File
@@ -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
}
+33
View File
@@ -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
View File
@@ -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
View File
@@ -170,7 +170,9 @@ html,
body {
@apply font-inter overflow-x-hidden;
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 {
+7 -4
View File
@@ -4,11 +4,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function() {
const stored = localStorage.getItem('theme');
const isDark = stored === 'dark' || (stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
(function () {
const stored = localStorage.getItem("theme");
const isDark =
stored === "dark" ||
(stored !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
if (isDark) {
document.documentElement.classList.add('dark');
document.documentElement.classList.add("dark");
}
})();
</script>
+1 -1
View File
@@ -19,7 +19,7 @@ export async function apiFetch<T>(
const url = `${baseUrl}${path}`;
const method = init?.method ?? "GET";
// Unix sockets require Bun's native fetch (SvelteKit's fetch doesn't support it)
const fetchFn = isUnixSocket ? fetch : (init?.fetch ?? fetch);
+5 -7
View File
@@ -16,10 +16,7 @@ import type {
// ============================================================================
// Client-side fetch wrapper for browser requests
async function clientApiFetch<T>(
path: string,
init?: RequestInit,
): Promise<T> {
async function clientApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
credentials: "same-origin", // Include cookies for auth
@@ -83,9 +80,10 @@ export async function deleteAdminProject(id: string): Promise<AdminProject> {
// Admin Tags API
export async function getAdminTags(): Promise<AdminTagWithCount[]> {
const tags = await clientApiFetch<
Array<AdminTag & { project_count: number }>
>("/api/tags");
const tags =
await clientApiFetch<Array<AdminTag & { project_count: number }>>(
"/api/tags",
);
// Transform snake_case to camelCase
return tags.map((item) => ({
+12 -2
View File
@@ -19,9 +19,19 @@
} = $props();
</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]} />
<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}
<div class="absolute top-5 right-6 z-50">
<ThemeToggle />
+16 -5
View File
@@ -83,7 +83,10 @@
transition:scale={{ duration: 200, start: 0.95 }}
>
<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
</h2>
<button
@@ -109,17 +112,23 @@
</div>
<!-- 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">
Fingerprint
</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}
</div>
</div>
<!-- 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}
<div class="p-4 text-center text-zinc-600 dark:text-zinc-400">
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"
>
<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>
</div>
</div>
+86 -76
View File
@@ -13,7 +13,9 @@
let { project, class: className }: Props = $props();
// 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 {
const date = new Date(dateString);
@@ -34,87 +36,95 @@
</script>
{#if projectUrl}
<a
href={projectUrl}
target="_blank"
rel="noopener noreferrer"
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",
className,
)}
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<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"
<a
href={projectUrl}
target="_blank"
rel="noopener noreferrer"
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",
className,
)}
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<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"
>
{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}
</h3>
<span class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300">
{formatDate(project.updatedAt)}
</span>
{project.shortDescription}
</p>
</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">
{#each project.tags as tag (tag.name)}
<!-- TODO: Add link to project search with tag filtering -->
<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"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each}
</div>
</a>
<div class="mt-auto flex flex-wrap gap-1">
{#each project.tags as tag (tag.name)}
<!-- TODO: Add link to project search with tag filtering -->
<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"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each}
</div>
</a>
{:else}
<div
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",
className,
)}
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<h3
class="truncate font-medium text-lg sm:text-base text-zinc-900 dark:text-zinc-100"
<div
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",
className,
)}
>
<div class="flex flex-col gap-1">
<div class="flex items-start justify-between gap-2">
<h3
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}
</h3>
<span class="shrink-0 sm:text-[0.83rem] text-zinc-600 dark:text-zinc-300">
{formatDate(project.updatedAt)}
</span>
{project.shortDescription}
</p>
</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">
{#each project.tags as tag (tag.name)}
<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"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each}
<div class="mt-auto flex flex-wrap gap-1">
{#each project.tags as tag (tag.name)}
<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"
style="border-left-color: #{tag.color || '06b6d4'}"
>
{#if tag.iconSvg}
<span class="size-4.25 sm:size-3.75 [&>svg]:w-full [&>svg]:h-full">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html tag.iconSvg}
</span>
{/if}
<span>{tag.name}</span>
</span>
{/each}
</div>
</div>
</div>
{/if}
+3 -1
View File
@@ -7,7 +7,9 @@
<button
type="button"
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"
>
<div class="absolute inset-0 flex items-center justify-center">
+2 -1
View File
@@ -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",
danger:
"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 = {
@@ -117,7 +117,8 @@
<div class="flex items-start gap-2">
<div class="flex-1">
<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
>
<input
+5 -2
View File
@@ -64,7 +64,9 @@
{event.message}
</span>
<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>
</div>
<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]"
>
<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,
null,
2,
@@ -211,7 +211,9 @@
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html selectedIconSvg}
{: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}
</div>
<div class="flex-1">
+9 -3
View File
@@ -78,10 +78,14 @@
>
<div class="flex h-full flex-col">
<!-- 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">
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>
<ThemeToggle />
</div>
@@ -110,7 +114,9 @@
</nav>
<!-- 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
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"
+4 -4
View File
@@ -1,6 +1,6 @@
export const PGP_KEY_METADATA = {
fingerprint: '211D 7157 249B F07D 81C8 B9DE C217 005C F3C0 0672',
keyId: 'C217005CF3C00672',
email: 'xevion@xevion.dev',
name: 'Ryan Walters',
fingerprint: "211D 7157 249B F07D 81C8 B9DE C217 005C F3C0 0672",
keyId: "C217005CF3C00672",
email: "xevion@xevion.dev",
name: "Ryan Walters",
} as const;
+18 -7
View File
@@ -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"
>
<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
>
<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}
</span>
</div>
@@ -61,7 +64,8 @@
<span class="size-4 text-zinc-600 dark:text-zinc-300">
{@html link.iconSvg}
</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
>
</a>
@@ -75,7 +79,8 @@
<span class="size-4 text-zinc-600 dark:text-zinc-300">
{@html link.iconSvg}
</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
>
</button>
@@ -88,7 +93,8 @@
<span class="size-4.5 text-zinc-600 dark:text-zinc-300">
{@html link.iconSvg}
</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
>
</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"
onclick={() => (pgpModalOpen = true)}
>
<MaterialSymbolsVpnKey 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>
<MaterialSymbolsVpnKey
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>
</div>
</div>
+6 -2
View File
@@ -63,7 +63,9 @@
<div
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
href={resolve("/admin/events")}
class="text-sm text-admin-accent hover:text-admin-accent-hover transition-colors"
@@ -73,7 +75,9 @@
</div>
{#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}
<EventLog events={recentEvents} maxHeight="400px" />
{/if}
+3 -1
View File
@@ -84,7 +84,9 @@
<div
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">
Event Log
<span class="text-admin-text-muted font-normal ml-2">
+3 -1
View File
@@ -43,7 +43,9 @@
<div class="flex min-h-screen items-center justify-center px-4">
<div class="w-full max-w-md space-y-4">
<!-- 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">
<Input
label="Username"
+24 -8
View File
@@ -84,7 +84,9 @@
<div class="flex items-center justify-between">
<div>
<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>
<Button variant="primary" href="/admin/projects/new">
<IconPlus class="w-4 h-4 mr-2" />
@@ -94,7 +96,9 @@
<!-- Projects Table -->
{#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}
<div class="text-center py-12">
<p class="text-admin-text-muted mb-4">No projects yet</p>
@@ -106,19 +110,29 @@
<Table>
<thead class="bg-admin-surface/50">
<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
</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
</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
</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
</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
</th>
</tr>
@@ -192,7 +206,9 @@
oncancel={cancelDelete}
>
{#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="text-sm text-admin-text-secondary">{deleteTarget.slug}</p>
</div>
@@ -257,7 +257,11 @@
>
Cancel
</Button>
<Button variant="primary" onclick={handleSave} disabled={!hasChanges || saving}>
<Button
variant="primary"
onclick={handleSave}
disabled={!hasChanges || saving}
>
{saving ? "Saving..." : "Save Changes"}
</Button>
</div>
+24 -8
View File
@@ -231,19 +231,29 @@
<Table>
<thead class="bg-admin-surface/50">
<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
</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
</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
</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
</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
</th>
</tr>
@@ -274,7 +284,9 @@
class="size-6 rounded border border-admin-border"
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>
{:else}
<span class="text-xs text-admin-text-muted">No color</span>
@@ -318,7 +330,9 @@
class="size-6 rounded border border-admin-border"
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>
{:else}
<span class="text-xs text-admin-text-muted">No color</span>
@@ -364,7 +378,9 @@
oncancel={cancelDelete}
>
{#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="text-sm text-admin-text-secondary">
Used in {deleteTarget.projectCount} project{deleteTarget.projectCount ===
+6 -6
View File
@@ -1,14 +1,14 @@
import { PGP_KEY_METADATA } from '$lib/pgp/key-info';
import { readFileSync } from 'fs';
import { join } from 'path';
import { PGP_KEY_METADATA } from "$lib/pgp/key-info";
import { readFileSync } from "fs";
import { join } from "path";
export const prerender = true;
export const load = () => {
// Read the PGP key from static directory at build time
const keyPath = join(process.cwd(), 'static', 'publickey.asc');
const content = readFileSync(keyPath, 'utf-8');
const keyPath = join(process.cwd(), "static", "publickey.asc");
const content = readFileSync(keyPath, "utf-8");
return {
key: {
...PGP_KEY_METADATA,
+53 -19
View File
@@ -26,7 +26,9 @@
async function copyCommand() {
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;
setTimeout(() => {
copyCommandSuccess = false;
@@ -48,7 +50,10 @@
<svelte:head>
<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>
<AppWrapper class="overflow-x-hidden font-schibsted">
@@ -56,11 +61,14 @@
<div class="max-w-2xl w-full">
<!-- Header -->
<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
</h1>
<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>
</div>
@@ -68,13 +76,19 @@
<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"
>
<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
</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}
</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">
<span class="font-medium">Key ID:</span>
<span class="font-mono ml-2">{data.key.keyId}</span>
@@ -90,8 +104,12 @@
<div
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 class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300">
<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
class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300"
>
Public Key
</div>
</div>
@@ -103,7 +121,8 @@
style="max-height: 400px"
>
<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>
</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"
>
<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
onclick={downloadKey}
@@ -126,31 +147,44 @@
</div>
<!-- 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">
<h2 class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2">
<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"
>
<h2
class="text-xs sm:text-sm font-semibold text-zinc-700 dark:text-zinc-300 mb-2"
>
How to use this key
</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>
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>
<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
onclick={copyCommand}
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"}
>
{#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}
<IconCopy class="size-3.5 text-zinc-600 dark:text-zinc-400" />
{/if}
</button>
</div>
<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>
</div>
</div>