mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
feat: add comprehensive CLI with API client, session management, and data seeding
- Binary renamed from 'api' to 'xevion' with full CLI command structure - Authentication: login/logout with session persistence to .xevion-session - API commands: projects, tags, settings CRUD operations with JSON/table output - Serve command: run production server with configurable listen addresses - Seed command: moved from bin/ to CLI subcommand for database initialization - HTTP client abstraction supporting both TCP and Unix socket connections
This commit is contained in:
Vendored
+2
-1
@@ -6,7 +6,8 @@ target/
|
||||
web/build/
|
||||
web/.svelte-kit/
|
||||
|
||||
# CLI session file
|
||||
.xevion-session
|
||||
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
||||
+4
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use crate::cli::client::{ApiClient, Session, check_response};
|
||||
use crate::cli::output;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
success: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SessionResponse {
|
||||
authenticated: bool,
|
||||
username: String,
|
||||
}
|
||||
|
||||
/// Login and save session
|
||||
pub async fn login(
|
||||
mut client: ApiClient,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let request = LoginRequest {
|
||||
username: username.to_string(),
|
||||
password: password.to_string(),
|
||||
};
|
||||
|
||||
let response = client.post("/api/login", &request).await?;
|
||||
|
||||
// Extract session cookie from response headers
|
||||
let session_token = response
|
||||
.headers()
|
||||
.get_all("set-cookie")
|
||||
.iter()
|
||||
.find_map(|v| {
|
||||
let s = v.to_str().ok()?;
|
||||
if s.starts_with("admin_session=") {
|
||||
// Extract just the token value
|
||||
let token = s.strip_prefix("admin_session=")?.split(';').next()?;
|
||||
Some(token.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let response = check_response(response).await?;
|
||||
let login_response: LoginResponse = response.json().await?;
|
||||
|
||||
if !login_response.success {
|
||||
output::error("Login failed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let session_token = session_token.ok_or("No session cookie received")?;
|
||||
|
||||
let session = Session {
|
||||
api_url: client.api_url.clone(),
|
||||
session_token,
|
||||
username: login_response.username.clone(),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
expires_at: OffsetDateTime::now_utc() + Duration::days(7),
|
||||
};
|
||||
|
||||
client.save_session(session)?;
|
||||
|
||||
output::success(&format!("Logged in as {}", login_response.username));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear saved session
|
||||
pub async fn logout(mut client: ApiClient) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Try to call logout endpoint if we have a session
|
||||
if client.is_authenticated() {
|
||||
let _ = client.post("/api/logout", &()).await;
|
||||
}
|
||||
|
||||
client.clear_session()?;
|
||||
output::success("Logged out");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check current session status
|
||||
pub async fn session(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First check local session
|
||||
if let Some(session) = client.session() {
|
||||
// Verify with server
|
||||
let response = client.get("/api/session").await?;
|
||||
let response = check_response(response).await?;
|
||||
let session_response: SessionResponse = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"authenticated": session_response.authenticated,
|
||||
"username": session_response.username,
|
||||
"api_url": session.api_url,
|
||||
"expires_at": session.expires_at.format(&time::format_description::well_known::Rfc3339).unwrap(),
|
||||
})
|
||||
);
|
||||
} else if session_response.authenticated {
|
||||
output::print_session(&session_response.username, &session.api_url);
|
||||
} else {
|
||||
output::error("Session expired or invalid");
|
||||
}
|
||||
} else {
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"authenticated": false,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
output::info("Not logged in");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub mod auth;
|
||||
pub mod projects;
|
||||
pub mod settings;
|
||||
pub mod tags;
|
||||
|
||||
use crate::cli::client::ApiClient;
|
||||
use crate::cli::{ApiArgs, ApiCommand};
|
||||
|
||||
/// Run an API subcommand
|
||||
pub async fn run(args: ApiArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let client = ApiClient::new(args.api_url, args.session);
|
||||
|
||||
match args.command {
|
||||
ApiCommand::Login { username, password } => auth::login(client, &username, &password).await,
|
||||
ApiCommand::Logout => auth::logout(client).await,
|
||||
ApiCommand::Session => auth::session(client, args.json).await,
|
||||
ApiCommand::Projects(cmd) => projects::run(client, cmd, args.json).await,
|
||||
ApiCommand::Tags(cmd) => tags::run(client, cmd, args.json).await,
|
||||
ApiCommand::Settings(cmd) => settings::run(client, cmd, args.json).await,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cli::client::{ApiClient, ApiError, check_response};
|
||||
use crate::cli::output;
|
||||
use crate::cli::{ProjectsCommand, TagOp, parse_create_tags, parse_update_tags};
|
||||
use crate::db::{ApiAdminProject, ApiTag, ProjectStatus};
|
||||
|
||||
/// Request to create a project
|
||||
#[derive(Serialize)]
|
||||
struct CreateProjectRequest {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slug: Option<String>,
|
||||
short_description: String,
|
||||
description: String,
|
||||
status: ProjectStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
github_repo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
demo_url: Option<String>,
|
||||
tag_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Request to update a project
|
||||
#[derive(Serialize)]
|
||||
struct UpdateProjectRequest {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
slug: Option<String>,
|
||||
short_description: String,
|
||||
description: String,
|
||||
status: ProjectStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
github_repo: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
demo_url: Option<String>,
|
||||
tag_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Run a projects subcommand
|
||||
pub async fn run(
|
||||
client: ApiClient,
|
||||
command: ProjectsCommand,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
ProjectsCommand::List => list(client, json).await,
|
||||
ProjectsCommand::Get { reference } => get(client, &reference, json).await,
|
||||
ProjectsCommand::Create {
|
||||
name,
|
||||
short_desc,
|
||||
desc,
|
||||
slug,
|
||||
status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tags,
|
||||
} => {
|
||||
let tag_slugs = tags.map(|s| parse_create_tags(&s)).unwrap_or_default();
|
||||
create(
|
||||
client,
|
||||
&name,
|
||||
&short_desc,
|
||||
&desc,
|
||||
slug,
|
||||
&status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tag_slugs,
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ProjectsCommand::Update {
|
||||
reference,
|
||||
name,
|
||||
slug,
|
||||
short_desc,
|
||||
desc,
|
||||
status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tags,
|
||||
} => {
|
||||
let tag_ops = match tags {
|
||||
Some(s) => parse_update_tags(&s)?,
|
||||
None => vec![],
|
||||
};
|
||||
update(
|
||||
client,
|
||||
&reference,
|
||||
name,
|
||||
slug,
|
||||
short_desc,
|
||||
desc,
|
||||
status,
|
||||
github_repo,
|
||||
demo_url,
|
||||
tag_ops,
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
ProjectsCommand::Delete { reference } => delete(client, &reference, json).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all projects
|
||||
async fn list(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get_auth("/api/projects").await?;
|
||||
let response = check_response(response).await?;
|
||||
let projects: Vec<ApiAdminProject> = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&projects)?);
|
||||
} else {
|
||||
output::print_projects_table(&projects);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a project by slug or UUID
|
||||
async fn get(
|
||||
client: ApiClient,
|
||||
reference: &str,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let project = resolve_project(&client, reference).await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&project)?);
|
||||
} else {
|
||||
output::print_project(&project);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new project
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn create(
|
||||
client: ApiClient,
|
||||
name: &str,
|
||||
short_desc: &str,
|
||||
desc: &str,
|
||||
slug: Option<String>,
|
||||
status: &str,
|
||||
github_repo: Option<String>,
|
||||
demo_url: Option<String>,
|
||||
tag_slugs: Vec<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Resolve tag slugs to IDs
|
||||
let tag_ids = resolve_tag_ids(&client, &tag_slugs).await?;
|
||||
|
||||
let status = parse_status(status)?;
|
||||
|
||||
let request = CreateProjectRequest {
|
||||
name: name.to_string(),
|
||||
slug,
|
||||
short_description: short_desc.to_string(),
|
||||
description: desc.to_string(),
|
||||
status,
|
||||
github_repo: github_repo.filter(|s| !s.is_empty()),
|
||||
demo_url: demo_url.filter(|s| !s.is_empty()),
|
||||
tag_ids,
|
||||
};
|
||||
|
||||
let response = client.post_auth("/api/projects", &request).await?;
|
||||
let response = check_response(response).await?;
|
||||
let project: ApiAdminProject = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&project)?);
|
||||
} else {
|
||||
output::success(&format!("Created project: {}", project.project.name));
|
||||
output::print_project(&project);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an existing project
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn update(
|
||||
client: ApiClient,
|
||||
reference: &str,
|
||||
name: Option<String>,
|
||||
slug: Option<String>,
|
||||
short_desc: Option<String>,
|
||||
desc: Option<String>,
|
||||
status: Option<String>,
|
||||
github_repo: Option<String>,
|
||||
demo_url: Option<String>,
|
||||
tag_ops: Vec<TagOp>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First fetch the current project
|
||||
let current = resolve_project(&client, reference).await?;
|
||||
|
||||
// Apply tag operations
|
||||
let mut current_tag_ids: Vec<String> = current.tags.iter().map(|t| t.id.clone()).collect();
|
||||
|
||||
for op in tag_ops {
|
||||
match op {
|
||||
TagOp::Add(slug_or_id) => {
|
||||
let tag_id = resolve_tag_id(&client, &slug_or_id).await?;
|
||||
if !current_tag_ids.contains(&tag_id) {
|
||||
current_tag_ids.push(tag_id);
|
||||
}
|
||||
}
|
||||
TagOp::Remove(slug_or_id) => {
|
||||
let tag_id = resolve_tag_id(&client, &slug_or_id).await?;
|
||||
current_tag_ids.retain(|id| id != &tag_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge updates with current values
|
||||
let status = if let Some(s) = status {
|
||||
parse_status(&s)?
|
||||
} else {
|
||||
parse_status(¤t.status)?
|
||||
};
|
||||
|
||||
let request = UpdateProjectRequest {
|
||||
name: name.unwrap_or(current.project.name),
|
||||
slug,
|
||||
short_description: short_desc.unwrap_or(current.project.short_description),
|
||||
description: desc.unwrap_or(current.description),
|
||||
status,
|
||||
github_repo: match github_repo {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.github_repo,
|
||||
},
|
||||
demo_url: match demo_url {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.demo_url,
|
||||
},
|
||||
tag_ids: current_tag_ids,
|
||||
};
|
||||
|
||||
let response = client
|
||||
.put_auth(&format!("/api/projects/{}", current.project.id), &request)
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
let project: ApiAdminProject = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&project)?);
|
||||
} else {
|
||||
output::success(&format!("Updated project: {}", project.project.name));
|
||||
output::print_project(&project);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a project
|
||||
async fn delete(
|
||||
client: ApiClient,
|
||||
reference: &str,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First resolve to get the ID
|
||||
let project = resolve_project(&client, reference).await?;
|
||||
|
||||
let response = client
|
||||
.delete_auth(&format!("/api/projects/{}", project.project.id))
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
let deleted: ApiAdminProject = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&deleted)?);
|
||||
} else {
|
||||
output::success(&format!("Deleted project: {}", deleted.project.name));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve a project reference (slug or UUID) to a full project
|
||||
async fn resolve_project(
|
||||
client: &ApiClient,
|
||||
reference: &str,
|
||||
) -> Result<ApiAdminProject, Box<dyn std::error::Error>> {
|
||||
// Try as UUID first
|
||||
if Uuid::parse_str(reference).is_ok() {
|
||||
let response = client
|
||||
.get_auth(&format!("/api/projects/{}", reference))
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
return Ok(response.json().await?);
|
||||
}
|
||||
|
||||
// Otherwise search by slug in the list
|
||||
let response = client.get_auth("/api/projects").await?;
|
||||
let response = check_response(response).await?;
|
||||
let projects: Vec<ApiAdminProject> = response.json().await?;
|
||||
|
||||
projects
|
||||
.into_iter()
|
||||
.find(|p| p.project.slug == reference)
|
||||
.ok_or_else(|| ApiError::Parse(format!("Project not found: {}", reference)).into())
|
||||
}
|
||||
|
||||
/// Resolve tag slugs to IDs
|
||||
async fn resolve_tag_ids(
|
||||
client: &ApiClient,
|
||||
slugs: &[String],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
if slugs.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for slug in slugs {
|
||||
ids.push(resolve_tag_id(client, slug).await?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Resolve a single tag slug or ID to an ID
|
||||
async fn resolve_tag_id(
|
||||
client: &ApiClient,
|
||||
slug_or_id: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try as UUID first
|
||||
if Uuid::parse_str(slug_or_id).is_ok() {
|
||||
return Ok(slug_or_id.to_string());
|
||||
}
|
||||
|
||||
// Otherwise look up by slug
|
||||
#[derive(Deserialize)]
|
||||
struct TagResponse {
|
||||
tag: ApiTag,
|
||||
}
|
||||
|
||||
let response = client.get(&format!("/api/tags/{}", slug_or_id)).await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag_response: TagResponse = response.json().await?;
|
||||
Ok(tag_response.tag.id)
|
||||
}
|
||||
|
||||
/// Parse status string to ProjectStatus
|
||||
fn parse_status(s: &str) -> Result<ProjectStatus, Box<dyn std::error::Error>> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"active" => Ok(ProjectStatus::Active),
|
||||
"maintained" => Ok(ProjectStatus::Maintained),
|
||||
"archived" => Ok(ProjectStatus::Archived),
|
||||
"hidden" => Ok(ProjectStatus::Hidden),
|
||||
_ => Err(format!(
|
||||
"Invalid status '{}'. Valid values: active, maintained, archived, hidden",
|
||||
s
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::cli::SettingsCommand;
|
||||
use crate::cli::client::{ApiClient, check_response};
|
||||
use crate::cli::output;
|
||||
use crate::db::{ApiSiteSettings, UpdateSiteIdentityRequest, UpdateSiteSettingsRequest};
|
||||
|
||||
/// Run a settings subcommand
|
||||
pub async fn run(
|
||||
client: ApiClient,
|
||||
command: SettingsCommand,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
SettingsCommand::Get => get(client, json).await,
|
||||
SettingsCommand::Update {
|
||||
display_name,
|
||||
occupation,
|
||||
bio,
|
||||
site_title,
|
||||
} => update(client, display_name, occupation, bio, site_title, json).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current site settings
|
||||
async fn get(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get("/api/settings").await?;
|
||||
let response = check_response(response).await?;
|
||||
let settings: ApiSiteSettings = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||
} else {
|
||||
output::print_settings(&settings);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update site settings
|
||||
async fn update(
|
||||
client: ApiClient,
|
||||
display_name: Option<String>,
|
||||
occupation: Option<String>,
|
||||
bio: Option<String>,
|
||||
site_title: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// First fetch current settings
|
||||
let response = client.get("/api/settings").await?;
|
||||
let response = check_response(response).await?;
|
||||
let current: ApiSiteSettings = response.json().await?;
|
||||
|
||||
// Merge updates
|
||||
let request = UpdateSiteSettingsRequest {
|
||||
identity: UpdateSiteIdentityRequest {
|
||||
display_name: display_name.unwrap_or(current.identity.display_name),
|
||||
occupation: occupation.unwrap_or(current.identity.occupation),
|
||||
bio: bio.unwrap_or(current.identity.bio),
|
||||
site_title: site_title.unwrap_or(current.identity.site_title),
|
||||
},
|
||||
// Keep existing social links unchanged
|
||||
social_links: current
|
||||
.social_links
|
||||
.into_iter()
|
||||
.map(|link| crate::db::UpdateSocialLinkRequest {
|
||||
id: link.id,
|
||||
platform: link.platform,
|
||||
label: link.label,
|
||||
value: link.value,
|
||||
icon: link.icon,
|
||||
visible: link.visible,
|
||||
display_order: link.display_order,
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let response = client.put_auth("/api/settings", &request).await?;
|
||||
let response = check_response(response).await?;
|
||||
let settings: ApiSiteSettings = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&settings)?);
|
||||
} else {
|
||||
output::success("Updated site settings");
|
||||
output::print_settings(&settings);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::cli::TagsCommand;
|
||||
use crate::cli::client::{ApiClient, check_response};
|
||||
use crate::cli::output;
|
||||
use crate::db::{ApiTag, ApiTagWithCount};
|
||||
use crate::handlers::{CreateTagRequest, UpdateTagRequest};
|
||||
|
||||
/// Response for get tag endpoint
|
||||
#[derive(Deserialize)]
|
||||
struct GetTagResponse {
|
||||
tag: ApiTag,
|
||||
projects: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run a tags subcommand
|
||||
pub async fn run(
|
||||
client: ApiClient,
|
||||
command: TagsCommand,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
TagsCommand::List => list(client, json).await,
|
||||
TagsCommand::Get { slug } => get(client, &slug, json).await,
|
||||
TagsCommand::Create {
|
||||
name,
|
||||
slug,
|
||||
icon,
|
||||
color,
|
||||
} => create(client, &name, slug, icon, color, json).await,
|
||||
TagsCommand::Update {
|
||||
slug,
|
||||
name,
|
||||
new_slug,
|
||||
icon,
|
||||
color,
|
||||
} => update(client, &slug, name, new_slug, icon, color, json).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all tags
|
||||
async fn list(client: ApiClient, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get("/api/tags").await?;
|
||||
let response = check_response(response).await?;
|
||||
let tags: Vec<ApiTagWithCount> = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&tags)?);
|
||||
} else {
|
||||
output::print_tags_table(&tags);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a tag by slug
|
||||
async fn get(client: ApiClient, slug: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let response = client.get(&format!("/api/tags/{}", slug)).await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag_response: GetTagResponse = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"tag": tag_response.tag,
|
||||
"projects": tag_response.projects,
|
||||
}))?
|
||||
);
|
||||
} else {
|
||||
output::print_tag(&tag_response.tag);
|
||||
if !tag_response.projects.is_empty() {
|
||||
output::info(&format!(
|
||||
"{} associated project(s)",
|
||||
tag_response.projects.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new tag
|
||||
async fn create(
|
||||
client: ApiClient,
|
||||
name: &str,
|
||||
slug: Option<String>,
|
||||
icon: Option<String>,
|
||||
color: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Validate color if provided
|
||||
if let Some(ref c) = color {
|
||||
if !c.chars().all(|ch| ch.is_ascii_hexdigit()) || c.len() != 6 {
|
||||
return Err("Color must be a 6-character hex string (e.g., '3b82f6')".into());
|
||||
}
|
||||
}
|
||||
|
||||
let request = CreateTagRequest {
|
||||
name: name.to_string(),
|
||||
slug,
|
||||
icon: icon.filter(|s| !s.is_empty()),
|
||||
color: color.filter(|s| !s.is_empty()),
|
||||
};
|
||||
|
||||
let response = client.post_auth("/api/tags", &request).await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag: ApiTag = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&tag)?);
|
||||
} else {
|
||||
output::success(&format!("Created tag: {}", tag.name));
|
||||
output::print_tag(&tag);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update an existing tag
|
||||
async fn update(
|
||||
client: ApiClient,
|
||||
slug: &str,
|
||||
name: Option<String>,
|
||||
new_slug: Option<String>,
|
||||
icon: Option<String>,
|
||||
color: Option<String>,
|
||||
json: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Validate color if provided
|
||||
if let Some(ref c) = color {
|
||||
if !c.is_empty() && (!c.chars().all(|ch| ch.is_ascii_hexdigit()) || c.len() != 6) {
|
||||
return Err("Color must be a 6-character hex string (e.g., '3b82f6')".into());
|
||||
}
|
||||
}
|
||||
|
||||
// First fetch the current tag
|
||||
let response = client.get(&format!("/api/tags/{}", slug)).await?;
|
||||
let response = check_response(response).await?;
|
||||
let current: GetTagResponse = response.json().await?;
|
||||
|
||||
// Merge updates
|
||||
let request = UpdateTagRequest {
|
||||
name: name.unwrap_or(current.tag.name),
|
||||
slug: new_slug,
|
||||
icon: match icon {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.tag.icon,
|
||||
},
|
||||
color: match color {
|
||||
Some(s) if s.is_empty() => None,
|
||||
Some(s) => Some(s),
|
||||
None => current.tag.color,
|
||||
},
|
||||
};
|
||||
|
||||
let response = client
|
||||
.put_auth(&format!("/api/tags/{}", slug), &request)
|
||||
.await?;
|
||||
let response = check_response(response).await?;
|
||||
let tag: ApiTag = response.json().await?;
|
||||
|
||||
if json {
|
||||
println!("{}", serde_json::to_string_pretty(&tag)?);
|
||||
} else {
|
||||
output::success(&format!("Updated tag: {}", tag.name));
|
||||
output::print_tag(&tag);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use reqwest::{Client, Response, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
/// Session data stored in the session file
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub api_url: String,
|
||||
pub session_token: String,
|
||||
pub username: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// API client with session management
|
||||
pub struct ApiClient {
|
||||
pub api_url: String,
|
||||
session_path: String,
|
||||
session: Option<Session>,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
/// HTTP request failed
|
||||
Request(reqwest::Error),
|
||||
/// Server returned an error response
|
||||
Http { status: StatusCode, body: String },
|
||||
/// Failed to parse response
|
||||
Parse(String),
|
||||
/// Session file error
|
||||
Session(String),
|
||||
/// Not authenticated
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ApiError::Request(e) => write!(f, "Request failed: {}", e),
|
||||
ApiError::Http { status, body } => {
|
||||
write!(f, "HTTP {}: {}", status, body)
|
||||
}
|
||||
ApiError::Parse(msg) => write!(f, "Parse error: {}", msg),
|
||||
ApiError::Session(msg) => write!(f, "Session error: {}", msg),
|
||||
ApiError::Unauthorized => write!(f, "Not authenticated. Run 'xevion api login' first."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
impl From<reqwest::Error> for ApiError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
ApiError::Request(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
/// Create a new API client
|
||||
pub fn new(api_url: String, session_path: String) -> Self {
|
||||
let session = Self::load_session_from_path(&session_path);
|
||||
Self {
|
||||
api_url,
|
||||
session_path,
|
||||
session,
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_session_from_path(path: &str) -> Option<Session> {
|
||||
let path = Path::new(path);
|
||||
if !path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let session: Session = serde_json::from_str(&content).ok()?;
|
||||
|
||||
// Check if session is expired
|
||||
if session.expires_at < OffsetDateTime::now_utc() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(session)
|
||||
}
|
||||
|
||||
/// Save session to file
|
||||
pub fn save_session(&mut self, session: Session) -> Result<(), ApiError> {
|
||||
let content = serde_json::to_string_pretty(&session)
|
||||
.map_err(|e| ApiError::Session(format!("Failed to serialize session: {}", e)))?;
|
||||
|
||||
std::fs::write(&self.session_path, content)
|
||||
.map_err(|e| ApiError::Session(format!("Failed to write session file: {}", e)))?;
|
||||
|
||||
self.session = Some(session);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the session
|
||||
pub fn clear_session(&mut self) -> Result<(), ApiError> {
|
||||
let path = Path::new(&self.session_path);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)
|
||||
.map_err(|e| ApiError::Session(format!("Failed to remove session file: {}", e)))?;
|
||||
}
|
||||
self.session = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current session if valid
|
||||
pub fn session(&self) -> Option<&Session> {
|
||||
self.session.as_ref()
|
||||
}
|
||||
|
||||
/// Check if we have a valid session
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
/// Build the full URL for an endpoint
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}{}", self.api_url, path)
|
||||
}
|
||||
|
||||
/// Make a GET request
|
||||
pub async fn get(&self, path: &str) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.get(self.url(path));
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated GET request (fails if not authenticated)
|
||||
pub async fn get_auth(&self, path: &str) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.get(path).await
|
||||
}
|
||||
|
||||
/// Make a POST request with JSON body
|
||||
pub async fn post<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.post(self.url(path)).json(body);
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated POST request
|
||||
pub async fn post_auth<T: Serialize>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &T,
|
||||
) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.post(path, body).await
|
||||
}
|
||||
|
||||
/// Make a PUT request with JSON body
|
||||
pub async fn put<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.put(self.url(path)).json(body);
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated PUT request
|
||||
pub async fn put_auth<T: Serialize>(&self, path: &str, body: &T) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.put(path, body).await
|
||||
}
|
||||
|
||||
/// Make a DELETE request
|
||||
pub async fn delete(&self, path: &str) -> Result<Response, ApiError> {
|
||||
let mut request = self.client.delete(self.url(path));
|
||||
|
||||
if let Some(session) = &self.session {
|
||||
request = request.header("Cookie", format!("admin_session={}", session.session_token));
|
||||
}
|
||||
|
||||
let response = request.send().await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Make an authenticated DELETE request
|
||||
pub async fn delete_auth(&self, path: &str) -> Result<Response, ApiError> {
|
||||
if !self.is_authenticated() {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
self.delete(path).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to check response and extract error message
|
||||
pub async fn check_response(response: Response) -> Result<Response, ApiError> {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
Ok(response)
|
||||
} else {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
Err(ApiError::Http { status, body })
|
||||
}
|
||||
}
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod output;
|
||||
pub mod seed;
|
||||
pub mod serve;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::config::ListenAddr;
|
||||
|
||||
/// xevion.dev - Personal portfolio server and API client
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "xevion")]
|
||||
#[command(about = "Personal portfolio server with API client for content management")]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Command>,
|
||||
|
||||
/// Address(es) to listen on (TCP or Unix socket)
|
||||
#[arg(long, env = "LISTEN_ADDR", value_delimiter = ',')]
|
||||
pub listen: Vec<ListenAddr>,
|
||||
|
||||
/// Downstream SSR server URL
|
||||
#[arg(long, env = "DOWNSTREAM_URL")]
|
||||
pub downstream: Option<String>,
|
||||
|
||||
/// Trust X-Request-ID header from specified source
|
||||
#[arg(long, env = "TRUST_REQUEST_ID")]
|
||||
pub trust_request_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Command {
|
||||
/// Seed the database with sample data
|
||||
Seed,
|
||||
|
||||
/// API client for managing content remotely
|
||||
Api(ApiArgs),
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ApiArgs {
|
||||
/// API base URL
|
||||
#[arg(long, env = "API_BASE_URL", default_value = "http://localhost:8080")]
|
||||
pub api_url: String,
|
||||
|
||||
/// Session file path
|
||||
#[arg(long, env = "XEVION_SESSION", default_value = ".xevion-session")]
|
||||
pub session: String,
|
||||
|
||||
/// Output raw JSON instead of formatted text
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: ApiCommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ApiCommand {
|
||||
/// Login and save session
|
||||
Login {
|
||||
/// Username
|
||||
username: String,
|
||||
/// Password
|
||||
password: String,
|
||||
},
|
||||
|
||||
/// Clear saved session
|
||||
Logout,
|
||||
|
||||
/// Check current session status
|
||||
Session,
|
||||
|
||||
/// Project management
|
||||
#[command(subcommand)]
|
||||
Projects(ProjectsCommand),
|
||||
|
||||
/// Tag management
|
||||
#[command(subcommand)]
|
||||
Tags(TagsCommand),
|
||||
|
||||
/// Site settings management
|
||||
#[command(subcommand)]
|
||||
Settings(SettingsCommand),
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ProjectsCommand {
|
||||
/// List all projects
|
||||
List,
|
||||
|
||||
/// Get project details by slug or UUID
|
||||
Get {
|
||||
/// Project slug or UUID
|
||||
#[arg(name = "ref")]
|
||||
reference: String,
|
||||
},
|
||||
|
||||
/// Create a new project
|
||||
Create {
|
||||
/// Project name
|
||||
name: String,
|
||||
|
||||
/// Short description
|
||||
#[arg(short = 's', long)]
|
||||
short_desc: String,
|
||||
|
||||
/// Full description
|
||||
#[arg(short = 'd', long)]
|
||||
desc: String,
|
||||
|
||||
/// URL slug (auto-generated from name if omitted)
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
|
||||
/// Project status
|
||||
#[arg(long, default_value = "active")]
|
||||
status: String,
|
||||
|
||||
/// GitHub repository (e.g., "Xevion/xevion.dev")
|
||||
#[arg(long)]
|
||||
github_repo: Option<String>,
|
||||
|
||||
/// Demo URL
|
||||
#[arg(long)]
|
||||
demo_url: Option<String>,
|
||||
|
||||
/// Tags to add (comma-separated, + prefix optional)
|
||||
#[arg(short = 't', long)]
|
||||
tags: Option<String>,
|
||||
},
|
||||
|
||||
/// Update an existing project
|
||||
Update {
|
||||
/// Project slug or UUID
|
||||
#[arg(name = "ref")]
|
||||
reference: String,
|
||||
|
||||
/// Project name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// URL slug
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
|
||||
/// Short description
|
||||
#[arg(short = 's', long)]
|
||||
short_desc: Option<String>,
|
||||
|
||||
/// Full description
|
||||
#[arg(short = 'd', long)]
|
||||
desc: Option<String>,
|
||||
|
||||
/// Project status
|
||||
#[arg(long)]
|
||||
status: Option<String>,
|
||||
|
||||
/// GitHub repository (use "" to clear)
|
||||
#[arg(long)]
|
||||
github_repo: Option<String>,
|
||||
|
||||
/// Demo URL (use "" to clear)
|
||||
#[arg(long)]
|
||||
demo_url: Option<String>,
|
||||
|
||||
/// Tag changes: +tag to add, -tag to remove (comma-separated)
|
||||
#[arg(short = 't', long)]
|
||||
tags: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a project
|
||||
Delete {
|
||||
/// Project slug or UUID
|
||||
#[arg(name = "ref")]
|
||||
reference: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum TagsCommand {
|
||||
/// List all tags with project counts
|
||||
List,
|
||||
|
||||
/// Get tag details with associated projects
|
||||
Get {
|
||||
/// Tag slug
|
||||
slug: String,
|
||||
},
|
||||
|
||||
/// Create a new tag
|
||||
Create {
|
||||
/// Tag name
|
||||
name: String,
|
||||
|
||||
/// URL slug (auto-generated from name if omitted)
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
|
||||
/// Icon identifier (e.g., "simple-icons:rust")
|
||||
#[arg(long)]
|
||||
icon: Option<String>,
|
||||
|
||||
/// Color hex without # (e.g., "3b82f6")
|
||||
#[arg(long)]
|
||||
color: Option<String>,
|
||||
},
|
||||
|
||||
/// Update an existing tag
|
||||
Update {
|
||||
/// Tag slug
|
||||
slug: String,
|
||||
|
||||
/// Tag name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// New URL slug
|
||||
#[arg(long = "new-slug")]
|
||||
new_slug: Option<String>,
|
||||
|
||||
/// Icon identifier (use "" to clear)
|
||||
#[arg(long)]
|
||||
icon: Option<String>,
|
||||
|
||||
/// Color hex (use "" to clear)
|
||||
#[arg(long)]
|
||||
color: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum SettingsCommand {
|
||||
/// Get current site settings
|
||||
Get,
|
||||
|
||||
/// Update site settings
|
||||
Update {
|
||||
/// Display name
|
||||
#[arg(long)]
|
||||
display_name: Option<String>,
|
||||
|
||||
/// Occupation/title
|
||||
#[arg(long)]
|
||||
occupation: Option<String>,
|
||||
|
||||
/// Bio text
|
||||
#[arg(long)]
|
||||
bio: Option<String>,
|
||||
|
||||
/// Site title
|
||||
#[arg(long)]
|
||||
site_title: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Tag operation for updates
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TagOp {
|
||||
Add(String),
|
||||
Remove(String),
|
||||
}
|
||||
|
||||
/// Parse tags for create command (+ prefix optional)
|
||||
pub fn parse_create_tags(s: &str) -> Vec<String> {
|
||||
s.split(',')
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| t.strip_prefix('+').unwrap_or(t).to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse tags for update command (+ or - prefix required)
|
||||
pub fn parse_update_tags(s: &str) -> Result<Vec<TagOp>, String> {
|
||||
s.split(',')
|
||||
.map(|t| t.trim())
|
||||
.filter(|t| !t.is_empty())
|
||||
.map(|t| {
|
||||
if let Some(tag) = t.strip_prefix('+') {
|
||||
if tag.is_empty() {
|
||||
Err("Tag name cannot be empty after '+'".to_string())
|
||||
} else {
|
||||
Ok(TagOp::Add(tag.to_string()))
|
||||
}
|
||||
} else if let Some(tag) = t.strip_prefix('-') {
|
||||
if tag.is_empty() {
|
||||
Err("Tag name cannot be empty after '-'".to_string())
|
||||
} else {
|
||||
Ok(TagOp::Remove(tag.to_string()))
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Tag '{}' requires prefix: use +{} to add, -{} to remove",
|
||||
t, t, t
|
||||
))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use nu_ansi_term::{Color, Style};
|
||||
|
||||
use crate::db::{ApiAdminProject, ApiSiteSettings, ApiTag, ApiTagWithCount};
|
||||
|
||||
/// Print a success message
|
||||
pub fn success(msg: &str) {
|
||||
println!("{} {}", Color::Green.paint("✓"), msg);
|
||||
}
|
||||
|
||||
/// Print an error message
|
||||
pub fn error(msg: &str) {
|
||||
eprintln!("{} {}", Color::Red.paint("✗"), msg);
|
||||
}
|
||||
|
||||
/// Print an info message
|
||||
pub fn info(msg: &str) {
|
||||
println!("{} {}", Color::Blue.paint("→"), msg);
|
||||
}
|
||||
|
||||
/// Print a project in formatted output
|
||||
pub fn print_project(project: &ApiAdminProject) {
|
||||
let header = Style::new().bold();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
println!("{}", header.paint(&project.project.name));
|
||||
println!(" {} {}", dim.paint("ID:"), project.project.id);
|
||||
println!(" {} {}", dim.paint("Slug:"), project.project.slug);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Status:"),
|
||||
format_status(&project.status)
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Description:"),
|
||||
project.project.short_description
|
||||
);
|
||||
|
||||
if let Some(ref repo) = project.github_repo {
|
||||
println!(" {} {}", dim.paint("GitHub:"), repo);
|
||||
}
|
||||
if let Some(ref url) = project.demo_url {
|
||||
println!(" {} {}", dim.paint("Demo:"), url);
|
||||
}
|
||||
|
||||
if !project.tags.is_empty() {
|
||||
let tags: Vec<_> = project.tags.iter().map(|t| t.slug.as_str()).collect();
|
||||
println!(" {} {}", dim.paint("Tags:"), tags.join(", "));
|
||||
}
|
||||
|
||||
println!(" {} {}", dim.paint("Updated:"), project.updated_at);
|
||||
}
|
||||
|
||||
/// Print a list of projects in table format
|
||||
pub fn print_projects_table(projects: &[ApiAdminProject]) {
|
||||
if projects.is_empty() {
|
||||
info("No projects found");
|
||||
return;
|
||||
}
|
||||
|
||||
let header = Style::new().bold().underline();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
// Calculate column widths
|
||||
let name_width = projects
|
||||
.iter()
|
||||
.map(|p| p.project.name.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let slug_width = projects
|
||||
.iter()
|
||||
.map(|p| p.project.slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
|
||||
// Header
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:10} {}",
|
||||
header.paint("NAME"),
|
||||
header.paint("SLUG"),
|
||||
header.paint("STATUS"),
|
||||
header.paint("TAGS"),
|
||||
);
|
||||
|
||||
// Rows
|
||||
for project in projects {
|
||||
let tags: Vec<_> = project.tags.iter().map(|t| t.slug.as_str()).collect();
|
||||
let tags_str = if tags.is_empty() {
|
||||
dim.paint("-").to_string()
|
||||
} else {
|
||||
tags.join(", ")
|
||||
};
|
||||
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:10} {}",
|
||||
project.project.name,
|
||||
dim.paint(&project.project.slug),
|
||||
format_status(&project.status),
|
||||
tags_str,
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
info(&format!("{} project(s)", projects.len()));
|
||||
}
|
||||
|
||||
/// Print a tag in formatted output
|
||||
pub fn print_tag(tag: &ApiTag) {
|
||||
let header = Style::new().bold();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
println!("{}", header.paint(&tag.name));
|
||||
println!(" {} {}", dim.paint("ID:"), tag.id);
|
||||
println!(" {} {}", dim.paint("Slug:"), tag.slug);
|
||||
|
||||
if let Some(ref icon) = tag.icon {
|
||||
println!(" {} {}", dim.paint("Icon:"), icon);
|
||||
}
|
||||
if let Some(ref color) = tag.color {
|
||||
println!(" {} #{}", dim.paint("Color:"), color);
|
||||
}
|
||||
}
|
||||
|
||||
/// Print a list of tags in table format
|
||||
pub fn print_tags_table(tags: &[ApiTagWithCount]) {
|
||||
if tags.is_empty() {
|
||||
info("No tags found");
|
||||
return;
|
||||
}
|
||||
|
||||
let header = Style::new().bold().underline();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
// Calculate column widths
|
||||
let name_width = tags
|
||||
.iter()
|
||||
.map(|t| t.tag.name.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
let slug_width = tags
|
||||
.iter()
|
||||
.map(|t| t.tag.slug.len())
|
||||
.max()
|
||||
.unwrap_or(4)
|
||||
.max(4);
|
||||
|
||||
// Header
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:8} {:20} {}",
|
||||
header.paint("NAME"),
|
||||
header.paint("SLUG"),
|
||||
header.paint("PROJECTS"),
|
||||
header.paint("ICON"),
|
||||
header.paint("COLOR"),
|
||||
);
|
||||
|
||||
// Rows
|
||||
for tag in tags {
|
||||
let icon = tag.tag.icon.as_deref().unwrap_or("-");
|
||||
let color = tag
|
||||
.tag
|
||||
.color
|
||||
.as_ref()
|
||||
.map(|c| format!("#{}", c))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
|
||||
println!(
|
||||
"{:name_width$} {:slug_width$} {:8} {:20} {}",
|
||||
tag.tag.name,
|
||||
dim.paint(&tag.tag.slug),
|
||||
tag.project_count,
|
||||
dim.paint(icon),
|
||||
dim.paint(&color),
|
||||
);
|
||||
}
|
||||
|
||||
println!();
|
||||
info(&format!("{} tag(s)", tags.len()));
|
||||
}
|
||||
|
||||
/// Print site settings in formatted output
|
||||
pub fn print_settings(settings: &ApiSiteSettings) {
|
||||
let header = Style::new().bold();
|
||||
let dim = Style::new().dimmed();
|
||||
|
||||
println!("{}", header.paint("Site Identity"));
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Display Name:"),
|
||||
settings.identity.display_name
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Occupation:"),
|
||||
settings.identity.occupation
|
||||
);
|
||||
println!(" {} {}", dim.paint("Bio:"), settings.identity.bio);
|
||||
println!(
|
||||
" {} {}",
|
||||
dim.paint("Site Title:"),
|
||||
settings.identity.site_title
|
||||
);
|
||||
|
||||
if !settings.social_links.is_empty() {
|
||||
println!();
|
||||
println!("{}", header.paint("Social Links"));
|
||||
for link in &settings.social_links {
|
||||
let visibility = if link.visible { "" } else { " (hidden)" };
|
||||
println!(
|
||||
" {} {}: {}{}",
|
||||
dim.paint(format!("[{}]", link.display_order)),
|
||||
link.label,
|
||||
link.value,
|
||||
dim.paint(visibility)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format project status with color
|
||||
fn format_status(status: &str) -> String {
|
||||
match status {
|
||||
"active" => Color::Green.paint(status).to_string(),
|
||||
"maintained" => Color::Blue.paint(status).to_string(),
|
||||
"archived" => Color::Yellow.paint(status).to_string(),
|
||||
"hidden" => Color::Red.paint(status).to_string(),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print session info
|
||||
pub fn print_session(username: &str, api_url: &str) {
|
||||
let dim = Style::new().dimmed();
|
||||
success(&format!(
|
||||
"Logged in as {}",
|
||||
Style::new().bold().paint(username)
|
||||
));
|
||||
println!(" {} {}", dim.paint("API:"), api_url);
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
|
||||
+48
-209
@@ -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<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 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<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");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ while (!existsSync(BUN_SOCKET)) {
|
||||
console.log("Starting Rust API...");
|
||||
const rustProc = spawn({
|
||||
cmd: [
|
||||
"/app/api",
|
||||
"/app/xevion",
|
||||
"--listen",
|
||||
`[::]:${PORT}`,
|
||||
"--listen",
|
||||
|
||||
Reference in New Issue
Block a user