mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-02-01 10:26:39 -06:00
feat: add comprehensive CLI with API client, session management, and data seeding
- Binary renamed from 'api' to 'xevion' with full CLI command structure - Authentication: login/logout with session persistence to .xevion-session - API commands: projects, tags, settings CRUD operations with JSON/table output - Serve command: run production server with configurable listen addresses - Seed command: moved from bin/ to CLI subcommand for database initialization - HTTP client abstraction supporting both TCP and Unix socket connections
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use crate::cli::client::{ApiClient, Session, check_response};
|
||||
use crate::cli::output;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
success: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionResponse {
|
||||
authenticated: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
/// Login and save session
|
||||
pub async fn login(
|
||||
mut client: ApiClient,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let request = LoginRequest {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
|
||||
let response = client.post("/api/login", &request).await?;
|
||||
|
||||
// Extract session cookie from response headers
|
||||
let session_token = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.find_map(|v| {
|
||||
let s = v.to_str().ok()?;
|
||||
if s.starts_with("admin_session=") {
|
||||
// Extract just the token value
|
||||
let token = s.strip_prefix("admin_session=")?.split(';').next()?;
|
||||
Some(token.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let response = check_response(response).await?;
|
||||
let login_response: LoginResponse = response.json().await?;
|
||||
|
||||
if !login_response.success {
|
||||
output::error("Login failed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session_token = session_token.ok_or("No session cookie received")?;
|
||||
|
||||
let session = Session {
|
||||
api_url: client.api_url.clone(),
|
||||
session_token,
|
||||
username: login_response.username.clone(),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
expires_at: OffsetDateTime::now_utc() + Duration::days(7),
|
||||
};
|
||||
|
||||
client.save_session(session)?;
|
||||
|
||||
output::success(&format!("Logged in as {}", login_response.username));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear saved session
|
||||
pub async fn logout(mut client: ApiClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Try to call logout endpoint if we have a session
|
||||
if client.is_authenticated() {
|
||||
let _ = client.post("/api/logout", &()).await;
|
||||
}
|
||||
|
||||
client.clear_session()?;
|
||||
output::success("Logged out");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check current session status
|
||||
pub async fn session(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First check local session
|
||||
if let Some(session) = client.session() {
|
||||
// Verify with server
|
||||
let response = client.get("/api/session").await?;
|
||||
let response = check_response(response).await?;
|
||||
let session_response: SessionResponse = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"authenticated": session_response.authenticated,
|
||||
"username": session_response.username,
|
||||
"api_url": session.api_url,
|
||||
"expires_at": session.expires_at.format(&time::format_description::well_known::Rfc3339).unwrap(),
|
||||
})
|
||||
);
|
||||
} else if session_response.authenticated {
|
||||
output::print_session(&session_response.username, &session.api_url);
|
||||
} else {
|
||||
output::error("Session expired or invalid");
|
||||
}
|
||||
} else {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"authenticated": false,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
output::info("Not logged in");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub mod auth;
|
||||
pub mod projects;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
|
||||
use crate::cli::client::ApiClient;
|
||||
use crate::cli::{ApiArgs, ApiCommand};
|
||||
|
||||
/// Run an API subcommand
|
||||
pub async fn run(args: ApiArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = ApiClient::new(args.api_url, args.session);
|
||||
|
||||
match args.command {
|
||||
ApiCommand::Login { username, password } => auth::login(client, &username, &password).await,
|
||||
ApiCommand::Logout => auth::logout(client).await,
|
||||
ApiCommand::Session => auth::session(client, args.json).await,
|
||||
ApiCommand::Projects(cmd) => projects::run(client, cmd, args.json).await,
|
||||
ApiCommand::Tags(cmd) => tags::run(client, cmd, args.json).await,
|
||||
ApiCommand::Settings(cmd) => settings::run(client, cmd, args.json).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cli::client::{ApiClient, ApiError, check_response};
|
||||
use crate::cli::output;
|
||||
use crate::cli::{ProjectsCommand, TagOp, parse_create_tags, parse_update_tags};
|
||||
use crate::db::{ApiAdminProject, ApiTag, ProjectStatus};
|
||||
|
||||
/// Request to create a project
|
||||
#[derive(Serialize)]
|
||||
struct CreateProjectRequest {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slug: Option<String>,
|
||||
short_description: String,
|
||||
description: String,
|
||||
status: ProjectStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
github_repo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
demo_url: Option<String>,
|
||||
tag_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request to update a project
|
||||
#[derive(Serialize)]
|
||||
struct UpdateProjectRequest {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slug: Option<String>,
|
||||
short_description: String,
|
||||
description: String,
|
||||
status: ProjectStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
github_repo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
demo_url: Option<String>,
|
||||
tag_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Run a projects subcommand
|
||||
pub async fn run(
|
||||
client: ApiClient,
|
||||
command: ProjectsCommand,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
ProjectsCommand::List => list(client, json).await,
|
||||
ProjectsCommand::Get { reference } => get(client, &reference, json).await,
|
||||
ProjectsCommand::Create {
|
||||
name,
|
||||
short_desc,
|
||||
desc,
|
||||
slug,
|
||||
status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tags,
|
||||
} => {
|
||||
let tag_slugs = tags.map(|s| parse_create_tags(&s)).unwrap_or_default();
|
||||
create(
|
||||
client,
|
||||
&name,
|
||||
&short_desc,
|
||||
&desc,
|
||||
slug,
|
||||
&status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tag_slugs,
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ProjectsCommand::Update {
|
||||
reference,
|
||||
name,
|
||||
slug,
|
||||
short_desc,
|
||||
desc,
|
||||
status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tags,
|
||||
} => {
|
||||
let tag_ops = match tags {
|
||||
Some(s) => parse_update_tags(&s)?,
|
||||
None => vec![],
|
||||
};
|
||||
update(
|
||||
client,
|
||||
&reference,
|
||||
name,
|
||||
slug,
|
||||
short_desc,
|
||||
desc,
|
||||
status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tag_ops,
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ProjectsCommand::Delete { reference } => delete(client, &reference, json).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all projects
|
||||
async fn list(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get_auth("/api/projects").await?;
|
||||
let response = check_response(response).await?;
|
||||
let projects: Vec<ApiAdminProject> = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&projects)?);
|
||||
} else {
|
||||
output::print_projects_table(&projects);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a project by slug or UUID
|
||||
async fn get(
|
||||
client: ApiClient,
|
||||
reference: &str,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let project = resolve_project(&client, reference).await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&project)?);
|
||||
} else {
|
||||
output::print_project(&project);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new project
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create(
|
||||
client: ApiClient,
|
||||
name: &str,
|
||||
short_desc: &str,
|
||||
desc: &str,
|
||||
slug: Option<String>,
|
||||
status: &str,
|
||||
github_repo: Option<String>,
|
||||
demo_url: Option<String>,
|
||||
tag_slugs: Vec<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Resolve tag slugs to IDs
|
||||
let tag_ids = resolve_tag_ids(&client, &tag_slugs).await?;
|
||||
|
||||
let status = parse_status(status)?;
|
||||
|
||||
let request = CreateProjectRequest {
|
||||
name: name.to_string(),
|
||||
slug,
|
||||
short_description: short_desc.to_string(),
|
||||
description: desc.to_string(),
|
||||
status,
|
||||
github_repo: github_repo.filter(|s| !s.is_empty()),
|
||||
demo_url: demo_url.filter(|s| !s.is_empty()),
|
||||
tag_ids,
|
||||
};
|
||||
|
||||
let response = client.post_auth("/api/projects", &request).await?;
|
||||
let response = check_response(response).await?;
|
||||
let project: ApiAdminProject = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&project)?);
|
||||
} else {
|
||||
output::success(&format!("Created project: {}", project.project.name));
|
||||
output::print_project(&project);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an existing project
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update(
|
||||
client: ApiClient,
|
||||
reference: &str,
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
short_desc: Option<String>,
|
||||
desc: Option<String>,
|
||||
status: Option<String>,
|
||||
github_repo: Option<String>,
|
||||
demo_url: Option<String>,
|
||||
tag_ops: Vec<TagOp>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First fetch the current project
|
||||
let current = resolve_project(&client, reference).await?;
|
||||
|
||||
// Apply tag operations
|
||||
let mut current_tag_ids: Vec<String> = current.tags.iter().map(|t| t.id.clone()).collect();
|
||||
|
||||
for op in tag_ops {
|
||||
match op {
|
||||
TagOp::Add(slug_or_id) => {
|
||||
let tag_id = resolve_tag_id(&client, &slug_or_id).await?;
|
||||
if !current_tag_ids.contains(&tag_id) {
|
||||
current_tag_ids.push(tag_id);
|
||||
}
|
||||
}
|
||||
TagOp::Remove(slug_or_id) => {
|
||||
let tag_id = resolve_tag_id(&client, &slug_or_id).await?;
|
||||
current_tag_ids.retain(|id| id != &tag_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge updates with current values
|
||||
let status = if let Some(s) = status {
|
||||
parse_status(&s)?
|
||||
} else {
|
||||
parse_status(¤t.status)?
|
||||
};
|
||||
|
||||
let request = UpdateProjectRequest {
|
||||
name: name.unwrap_or(current.project.name),
|
||||
slug,
|
||||
short_description: short_desc.unwrap_or(current.project.short_description),
|
||||
description: desc.unwrap_or(current.description),
|
||||
status,
|
||||
github_repo: match github_repo {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.github_repo,
|
||||
},
|
||||
demo_url: match demo_url {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.demo_url,
|
||||
},
|
||||
tag_ids: current_tag_ids,
|
||||
};
|
||||
|
||||
let response = client
|
||||
.put_auth(&format!("/api/projects/{}", current.project.id), &request)
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
let project: ApiAdminProject = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&project)?);
|
||||
} else {
|
||||
output::success(&format!("Updated project: {}", project.project.name));
|
||||
output::print_project(&project);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a project
|
||||
async fn delete(
|
||||
client: ApiClient,
|
||||
reference: &str,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First resolve to get the ID
|
||||
let project = resolve_project(&client, reference).await?;
|
||||
|
||||
let response = client
|
||||
.delete_auth(&format!("/api/projects/{}", project.project.id))
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
let deleted: ApiAdminProject = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&deleted)?);
|
||||
} else {
|
||||
output::success(&format!("Deleted project: {}", deleted.project.name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a project reference (slug or UUID) to a full project
|
||||
async fn resolve_project(
|
||||
client: &ApiClient,
|
||||
reference: &str,
|
||||
) -> Result<ApiAdminProject, Box<dyn std::error::Error>> {
|
||||
// Try as UUID first
|
||||
if Uuid::parse_str(reference).is_ok() {
|
||||
let response = client
|
||||
.get_auth(&format!("/api/projects/{}", reference))
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
return Ok(response.json().await?);
|
||||
}
|
||||
|
||||
// Otherwise search by slug in the list
|
||||
let response = client.get_auth("/api/projects").await?;
|
||||
let response = check_response(response).await?;
|
||||
let projects: Vec<ApiAdminProject> = response.json().await?;
|
||||
|
||||
projects
|
||||
.into_iter()
|
||||
.find(|p| p.project.slug == reference)
|
||||
.ok_or_else(|| ApiError::Parse(format!("Project not found: {}", reference)).into())
|
||||
}
|
||||
|
||||
/// Resolve tag slugs to IDs
|
||||
async fn resolve_tag_ids(
|
||||
client: &ApiClient,
|
||||
slugs: &[String],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
if slugs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for slug in slugs {
|
||||
ids.push(resolve_tag_id(client, slug).await?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Resolve a single tag slug or ID to an ID
|
||||
async fn resolve_tag_id(
|
||||
client: &ApiClient,
|
||||
slug_or_id: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try as UUID first
|
||||
if Uuid::parse_str(slug_or_id).is_ok() {
|
||||
return Ok(slug_or_id.to_string());
|
||||
}
|
||||
|
||||
// Otherwise look up by slug
|
||||
#[derive(Deserialize)]
|
||||
struct TagResponse {
|
||||
tag: ApiTag,
|
||||
}
|
||||
|
||||
let response = client.get(&format!("/api/tags/{}", slug_or_id)).await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag_response: TagResponse = response.json().await?;
|
||||
Ok(tag_response.tag.id)
|
||||
}
|
||||
|
||||
/// Parse status string to ProjectStatus
|
||||
fn parse_status(s: &str) -> Result<ProjectStatus, Box<dyn std::error::Error>> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"active" => Ok(ProjectStatus::Active),
|
||||
"maintained" => Ok(ProjectStatus::Maintained),
|
||||
"archived" => Ok(ProjectStatus::Archived),
|
||||
"hidden" => Ok(ProjectStatus::Hidden),
|
||||
_ => Err(format!(
|
||||
"Invalid status '{}'. Valid values: active, maintained, archived, hidden",
|
||||
s
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::cli::SettingsCommand;
|
||||
use crate::cli::client::{ApiClient, check_response};
|
||||
use crate::cli::output;
|
||||
use crate::db::{ApiSiteSettings, UpdateSiteIdentityRequest, UpdateSiteSettingsRequest};
|
||||
|
||||
/// Run a settings subcommand
|
||||
pub async fn run(
|
||||
client: ApiClient,
|
||||
command: SettingsCommand,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
SettingsCommand::Get => get(client, json).await,
|
||||
SettingsCommand::Update {
|
||||
display_name,
|
||||
occupation,
|
||||
bio,
|
||||
site_title,
|
||||
} => update(client, display_name, occupation, bio, site_title, json).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current site settings
|
||||
async fn get(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get("/api/settings").await?;
|
||||
let response = check_response(response).await?;
|
||||
let settings: ApiSiteSettings = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||
} else {
|
||||
output::print_settings(&settings);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update site settings
|
||||
async fn update(
|
||||
client: ApiClient,
|
||||
display_name: Option<String>,
|
||||
occupation: Option<String>,
|
||||
bio: Option<String>,
|
||||
site_title: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First fetch current settings
|
||||
let response = client.get("/api/settings").await?;
|
||||
let response = check_response(response).await?;
|
||||
let current: ApiSiteSettings = response.json().await?;
|
||||
|
||||
// Merge updates
|
||||
let request = UpdateSiteSettingsRequest {
|
||||
identity: UpdateSiteIdentityRequest {
|
||||
display_name: display_name.unwrap_or(current.identity.display_name),
|
||||
occupation: occupation.unwrap_or(current.identity.occupation),
|
||||
bio: bio.unwrap_or(current.identity.bio),
|
||||
site_title: site_title.unwrap_or(current.identity.site_title),
|
||||
},
|
||||
// Keep existing social links unchanged
|
||||
social_links: current
|
||||
.social_links
|
||||
.into_iter()
|
||||
.map(|link| crate::db::UpdateSocialLinkRequest {
|
||||
id: link.id,
|
||||
platform: link.platform,
|
||||
label: link.label,
|
||||
value: link.value,
|
||||
icon: link.icon,
|
||||
visible: link.visible,
|
||||
display_order: link.display_order,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let response = client.put_auth("/api/settings", &request).await?;
|
||||
let response = check_response(response).await?;
|
||||
let settings: ApiSiteSettings = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||
} else {
|
||||
output::success("Updated site settings");
|
||||
output::print_settings(&settings);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::cli::TagsCommand;
|
||||
use crate::cli::client::{ApiClient, check_response};
|
||||
use crate::cli::output;
|
||||
use crate::db::{ApiTag, ApiTagWithCount};
|
||||
use crate::handlers::{CreateTagRequest, UpdateTagRequest};
|
||||
|
||||
/// Response for get tag endpoint
|
||||
#[derive(Deserialize)]
|
||||
struct GetTagResponse {
|
||||
tag: ApiTag,
|
||||
projects: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run a tags subcommand
|
||||
pub async fn run(
|
||||
client: ApiClient,
|
||||
command: TagsCommand,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
TagsCommand::List => list(client, json).await,
|
||||
TagsCommand::Get { slug } => get(client, &slug, json).await,
|
||||
TagsCommand::Create {
|
||||
name,
|
||||
slug,
|
||||
icon,
|
||||
color,
|
||||
} => create(client, &name, slug, icon, color, json).await,
|
||||
TagsCommand::Update {
|
||||
slug,
|
||||
name,
|
||||
new_slug,
|
||||
icon,
|
||||
color,
|
||||
} => update(client, &slug, name, new_slug, icon, color, json).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all tags
|
||||
async fn list(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get("/api/tags").await?;
|
||||
let response = check_response(response).await?;
|
||||
let tags: Vec<ApiTagWithCount> = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&tags)?);
|
||||
} else {
|
||||
output::print_tags_table(&tags);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a tag by slug
|
||||
async fn get(client: ApiClient, slug: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get(&format!("/api/tags/{}", slug)).await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag_response: GetTagResponse = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"tag": tag_response.tag,
|
||||
"projects": tag_response.projects,
|
||||
}))?
|
||||
);
|
||||
} else {
|
||||
output::print_tag(&tag_response.tag);
|
||||
if !tag_response.projects.is_empty() {
|
||||
output::info(&format!(
|
||||
"{} associated project(s)",
|
||||
tag_response.projects.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new tag
|
||||
async fn create(
|
||||
client: ApiClient,
|
||||
name: &str,
|
||||
slug: Option<String>,
|
||||
icon: Option<String>,
|
||||
color: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Validate color if provided
|
||||
if let Some(ref c) = color {
|
||||
if !c.chars().all(|ch| ch.is_ascii_hexdigit()) || c.len() != 6 {
|
||||
return Err("Color must be a 6-character hex string (e.g., '3b82f6')".into());
|
||||
}
|
||||
}
|
||||
|
||||
let request = CreateTagRequest {
|
||||
name: name.to_string(),
|
||||
slug,
|
||||
icon: icon.filter(|s| !s.is_empty()),
|
||||
color: color.filter(|s| !s.is_empty()),
|
||||
};
|
||||
|
||||
let response = client.post_auth("/api/tags", &request).await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag: ApiTag = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&tag)?);
|
||||
} else {
|
||||
output::success(&format!("Created tag: {}", tag.name));
|
||||
output::print_tag(&tag);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an existing tag
|
||||
async fn update(
|
||||
client: ApiClient,
|
||||
slug: &str,
|
||||
name: Option<String>,
|
||||
new_slug: Option<String>,
|
||||
icon: Option<String>,
|
||||
color: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Validate color if provided
|
||||
if let Some(ref c) = color {
|
||||
if !c.is_empty() && (!c.chars().all(|ch| ch.is_ascii_hexdigit()) || c.len() != 6) {
|
||||
return Err("Color must be a 6-character hex string (e.g., '3b82f6')".into());
|
||||
}
|
||||
}
|
||||
|
||||
// First fetch the current tag
|
||||
let response = client.get(&format!("/api/tags/{}", slug)).await?;
|
||||
let response = check_response(response).await?;
|
||||
let current: GetTagResponse = response.json().await?;
|
||||
|
||||
// Merge updates
|
||||
let request = UpdateTagRequest {
|
||||
name: name.unwrap_or(current.tag.name),
|
||||
slug: new_slug,
|
||||
icon: match icon {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.tag.icon,
|
||||
},
|
||||
color: match color {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.tag.color,
|
||||
},
|
||||
};
|
||||
|
||||
let response = client
|
||||
.put_auth(&format!("/api/tags/{}", slug), &request)
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag: ApiTag = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&tag)?);
|
||||
} else {
|
||||
output::success(&format!("Updated tag: {}", tag.name));
|
||||
output::print_tag(&tag);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use reqwest::{Client, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Session data stored in the session file
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub api_url: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// API client with session management
|
||||
pub struct ApiClient {
|
||||
pub api_url: String,
|
||||
session_path: String,
|
||||
session: Option<Session>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
/// HTTP request failed
|
||||
Request(reqwest::Error),
|
||||
/// Server returned an error response
|
||||
Http { status: StatusCode, body: String },
|
||||
/// Failed to parse response
|
||||
Parse(String),
|
||||
/// Session file error
|
||||
Session(String),
|
||||
/// Not authenticated
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ApiError::Request(e) => write!(f, "Request failed: {}", e),
|
||||
ApiError::Http { status, body } => {
|
||||
write!(f, "HTTP {}: {}", status, body)
|
||||
}
|
||||
ApiError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||
ApiError::Session(msg) => write!(f, "Session error: {}", msg),
|
||||
ApiError::Unauthorized => write!(f, "Not authenticated. Run 'xevion api login' first."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
impl From<reqwest::Error> for ApiError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
ApiError::Request(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
/// Create a new API client
|
||||
pub fn new(api_url: String, session_path: String) -> Self {
|
||||
let session = Self::load_session_from_path(&session_path);
|
||||
Self {
|
||||
api_url,
|
||||
session_path,
|
||||
session,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_session_from_path(path: &str) -> Option<Session> {
|
||||
let path = Path::new(path);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let session: Session = serde_json::from_str(&content).ok()?;
|
||||
|
||||
// Check if session is expired
|
||||
if session.expires_at < OffsetDateTime::now_utc() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(session)
|
||||
}
|
||||
|
||||
/// Save session to file
|
||||
pub fn save_session(&mut self, session: Session) -> Result<(), ApiError> {
|
||||
let content = serde_json::to_string_pretty(&session)
|
||||
.map_err(|e| ApiError::Session(format!("Failed to serialize session: {}", e)))?;
|
||||
|
||||
std::fs::write(&self.session_path, content)
|
||||
.map_err(|e| ApiError::Session(format!("Failed to write session file: {}", e)))?;
|
||||
|
||||
self.session = Some(session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the session
|
||||
pub fn clear_session(&mut self) -> Result<(), ApiError> {
|
||||
let path = Path::new(&self.session_path);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)
|
||||
.map_err(|e| ApiError::Session(format!("Failed to remove session file: {}", e)))?;
|
||||
}
|
||||
self.session = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current session if valid
|
||||
pub fn session(&self) -> Option<&Session> {
|
||||
self.session.as_ref()
|
||||
}
|
||||
|
||||
/// Check if we have a valid session
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
/// Build the full URL for an endpoint
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.api_url, path)
|
||||
}
|
||||
|
||||
/// Make a GET request
|
||||
pub async fn get(&self, path: &str) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.get(self.url(path));
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated GET request (fails if not authenticated)
|
||||
pub async fn get_auth(&self, path: &str) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.get(path).await
|
||||
}
|
||||
|
||||
/// Make a POST request with JSON body
|
||||
pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.post(self.url(path)).json(body);
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated POST request
|
||||
pub async fn post_auth<T: Serialize>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.post(path, body).await
|
||||
}
|
||||
|
||||
/// Make a PUT request with JSON body
|
||||
pub async fn put<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.put(self.url(path)).json(body);
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated PUT request
|
||||
pub async fn put_auth<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.put(path, body).await
|
||||
}
|
||||
|
||||
/// Make a DELETE request
|
||||
pub async fn delete(&self, path: &str) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.delete(self.url(path));
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated DELETE request
|
||||
pub async fn delete_auth(&self, path: &str) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.delete(path).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to check response and extract error message
|
||||
pub async fn check_response(response: Response) -> Result<Response, ApiError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
Err(ApiError::Http { status, body })
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod output;
|
||||
pub mod seed;
|
||||
pub mod serve;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::config::ListenAddr;
|
||||
|
||||
/// xevion.dev - Personal portfolio server and API client
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "xevion")]
|
||||
#[command(about = "Personal portfolio server with API client for content management")]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Address(es) to listen on (TCP or Unix socket)
|
||||
#[arg(long, env = "LISTEN_ADDR", value_delimiter = ',')]
|
||||
pub listen: Vec<ListenAddr>,
|
||||
|
||||
/// Downstream SSR server URL
|
||||
#[arg(long, env = "DOWNSTREAM_URL")]
|
||||
pub downstream: Option<String>,
|
||||
|
||||
/// Trust X-Request-ID header from specified source
|
||||
#[arg(long, env = "TRUST_REQUEST_ID")]
|
||||
pub trust_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Command {
|
||||
/// Seed the database with sample data
|
||||
Seed,
|
||||
|
||||
/// API client for managing content remotely
|
||||
Api(ApiArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ApiArgs {
|
||||
/// API base URL
|
||||
#[arg(long, env = "API_BASE_URL", default_value = "http://localhost:8080")]
|
||||
pub api_url: String,
|
||||
|
||||
/// Session file path
|
||||
#[arg(long, env = "XEVION_SESSION", default_value = ".xevion-session")]
|
||||
pub session: String,
|
||||
|
||||
/// Output raw JSON instead of formatted text
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: ApiCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ApiCommand {
|
||||
/// Login and save session
|
||||
Login {
|
||||
/// Username
|
||||
username: String,
|
||||
/// Password
|
||||
password: String,
|
||||
},
|
||||
|
||||
/// Clear saved session
|
||||
Logout,
|
||||
|
||||
/// Check current session status
|
||||
Session,
|
||||
|
||||
/// Project management
|
||||
#[command(subcommand)]
|
||||
Projects(ProjectsCommand),
|
||||
|
||||
/// Tag management
|
||||
#[command(subcommand)]
|
||||
Tags(TagsCommand),
|
||||
|
||||
/// Site settings management
|
||||
#[command(subcommand)]
|
||||
Settings(SettingsCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ProjectsCommand {
|
||||
/// List all projects
|
||||
List,
|
||||
|
||||
/// Get project details by slug or UUID
|
||||
Get {
|
||||
/// Project slug or UUID
|
||||
#[arg(name = "ref")]
|
||||
reference: String,
|
||||
},
|
||||
|
||||
/// Create a new project
|
||||
Create {
|
||||
/// Project name
|
||||
name: String,
|
||||
|
||||
/// Short description
|
||||
#[arg(short = 's', long)]
|
||||
short_desc: String,
|
||||
|
||||
/// Full description
|
||||
#[arg(short = 'd', long)]
|
||||
desc: String,
|
||||
|
||||
/// URL slug (auto-generated from name if omitted)
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
|
||||
/// Project status
|
||||
#[arg(long, default_value = "active")]
|
||||
status: String,
|
||||
|
||||
/// GitHub repository (e.g., "Xevion/xevion.dev")
|
||||
#[arg(long)]
|
||||
github_repo: Option<String>,
|
||||
|
||||
/// Demo URL
|
||||
#[arg(long)]
|
||||
demo_url: Option<String>,
|
||||
|
||||
/// Tags to add (comma-separated, + prefix optional)
|
||||
#[arg(short = 't', long)]
|
||||
tags: Option<String>,
|
||||
},
|
||||
|
||||
/// Update an existing project
|
||||
Update {
|
||||
/// Project slug or UUID
|
||||
#[arg(name = "ref")]
|
||||
reference: String,
|
||||
|
||||
/// Project name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// URL slug
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
|
||||
/// Short description
|
||||
#[arg(short = 's', long)]
|
||||
short_desc: Option<String>,
|
||||
|
||||
/// Full description
|
||||
#[arg(short = 'd', long)]
|
||||
desc: Option<String>,
|
||||
|
||||
/// Project status
|
||||
#[arg(long)]
|
||||
status: Option<String>,
|
||||
|
||||
/// GitHub repository (use "" to clear)
|
||||
#[arg(long)]
|
||||
github_repo: Option<String>,
|
||||
|
||||
/// Demo URL (use "" to clear)
|
||||
#[arg(long)]
|
||||
demo_url: Option<String>,
|
||||
|
||||
/// Tag changes: +tag to add, -tag to remove (comma-separated)
|
||||
#[arg(short = 't', long)]
|
||||
tags: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a project
|
||||
Delete {
|
||||
/// Project slug or UUID
|
||||
#[arg(name = "ref")]
|
||||
reference: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum TagsCommand {
|
||||
/// List all tags with project counts
|
||||
List,
|
||||
|
||||
/// Get tag details with associated projects
|
||||
Get {
|
||||
/// Tag slug
|
||||
slug: String,
|
||||
},
|
||||
|
||||
/// Create a new tag
|
||||
Create {
|
||||
/// Tag name
|
||||
name: String,
|
||||
|
||||
/// URL slug (auto-generated from name if omitted)
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
|
||||
/// Icon identifier (e.g., "simple-icons:rust")
|
||||
#[arg(long)]
|
||||
icon: Option<String>,
|
||||
|
||||
/// Color hex without # (e.g., "3b82f6")
|
||||
#[arg(long)]
|
||||
color: Option<String>,
|
||||
},
|
||||
|
||||
/// Update an existing tag
|
||||
Update {
|
||||
/// Tag slug
|
||||
slug: String,
|
||||
|
||||
/// Tag name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// New URL slug
|
||||
#[arg(long = "new-slug")]
|
||||
new_slug: Option<String>,
|
||||
|
||||
/// Icon identifier (use "" to clear)
|
||||
#[arg(long)]
|
||||
icon: Option<String>,
|
||||
|
||||
/// Color hex (use "" to clear)
|
||||
#[arg(long)]
|
||||
color: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SettingsCommand {
|
||||
/// Get current site settings
|
||||
Get,
|
||||
|
||||
/// Update site settings
|
||||
Update {
|
||||
/// Display name
|
||||
#[arg(long)]
|
||||
display_name: Option<String>,
|
||||
|
||||
/// Occupation/title
|
||||
#[arg(long)]
|
||||
occupation: Option<String>,
|
||||
|
||||
/// Bio text
|
||||
#[arg(long)]
|
||||
bio: Option<String>,
|
||||
|
||||
/// Site title
|
||||
#[arg(long)]
|
||||
site_title: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tag operation for updates
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TagOp {
|
||||
Add(String),
|
||||
Remove(String),
|
||||
}
|
||||
|
||||
/// Parse tags for create command (+ prefix optional)
|
||||
pub fn parse_create_tags(s: &str) -> Vec<String> {
|
||||
s.split(',')
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.strip_prefix('+').unwrap_or(t).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse tags for update command (+ or - prefix required)
|
||||
pub fn parse_update_tags(s: &str) -> Result<Vec<TagOp>, String> {
|
||||
s.split(',')
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| {
|
||||
if let Some(tag) = t.strip_prefix('+') {
|
||||
if tag.is_empty() {
|
||||
Err("Tag name cannot be empty after '+'".to_string())
|
||||
} else {
|
||||
Ok(TagOp::Add(tag.to_string()))
|
||||
}
|
||||
} else if let Some(tag) = t.strip_prefix('-') {
|
||||
if tag.is_empty() {
|
||||
Err("Tag name cannot be empty after '-'".to_string())
|
||||
} else {
|
||||
Ok(TagOp::Remove(tag.to_string()))
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Tag '{}' requires prefix: use +{} to add, -{} to remove",
|
||||
t, t, t
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use nu_ansi_term::{Color, Style};
|
||||
|
||||
use crate::db::{ApiAdminProject, ApiSiteSettings, ApiTag, ApiTagWithCount};
|
||||
|
||||
/// Print a success message
|
||||
pub fn success(msg: &str) {
|
||||
println!("{} {}", Color::Green.paint("✓"), msg);
|
||||
}
|
||||
|
||||
/// Print an error message
|
||||
pub fn error(msg: &str) {
|
||||
eprintln!("{} {}", Color::Red.paint("✗"), msg);
|
||||
}
|
||||
|
||||
/// Print an info message
|
||||
pub fn info(msg: &str) {
|
||||
println!("{} {}", Color::Blue.paint("→"), msg);
|
||||
}
|
||||
|
||||
/// Print a project in formatted output
|
||||
pub fn print_project(project: &ApiAdminProject) {
|
||||
let header = Style::new().bold();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
println!("{}", header.paint(&project.project.name));
|
||||
println!(" {} {}", dim.paint("ID:"), project.project.id);
|
||||
println!(" {} {}", dim.paint("Slug:"), project.project.slug);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Status:"),
|
||||
format_status(&project.status)
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Description:"),
|
||||
project.project.short_description
|
||||
);
|
||||
|
||||
if let Some(ref repo) = project.github_repo {
|
||||
println!(" {} {}", dim.paint("GitHub:"), repo);
|
||||
}
|
||||
if let Some(ref url) = project.demo_url {
|
||||
println!(" {} {}", dim.paint("Demo:"), url);
|
||||
}
|
||||
|
||||
if !project.tags.is_empty() {
|
||||
let tags: Vec<_> = project.tags.iter().map(|t| t.slug.as_str()).collect();
|
||||
println!(" {} {}", dim.paint("Tags:"), tags.join(", "));
|
||||
}
|
||||
|
||||
println!(" {} {}", dim.paint("Updated:"), project.updated_at);
|
||||
}
|
||||
|
||||
/// Print a list of projects in table format
|
||||
pub fn print_projects_table(projects: &[ApiAdminProject]) {
|
||||
if projects.is_empty() {
|
||||
info("No projects found");
|
||||
return;
|
||||
}
|
||||
|
||||
let header = Style::new().bold().underline();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
// Calculate column widths
|
||||
let name_width = projects
|
||||
.iter()
|
||||
.map(|p| p.project.name.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let slug_width = projects
|
||||
.iter()
|
||||
.map(|p| p.project.slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
|
||||
// Header
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:10} {}",
|
||||
header.paint("NAME"),
|
||||
header.paint("SLUG"),
|
||||
header.paint("STATUS"),
|
||||
header.paint("TAGS"),
|
||||
);
|
||||
|
||||
// Rows
|
||||
for project in projects {
|
||||
let tags: Vec<_> = project.tags.iter().map(|t| t.slug.as_str()).collect();
|
||||
let tags_str = if tags.is_empty() {
|
||||
dim.paint("-").to_string()
|
||||
} else {
|
||||
tags.join(", ")
|
||||
};
|
||||
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:10} {}",
|
||||
project.project.name,
|
||||
dim.paint(&project.project.slug),
|
||||
format_status(&project.status),
|
||||
tags_str,
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
info(&format!("{} project(s)", projects.len()));
|
||||
}
|
||||
|
||||
/// Print a tag in formatted output
|
||||
pub fn print_tag(tag: &ApiTag) {
|
||||
let header = Style::new().bold();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
println!("{}", header.paint(&tag.name));
|
||||
println!(" {} {}", dim.paint("ID:"), tag.id);
|
||||
println!(" {} {}", dim.paint("Slug:"), tag.slug);
|
||||
|
||||
if let Some(ref icon) = tag.icon {
|
||||
println!(" {} {}", dim.paint("Icon:"), icon);
|
||||
}
|
||||
if let Some(ref color) = tag.color {
|
||||
println!(" {} #{}", dim.paint("Color:"), color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a list of tags in table format
|
||||
pub fn print_tags_table(tags: &[ApiTagWithCount]) {
|
||||
if tags.is_empty() {
|
||||
info("No tags found");
|
||||
return;
|
||||
}
|
||||
|
||||
let header = Style::new().bold().underline();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
// Calculate column widths
|
||||
let name_width = tags
|
||||
.iter()
|
||||
.map(|t| t.tag.name.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let slug_width = tags
|
||||
.iter()
|
||||
.map(|t| t.tag.slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
|
||||
// Header
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:8} {:20} {}",
|
||||
header.paint("NAME"),
|
||||
header.paint("SLUG"),
|
||||
header.paint("PROJECTS"),
|
||||
header.paint("ICON"),
|
||||
header.paint("COLOR"),
|
||||
);
|
||||
|
||||
// Rows
|
||||
for tag in tags {
|
||||
let icon = tag.tag.icon.as_deref().unwrap_or("-");
|
||||
let color = tag
|
||||
.tag
|
||||
.color
|
||||
.as_ref()
|
||||
.map(|c| format!("#{}", c))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:8} {:20} {}",
|
||||
tag.tag.name,
|
||||
dim.paint(&tag.tag.slug),
|
||||
tag.project_count,
|
||||
dim.paint(icon),
|
||||
dim.paint(&color),
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
info(&format!("{} tag(s)", tags.len()));
|
||||
}
|
||||
|
||||
/// Print site settings in formatted output
|
||||
pub fn print_settings(settings: &ApiSiteSettings) {
|
||||
let header = Style::new().bold();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
println!("{}", header.paint("Site Identity"));
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Display Name:"),
|
||||
settings.identity.display_name
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Occupation:"),
|
||||
settings.identity.occupation
|
||||
);
|
||||
println!(" {} {}", dim.paint("Bio:"), settings.identity.bio);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Site Title:"),
|
||||
settings.identity.site_title
|
||||
);
|
||||
|
||||
if !settings.social_links.is_empty() {
|
||||
println!();
|
||||
println!("{}", header.paint("Social Links"));
|
||||
for link in &settings.social_links {
|
||||
let visibility = if link.visible { "" } else { " (hidden)" };
|
||||
println!(
|
||||
" {} {}: {}{}",
|
||||
dim.paint(format!("[{}]", link.display_order)),
|
||||
link.label,
|
||||
link.value,
|
||||
dim.paint(visibility)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format project status with color
|
||||
fn format_status(status: &str) -> String {
|
||||
match status {
|
||||
"active" => Color::Green.paint(status).to_string(),
|
||||
"maintained" => Color::Blue.paint(status).to_string(),
|
||||
"archived" => Color::Yellow.paint(status).to_string(),
|
||||
"hidden" => Color::Red.paint(status).to_string(),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print session info
|
||||
pub fn print_session(username: &str, api_url: &str) {
|
||||
let dim = Style::new().dimmed();
|
||||
success(&format!(
|
||||
"Logged in as {}",
|
||||
Style::new().bold().paint(username)
|
||||
));
|
||||
println!(" {} {}", dim.paint("API:"), api_url);
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Seed the database with sample data
|
||||
pub async fn run(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Seeding database...");
|
||||
|
||||
// Clear existing data (tags will cascade delete project_tags and tag_cooccurrence)
|
||||
sqlx::query("DELETE FROM tags").execute(pool).await?;
|
||||
sqlx::query("DELETE FROM projects").execute(pool).await?;
|
||||
|
||||
// Seed projects with diverse data
|
||||
let projects = vec![
|
||||
(
|
||||
"xevion-dev",
|
||||
"xevion.dev",
|
||||
"Personal portfolio and project showcase",
|
||||
"Personal portfolio site with fuzzy tag discovery and ISR caching",
|
||||
"active",
|
||||
Some("Xevion/xevion.dev"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"contest",
|
||||
"Contest",
|
||||
"Competitive programming archive",
|
||||
"Archive and analysis platform for competitive programming problems",
|
||||
"active",
|
||||
Some("Xevion/contest"),
|
||||
Some("https://contest.xevion.dev"),
|
||||
),
|
||||
(
|
||||
"reforge",
|
||||
"Reforge",
|
||||
"Rocket League replay parser",
|
||||
"Rust library for parsing and manipulating Replay files from Rocket League",
|
||||
"maintained",
|
||||
Some("Xevion/reforge"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"algorithms",
|
||||
"Algorithms",
|
||||
"Algorithm implementations in Python",
|
||||
"Collection of algorithm implementations and data structures in Python",
|
||||
"archived",
|
||||
Some("Xevion/algorithms"),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"wordplay",
|
||||
"WordPlay",
|
||||
"Real-time multiplayer word game",
|
||||
"Interactive word game with real-time multiplayer using WebSockets",
|
||||
"maintained",
|
||||
Some("Xevion/wordplay"),
|
||||
Some("https://wordplay.example.com"),
|
||||
),
|
||||
(
|
||||
"dotfiles",
|
||||
"Dotfiles",
|
||||
"Development environment configs",
|
||||
"Personal configuration files and development environment setup scripts",
|
||||
"active",
|
||||
Some("Xevion/dotfiles"),
|
||||
None,
|
||||
),
|
||||
];
|
||||
|
||||
let project_count = projects.len();
|
||||
|
||||
for (slug, name, short_desc, desc, status, repo, demo) in projects {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO projects (slug, name, short_description, description, status, github_repo, demo_url)
|
||||
VALUES ($1, $2, $3, $4, $5::project_status, $6, $7)
|
||||
"#,
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(short_desc)
|
||||
.bind(desc)
|
||||
.bind(status)
|
||||
.bind(repo)
|
||||
.bind(demo)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
println!(" Seeded {} projects", project_count);
|
||||
|
||||
// Seed tags
|
||||
let tags = vec![
|
||||
("rust", "Rust", "simple-icons:rust"),
|
||||
("python", "Python", "simple-icons:python"),
|
||||
("typescript", "TypeScript", "simple-icons:typescript"),
|
||||
("javascript", "JavaScript", "simple-icons:javascript"),
|
||||
("web", "Web", "lucide:globe"),
|
||||
("cli", "CLI", "lucide:terminal"),
|
||||
("library", "Library", "lucide:package"),
|
||||
("game", "Game", "lucide:gamepad-2"),
|
||||
("data-structures", "Data Structures", "lucide:database"),
|
||||
("algorithms", "Algorithms", "lucide:cpu"),
|
||||
("multiplayer", "Multiplayer", "lucide:users"),
|
||||
("config", "Config", "lucide:settings"),
|
||||
];
|
||||
|
||||
let mut tag_ids = std::collections::HashMap::new();
|
||||
|
||||
for (slug, name, icon) in tags {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO tags (slug, name, icon)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
"#,
|
||||
slug,
|
||||
name,
|
||||
icon
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
tag_ids.insert(slug, result.id);
|
||||
}
|
||||
|
||||
println!(" Seeded {} tags", tag_ids.len());
|
||||
|
||||
// Associate tags with projects
|
||||
let project_tag_associations = vec![
|
||||
// xevion-dev
|
||||
("xevion-dev", vec!["rust", "web", "typescript"]),
|
||||
// Contest
|
||||
(
|
||||
"contest",
|
||||
vec!["python", "web", "algorithms", "data-structures"],
|
||||
),
|
||||
// Reforge
|
||||
("reforge", vec!["rust", "library", "game"]),
|
||||
// Algorithms
|
||||
(
|
||||
"algorithms",
|
||||
vec!["python", "algorithms", "data-structures"],
|
||||
),
|
||||
// WordPlay
|
||||
(
|
||||
"wordplay",
|
||||
vec!["typescript", "javascript", "web", "game", "multiplayer"],
|
||||
),
|
||||
// Dotfiles
|
||||
("dotfiles", vec!["config", "cli"]),
|
||||
];
|
||||
|
||||
let mut association_count = 0;
|
||||
|
||||
for (project_slug, tag_slugs) in project_tag_associations {
|
||||
let project_id = sqlx::query!("SELECT id FROM projects WHERE slug = $1", project_slug)
|
||||
.fetch_one(pool)
|
||||
.await?
|
||||
.id;
|
||||
|
||||
for tag_slug in tag_slugs {
|
||||
if let Some(&tag_id) = tag_ids.get(tag_slug) {
|
||||
sqlx::query!(
|
||||
"INSERT INTO project_tags (project_id, tag_id) VALUES ($1, $2)",
|
||||
project_id,
|
||||
tag_id
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
association_count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" Created {} project-tag associations", association_count);
|
||||
|
||||
// Recalculate tag cooccurrence
|
||||
sqlx::query!("DELETE FROM tag_cooccurrence")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
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?;
|
||||
|
||||
println!(" Recalculated tag cooccurrence");
|
||||
println!("Database seeded successfully!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer};
|
||||
|
||||
use crate::cache::{IsrCache, IsrCacheConfig};
|
||||
use crate::config::ListenAddr;
|
||||
use crate::middleware::RequestIdLayer;
|
||||
use crate::state::AppState;
|
||||
use crate::tarpit::{TarpitConfig, TarpitState};
|
||||
use crate::{auth, db, health, http, og, proxy, routes};
|
||||
|
||||
/// Run the web server
|
||||
pub async fn run(
|
||||
listen: Vec<ListenAddr>,
|
||||
downstream: String,
|
||||
trust_request_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Load database URL from environment (fail-fast)
|
||||
let database_url =
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment");
|
||||
|
||||
// Create connection pool
|
||||
let pool = db::create_pool(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
// Check and run migrations on startup
|
||||
let migrator = sqlx::migrate!();
|
||||
|
||||
// Query applied migrations directly from the database
|
||||
let applied_versions: HashSet<i64> =
|
||||
sqlx::query_scalar::<_, i64>("SELECT version FROM _sqlx_migrations ORDER BY version")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let pending: Vec<_> = migrator
|
||||
.iter()
|
||||
.filter(|m| !m.migration_type.is_down_migration())
|
||||
.filter(|m| !applied_versions.contains(&m.version))
|
||||
.map(|m| m.description.as_ref())
|
||||
.collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
let last_version = applied_versions.iter().max();
|
||||
let last_name = last_version
|
||||
.and_then(|v| migrator.iter().find(|m| m.version == *v))
|
||||
.map(|m| m.description.as_ref());
|
||||
tracing::debug!(last_migration = ?last_name, "Database schema is current");
|
||||
} else {
|
||||
tracing::warn!(migrations = ?pending, "Pending database migrations");
|
||||
}
|
||||
|
||||
migrator.run(&pool).await.unwrap_or_else(|e| {
|
||||
tracing::error!(error = %e, "Migration failed");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
if !pending.is_empty() {
|
||||
tracing::info!(count = pending.len(), "Migrations applied");
|
||||
}
|
||||
|
||||
// Ensure admin user exists
|
||||
auth::ensure_admin_user(&pool)
|
||||
.await
|
||||
.expect("Failed to ensure admin user exists");
|
||||
|
||||
// Initialize session manager
|
||||
let session_manager = Arc::new(
|
||||
auth::SessionManager::new(pool.clone())
|
||||
.await
|
||||
.expect("Failed to initialize session manager"),
|
||||
);
|
||||
|
||||
// Spawn background task to cleanup expired sessions
|
||||
tokio::spawn({
|
||||
let session_manager = session_manager.clone();
|
||||
async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(3600)); // Every hour
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(e) = session_manager.cleanup_expired().await {
|
||||
tracing::error!(error = %e, "Failed to cleanup expired sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if listen.is_empty() {
|
||||
eprintln!("Error: At least one --listen address is required");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Create socket-aware HTTP client
|
||||
let client = http::HttpClient::new(&downstream).expect("Failed to create HTTP client");
|
||||
|
||||
// Create health checker
|
||||
let client_for_health = client.clone();
|
||||
let pool_for_health = pool.clone();
|
||||
|
||||
let health_checker = Arc::new(health::HealthChecker::new(move || {
|
||||
let client = client_for_health.clone();
|
||||
let pool = pool_for_health.clone();
|
||||
|
||||
async move { proxy::perform_health_check(client, Some(pool)).await }
|
||||
}));
|
||||
|
||||
let tarpit_config = TarpitConfig::from_env();
|
||||
let tarpit_state = Arc::new(TarpitState::new(tarpit_config));
|
||||
|
||||
tracing::info!(
|
||||
enabled = tarpit_state.config.enabled,
|
||||
delay_min_ms = tarpit_state.config.delay_min_ms,
|
||||
delay_max_ms = tarpit_state.config.delay_max_ms,
|
||||
max_global = tarpit_state.config.max_global_connections,
|
||||
max_per_ip = tarpit_state.config.max_connections_per_ip,
|
||||
"Tarpit initialized"
|
||||
);
|
||||
|
||||
// Initialize ISR cache
|
||||
let isr_cache_config = IsrCacheConfig::from_env();
|
||||
let isr_cache = Arc::new(IsrCache::new(isr_cache_config.clone()));
|
||||
|
||||
tracing::info!(
|
||||
enabled = isr_cache_config.enabled,
|
||||
max_entries = isr_cache_config.max_entries,
|
||||
fresh_sec = isr_cache_config.fresh_duration.as_secs(),
|
||||
stale_sec = isr_cache_config.stale_duration.as_secs(),
|
||||
"ISR cache initialized"
|
||||
);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
client,
|
||||
health_checker,
|
||||
tarpit_state,
|
||||
pool: pool.clone(),
|
||||
session_manager: session_manager.clone(),
|
||||
isr_cache,
|
||||
});
|
||||
|
||||
// Regenerate common OGP images on startup
|
||||
tokio::spawn({
|
||||
let state = state.clone();
|
||||
async move {
|
||||
og::regenerate_common_images(state).await;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply middleware to router
|
||||
fn apply_middleware(
|
||||
router: axum::Router<Arc<AppState>>,
|
||||
trust_request_id: Option<String>,
|
||||
) -> axum::Router<Arc<AppState>> {
|
||||
router
|
||||
.layer(RequestIdLayer::new(trust_request_id))
|
||||
.layer(CorsLayer::permissive())
|
||||
.layer(RequestBodyLimitLayer::new(1_048_576))
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for listen_addr in &listen {
|
||||
let state = state.clone();
|
||||
let trust_request_id = trust_request_id.clone();
|
||||
let listen_addr = listen_addr.clone();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
match listen_addr {
|
||||
ListenAddr::Tcp(addr) => {
|
||||
let app = apply_middleware(
|
||||
routes::build_base_router().fallback(proxy::fallback_handler_tcp),
|
||||
trust_request_id,
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("Failed to bind TCP listener");
|
||||
|
||||
let url = if addr.is_ipv6() {
|
||||
format!("http://[{}]:{}", addr.ip(), addr.port())
|
||||
} else {
|
||||
format!("http://{}:{}", addr.ip(), addr.port())
|
||||
};
|
||||
|
||||
tracing::info!(url, "Listening on TCP");
|
||||
axum::serve(
|
||||
listener,
|
||||
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.expect("Server error on TCP listener");
|
||||
}
|
||||
ListenAddr::Unix(path) => {
|
||||
let app = apply_middleware(
|
||||
routes::build_base_router().fallback(proxy::fallback_handler_unix),
|
||||
trust_request_id,
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
let listener = tokio::net::UnixListener::bind(&path)
|
||||
.expect("Failed to bind Unix socket listener");
|
||||
|
||||
tracing::info!(socket = %path.display(), "Listening on Unix socket");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server error on Unix socket listener");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
task.await.expect("Listener task panicked");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user