diff --git a/.gitignore b/.gitignore index 0968773..bd9837b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,8 @@ target/ web/build/ web/.svelte-kit/ +# CLI session file +.xevion-session # Added by cargo - /target diff --git a/Cargo.toml b/Cargo.toml index 7e43e71..ed86f8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,10 @@ name = "api" version = "0.1.0" edition = "2024" -default-run = "api" + +[[bin]] +name = "xevion" +path = "src/main.rs" [dependencies] argon2 = "0.5" diff --git a/Dockerfile b/Dockerfile index fb3a694..a9f5674 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,7 @@ RUN mkdir -p web/build/client && \ echo "placeholder" > web/build/client/.gitkeep RUN cargo build --release && \ - strip target/release/api + strip target/release/xevion # ========== Stage 4: Frontend Builder ========== FROM oven/bun:1 AS frontend @@ -66,7 +66,7 @@ COPY --from=frontend /build/build/prerendered ./web/build/prerendered # Build with real assets (use sqlx offline mode) ENV SQLX_OFFLINE=true RUN cargo build --release && \ - strip target/release/api + strip target/release/xevion # ========== Stage 6: Runtime ========== FROM oven/bun:1-alpine AS runtime @@ -76,7 +76,7 @@ WORKDIR /app RUN apk add --no-cache ca-certificates tzdata # Copy Rust binary -COPY --from=final-builder /build/target/release/api ./api +COPY --from=final-builder /build/target/release/xevion ./xevion # Copy Bun SSR server and client assets (including fonts for OG images) COPY --from=frontend /build/build/server ./web/build/server diff --git a/Justfile b/Justfile index af66b0f..cc697af 100644 --- a/Justfile +++ b/Justfile @@ -95,13 +95,13 @@ dev: just dev-json | hl --config .hl.config.toml -P dev-json: - LOG_JSON=true UPSTREAM_URL=/tmp/xevion-api.sock bunx concurrently --raw --prefix none "bun run --silent --cwd web dev --port 5173" "cargo watch --quiet --exec 'run --quiet -- --listen localhost:8080 --listen /tmp/xevion-api.sock --downstream http://localhost:5173'" + LOG_JSON=true UPSTREAM_URL=/tmp/xevion-api.sock bunx concurrently --raw --prefix none "bun run --silent --cwd web dev --port 5173" "cargo watch --quiet --exec 'run --bin xevion --quiet -- --listen localhost:8080 --listen /tmp/xevion-api.sock --downstream http://localhost:5173'" serve: just serve-json | hl --config .hl.config.toml -P serve-json: - LOG_JSON=true UPSTREAM_URL=/tmp/xevion-api.sock bunx concurrently --raw --prefix none "SOCKET_PATH=/tmp/xevion-bun.sock bun --preload ../console-logger.js --silent --cwd web/build index.js" "target/release/api --listen localhost:8080 --listen /tmp/xevion-api.sock --downstream /tmp/xevion-bun.sock" + LOG_JSON=true UPSTREAM_URL=/tmp/xevion-api.sock bunx concurrently --raw --prefix none "SOCKET_PATH=/tmp/xevion-bun.sock bun --preload ../console-logger.js --silent --cwd web/build index.js" "target/release/xevion --listen localhost:8080 --listen /tmp/xevion-api.sock --downstream /tmp/xevion-bun.sock" docker-image: docker build -t xevion-dev . @@ -131,7 +131,7 @@ seed: if (migrate.status !== 0) process.exit(migrate.status); // Seed data - const seed = spawnSync("cargo", ["run", "--bin", "seed"], { stdio: "inherit" }); + const seed = spawnSync("cargo", ["run", "--bin", "xevion", "--", "seed"], { stdio: "inherit" }); if (seed.status !== 0) process.exit(seed.status); console.log("✅ Database ready with seed data"); diff --git a/src/cli/api/auth.rs b/src/cli/api/auth.rs new file mode 100644 index 0000000..f2dcb70 --- /dev/null +++ b/src/cli/api/auth.rs @@ -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> { + 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> { + // 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> { + // 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(()) +} diff --git a/src/cli/api/mod.rs b/src/cli/api/mod.rs new file mode 100644 index 0000000..5bc67ac --- /dev/null +++ b/src/cli/api/mod.rs @@ -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> { + 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, + } +} diff --git a/src/cli/api/projects.rs b/src/cli/api/projects.rs new file mode 100644 index 0000000..b248742 --- /dev/null +++ b/src/cli/api/projects.rs @@ -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, + short_description: String, + description: String, + status: ProjectStatus, + #[serde(skip_serializing_if = "Option::is_none")] + github_repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + demo_url: Option, + tag_ids: Vec, +} + +/// Request to update a project +#[derive(Serialize)] +struct UpdateProjectRequest { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + slug: Option, + short_description: String, + description: String, + status: ProjectStatus, + #[serde(skip_serializing_if = "Option::is_none")] + github_repo: Option, + #[serde(skip_serializing_if = "Option::is_none")] + demo_url: Option, + tag_ids: Vec, +} + +/// Run a projects subcommand +pub async fn run( + client: ApiClient, + command: ProjectsCommand, + json: bool, +) -> Result<(), Box> { + 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> { + let response = client.get_auth("/api/projects").await?; + let response = check_response(response).await?; + let projects: Vec = 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> { + 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, + status: &str, + github_repo: Option, + demo_url: Option, + tag_slugs: Vec, + json: bool, +) -> Result<(), Box> { + // 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, + slug: Option, + short_desc: Option, + desc: Option, + status: Option, + github_repo: Option, + demo_url: Option, + tag_ops: Vec, + json: bool, +) -> Result<(), Box> { + // First fetch the current project + let current = resolve_project(&client, reference).await?; + + // Apply tag operations + let mut current_tag_ids: Vec = 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> { + // 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> { + // 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 = 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, Box> { + 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> { + // 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> { + 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()), + } +} diff --git a/src/cli/api/settings.rs b/src/cli/api/settings.rs new file mode 100644 index 0000000..474471c --- /dev/null +++ b/src/cli/api/settings.rs @@ -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> { + 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> { + 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, + occupation: Option, + bio: Option, + site_title: Option, + json: bool, +) -> Result<(), Box> { + // 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(()) +} diff --git a/src/cli/api/tags.rs b/src/cli/api/tags.rs new file mode 100644 index 0000000..5f10956 --- /dev/null +++ b/src/cli/api/tags.rs @@ -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, +} + +/// Run a tags subcommand +pub async fn run( + client: ApiClient, + command: TagsCommand, + json: bool, +) -> Result<(), Box> { + 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> { + let response = client.get("/api/tags").await?; + let response = check_response(response).await?; + let tags: Vec = 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> { + 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, + icon: Option, + color: Option, + json: bool, +) -> Result<(), Box> { + // 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, + new_slug: Option, + icon: Option, + color: Option, + json: bool, +) -> Result<(), Box> { + // 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(()) +} diff --git a/src/cli/client.rs b/src/cli/client.rs new file mode 100644 index 0000000..e6d4454 --- /dev/null +++ b/src/cli/client.rs @@ -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, + 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 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 { + 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 { + 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 { + if !self.is_authenticated() { + return Err(ApiError::Unauthorized); + } + self.get(path).await + } + + /// Make a POST request with JSON body + pub async fn post(&self, path: &str, body: &T) -> Result { + 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( + &self, + path: &str, + body: &T, + ) -> Result { + if !self.is_authenticated() { + return Err(ApiError::Unauthorized); + } + self.post(path, body).await + } + + /// Make a PUT request with JSON body + pub async fn put(&self, path: &str, body: &T) -> Result { + 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(&self, path: &str, body: &T) -> Result { + 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 { + 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 { + 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 { + 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 }) + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..1cd8dba --- /dev/null +++ b/src/cli/mod.rs @@ -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, + + /// Address(es) to listen on (TCP or Unix socket) + #[arg(long, env = "LISTEN_ADDR", value_delimiter = ',')] + pub listen: Vec, + + /// Downstream SSR server URL + #[arg(long, env = "DOWNSTREAM_URL")] + pub downstream: Option, + + /// Trust X-Request-ID header from specified source + #[arg(long, env = "TRUST_REQUEST_ID")] + pub trust_request_id: Option, +} + +#[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, + + /// Project status + #[arg(long, default_value = "active")] + status: String, + + /// GitHub repository (e.g., "Xevion/xevion.dev") + #[arg(long)] + github_repo: Option, + + /// Demo URL + #[arg(long)] + demo_url: Option, + + /// Tags to add (comma-separated, + prefix optional) + #[arg(short = 't', long)] + tags: Option, + }, + + /// Update an existing project + Update { + /// Project slug or UUID + #[arg(name = "ref")] + reference: String, + + /// Project name + #[arg(short = 'n', long)] + name: Option, + + /// URL slug + #[arg(long)] + slug: Option, + + /// Short description + #[arg(short = 's', long)] + short_desc: Option, + + /// Full description + #[arg(short = 'd', long)] + desc: Option, + + /// Project status + #[arg(long)] + status: Option, + + /// GitHub repository (use "" to clear) + #[arg(long)] + github_repo: Option, + + /// Demo URL (use "" to clear) + #[arg(long)] + demo_url: Option, + + /// Tag changes: +tag to add, -tag to remove (comma-separated) + #[arg(short = 't', long)] + tags: Option, + }, + + /// 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, + + /// Icon identifier (e.g., "simple-icons:rust") + #[arg(long)] + icon: Option, + + /// Color hex without # (e.g., "3b82f6") + #[arg(long)] + color: Option, + }, + + /// Update an existing tag + Update { + /// Tag slug + slug: String, + + /// Tag name + #[arg(short = 'n', long)] + name: Option, + + /// New URL slug + #[arg(long = "new-slug")] + new_slug: Option, + + /// Icon identifier (use "" to clear) + #[arg(long)] + icon: Option, + + /// Color hex (use "" to clear) + #[arg(long)] + color: Option, + }, +} + +#[derive(Subcommand, Debug)] +pub enum SettingsCommand { + /// Get current site settings + Get, + + /// Update site settings + Update { + /// Display name + #[arg(long)] + display_name: Option, + + /// Occupation/title + #[arg(long)] + occupation: Option, + + /// Bio text + #[arg(long)] + bio: Option, + + /// Site title + #[arg(long)] + site_title: Option, + }, +} + +/// 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 { + 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, 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() +} diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 0000000..4d001a4 --- /dev/null +++ b/src/cli/output.rs @@ -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); +} diff --git a/src/bin/seed.rs b/src/cli/seed.rs similarity index 87% rename from src/bin/seed.rs rename to src/cli/seed.rs index 75d5766..875920c 100644 --- a/src/bin/seed.rs +++ b/src/cli/seed.rs @@ -1,17 +1,12 @@ use sqlx::PgPool; -#[tokio::main] -async fn main() -> Result<(), Box> { - dotenvy::dotenv().ok(); - - let database_url = std::env::var("DATABASE_URL")?; - let pool = PgPool::connect(&database_url).await?; - - println!("🌱 Seeding database..."); +/// Seed the database with sample data +pub async fn run(pool: &PgPool) -> Result<(), Box> { + 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?; + sqlx::query("DELETE FROM tags").execute(pool).await?; + sqlx::query("DELETE FROM projects").execute(pool).await?; // Seed projects with diverse data let projects = vec![ @@ -87,11 +82,11 @@ async fn main() -> Result<(), Box> { .bind(status) .bind(repo) .bind(demo) - .execute(&pool) + .execute(pool) .await?; } - println!("✅ Seeded {} projects", project_count); + println!(" Seeded {} projects", project_count); // Seed tags let tags = vec![ @@ -122,13 +117,13 @@ async fn main() -> Result<(), Box> { name, icon ) - .fetch_one(&pool) + .fetch_one(pool) .await?; tag_ids.insert(slug, result.id); } - println!("✅ Seeded {} tags", tag_ids.len()); + println!(" Seeded {} tags", tag_ids.len()); // Associate tags with projects let project_tag_associations = vec![ @@ -159,7 +154,7 @@ async fn main() -> Result<(), Box> { 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) + .fetch_one(pool) .await? .id; @@ -170,7 +165,7 @@ async fn main() -> Result<(), Box> { project_id, tag_id ) - .execute(&pool) + .execute(pool) .await?; association_count += 1; @@ -178,11 +173,11 @@ async fn main() -> Result<(), Box> { } } - println!("✅ Created {} project-tag associations", association_count); + println!(" Created {} project-tag associations", association_count); // Recalculate tag cooccurrence sqlx::query!("DELETE FROM tag_cooccurrence") - .execute(&pool) + .execute(pool) .await?; sqlx::query!( @@ -199,10 +194,11 @@ async fn main() -> Result<(), Box> { HAVING COUNT(*) > 0 "# ) - .execute(&pool) + .execute(pool) .await?; - println!("✅ Recalculated tag cooccurrence"); + println!(" Recalculated tag cooccurrence"); + println!("Database seeded successfully!"); Ok(()) } diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 0000000..28c91ad --- /dev/null +++ b/src/cli/serve.rs @@ -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, + downstream: String, + trust_request_id: Option, +) -> Result<(), Box> { + // 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 = + 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>, + trust_request_id: Option, + ) -> axum::Router> { + 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::(), + ) + .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(()) +} diff --git a/src/db/settings.rs b/src/db/settings.rs index fb9af00..20e6906 100644 --- a/src/db/settings.rs +++ b/src/db/settings.rs @@ -60,7 +60,7 @@ pub struct ApiSiteSettings { } // Request types for updates -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct UpdateSiteIdentityRequest { #[serde(rename = "displayName")] pub display_name: String, @@ -70,7 +70,7 @@ pub struct UpdateSiteIdentityRequest { pub site_title: String, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct UpdateSocialLinkRequest { pub id: String, pub platform: String, @@ -82,7 +82,7 @@ pub struct UpdateSocialLinkRequest { pub display_order: i32, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct UpdateSiteSettingsRequest { pub identity: UpdateSiteIdentityRequest, #[serde(rename = "socialLinks")] diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 8930df6..2ec3e04 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -15,7 +15,7 @@ pub use tags::*; // Request/Response types used by handlers -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, serde::Serialize)] pub struct CreateTagRequest { pub name: String, pub slug: Option, @@ -23,7 +23,7 @@ pub struct CreateTagRequest { pub color: Option, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, serde::Serialize)] pub struct UpdateTagRequest { pub name: String, pub slug: Option, diff --git a/src/main.rs b/src/main.rs index a55d256..04f852b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,10 @@ use clap::Parser; -use std::collections::HashSet; -use std::net::SocketAddr; -use std::sync::Arc; -use std::time::Duration; -use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer}; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; mod assets; mod auth; mod cache; +mod cli; mod config; mod db; mod formatter; @@ -24,13 +20,8 @@ mod state; mod tarpit; mod utils; -use cache::{IsrCache, IsrCacheConfig}; -use config::{Args, ListenAddr}; +use cli::{Cli, Command}; use formatter::{CustomJsonFormatter, CustomPrettyFormatter}; -use health::HealthChecker; -use middleware::RequestIdLayer; -use state::AppState; -use tarpit::{TarpitConfig, TarpitState}; fn init_tracing() { let use_json = std::env::var("LOG_JSON") @@ -75,212 +66,60 @@ async fn main() { dotenvy::dotenv().ok(); // Parse args early to allow --help to work without database - let args = Args::parse(); + let args = Cli::parse(); - init_tracing(); + match args.command { + Some(Command::Seed) => { + // Seed command - connect to database and run seeding + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment"); - // Load database URL from environment (fail-fast) - let database_url = - std::env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment"); + let pool = db::create_pool(&database_url) + .await + .expect("Failed to connect to database"); - // Create connection pool - let pool = db::create_pool(&database_url) - .await - .expect("Failed to connect to database"); + // Run migrations first + sqlx::migrate!() + .run(&pool) + .await + .expect("Failed to run migrations"); - // Check and run migrations on startup - let migrator = sqlx::migrate!(); - - // Query applied migrations directly from the database - let applied_versions: HashSet = - 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 let Err(e) = cli::seed::run(&pool).await { + eprintln!("Error: {}", e); + std::process::exit(1); } } - }); - - if args.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(&args.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(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>, - trust_request_id: Option, - ) -> axum::Router> { - 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 &args.listen { - let state = state.clone(); - let trust_request_id = args.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::(), - ) - .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"); - } + Some(Command::Api(api_args)) => { + // API client commands - no tracing needed + if let Err(e) = cli::api::run(api_args).await { + eprintln!("Error: {}", e); + std::process::exit(1); } - }); + } + None => { + // No subcommand - run the server + init_tracing(); - tasks.push(task); - } + // Validate required server args + if args.listen.is_empty() { + eprintln!("Error: --listen is required when running the server"); + eprintln!("Example: xevion --listen :8080 --downstream http://localhost:5173"); + std::process::exit(1); + } - for task in tasks { - task.await.expect("Listener task panicked"); + let downstream = match args.downstream { + Some(d) => d, + None => { + eprintln!("Error: --downstream is required when running the server"); + eprintln!("Example: xevion --listen :8080 --downstream http://localhost:5173"); + std::process::exit(1); + } + }; + + if let Err(e) = cli::serve::run(args.listen, downstream, args.trust_request_id).await { + eprintln!("Server error: {}", e); + std::process::exit(1); + } + } } } diff --git a/web/entrypoint.ts b/web/entrypoint.ts index ee2693a..84556a9 100644 --- a/web/entrypoint.ts +++ b/web/entrypoint.ts @@ -63,7 +63,7 @@ while (!existsSync(BUN_SOCKET)) { console.log("Starting Rust API..."); const rustProc = spawn({ cmd: [ - "/app/api", + "/app/xevion", "--listen", `[::]:${PORT}`, "--listen",