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:
2026-01-13 19:55:45 -06:00
parent b6d377a143
commit aa56d31067
18 changed files with 1849 additions and 243 deletions
Vendored
+2 -1
View File
@@ -6,7 +6,8 @@ target/
web/build/
web/.svelte-kit/
# CLI session file
.xevion-session
# Added by cargo
/target
+4 -1
View File
@@ -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"
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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");
+128
View File
@@ -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(())
}
+21
View File
@@ -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,
}
}
+363
View File
@@ -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(&current.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()),
}
}
+88
View File
@@ -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(())
}
+172
View File
@@ -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(())
}
+226
View File
@@ -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
View File
@@ -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()
}
+242
View File
@@ -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);
}
+16 -20
View File
@@ -1,17 +1,12 @@
use sqlx::PgPool;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<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?;
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<dyn std::error::Error>> {
.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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
project_id,
tag_id
)
.execute(&pool)
.execute(pool)
.await?;
association_count += 1;
@@ -178,11 +173,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
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<dyn std::error::Error>> {
HAVING COUNT(*) > 0
"#
)
.execute(&pool)
.execute(pool)
.await?;
println!(" Recalculated tag cooccurrence");
println!(" Recalculated tag cooccurrence");
println!("Database seeded successfully!");
Ok(())
}
+226
View File
@@ -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(())
}
+3 -3
View File
@@ -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")]
+2 -2
View File
@@ -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<String>,
@@ -23,7 +23,7 @@ pub struct CreateTagRequest {
pub color: Option<String>,
}
#[derive(serde::Deserialize)]
#[derive(serde::Deserialize, serde::Serialize)]
pub struct UpdateTagRequest {
pub name: String,
pub slug: Option<String>,
+33 -194
View File
@@ -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();
// Load database URL from environment (fail-fast)
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");
// 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)
// Run migrations first
sqlx::migrate!()
.run(&pool)
.await
.unwrap_or_default()
.into_iter()
.collect();
.expect("Failed to run migrations");
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");
if let Err(e) = cli::seed::run(&pool).await {
eprintln!("Error: {}", e);
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");
}
}
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();
// Validate required server args
if args.listen.is_empty() {
eprintln!("Error: At least one --listen address is required");
eprintln!("Error: --listen is required when running the server");
eprintln!("Example: xevion --listen :8080 --downstream http://localhost:5173");
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;
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);
}
});
// 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 &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::<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");
if let Err(e) = cli::serve::run(args.listen, downstream, args.trust_request_id).await {
eprintln!("Server error: {}", e);
std::process::exit(1);
}
}
});
tasks.push(task);
}
for task in tasks {
task.await.expect("Listener task panicked");
}
}
+1 -1
View File
@@ -63,7 +63,7 @@ while (!existsSync(BUN_SOCKET)) {
console.log("Starting Rust API...");
const rustProc = spawn({
cmd: [
"/app/api",
"/app/xevion",
"--listen",
`[::]:${PORT}`,
"--listen",