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/build/
|
||||||
web/.svelte-kit/
|
web/.svelte-kit/
|
||||||
|
|
||||||
|
# CLI session file
|
||||||
|
.xevion-session
|
||||||
|
|
||||||
# Added by cargo
|
# Added by cargo
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,10 @@
|
|||||||
name = "api"
|
name = "api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
default-run = "api"
|
|
||||||
|
[[bin]]
|
||||||
|
name = "xevion"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
|
|||||||
+3
-3
@@ -29,7 +29,7 @@ RUN mkdir -p web/build/client && \
|
|||||||
echo "placeholder" > web/build/client/.gitkeep
|
echo "placeholder" > web/build/client/.gitkeep
|
||||||
|
|
||||||
RUN cargo build --release && \
|
RUN cargo build --release && \
|
||||||
strip target/release/api
|
strip target/release/xevion
|
||||||
|
|
||||||
# ========== Stage 4: Frontend Builder ==========
|
# ========== Stage 4: Frontend Builder ==========
|
||||||
FROM oven/bun:1 AS frontend
|
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)
|
# Build with real assets (use sqlx offline mode)
|
||||||
ENV SQLX_OFFLINE=true
|
ENV SQLX_OFFLINE=true
|
||||||
RUN cargo build --release && \
|
RUN cargo build --release && \
|
||||||
strip target/release/api
|
strip target/release/xevion
|
||||||
|
|
||||||
# ========== Stage 6: Runtime ==========
|
# ========== Stage 6: Runtime ==========
|
||||||
FROM oven/bun:1-alpine AS runtime
|
FROM oven/bun:1-alpine AS runtime
|
||||||
@@ -76,7 +76,7 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
# Copy Rust binary
|
# 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 Bun SSR server and client assets (including fonts for OG images)
|
||||||
COPY --from=frontend /build/build/server ./web/build/server
|
COPY --from=frontend /build/build/server ./web/build/server
|
||||||
|
|||||||
@@ -95,13 +95,13 @@ dev:
|
|||||||
just dev-json | hl --config .hl.config.toml -P
|
just dev-json | hl --config .hl.config.toml -P
|
||||||
|
|
||||||
dev-json:
|
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:
|
serve:
|
||||||
just serve-json | hl --config .hl.config.toml -P
|
just serve-json | hl --config .hl.config.toml -P
|
||||||
|
|
||||||
serve-json:
|
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-image:
|
||||||
docker build -t xevion-dev .
|
docker build -t xevion-dev .
|
||||||
@@ -131,7 +131,7 @@ seed:
|
|||||||
if (migrate.status !== 0) process.exit(migrate.status);
|
if (migrate.status !== 0) process.exit(migrate.status);
|
||||||
|
|
||||||
// Seed data
|
// 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);
|
if (seed.status !== 0) process.exit(seed.status);
|
||||||
|
|
||||||
console.log("✅ Database ready with seed data");
|
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;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
#[tokio::main]
|
/// Seed the database with sample data
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
pub async fn run(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
dotenvy::dotenv().ok();
|
println!("Seeding database...");
|
||||||
|
|
||||||
let database_url = std::env::var("DATABASE_URL")?;
|
|
||||||
let pool = PgPool::connect(&database_url).await?;
|
|
||||||
|
|
||||||
println!("🌱 Seeding database...");
|
|
||||||
|
|
||||||
// Clear existing data (tags will cascade delete project_tags and tag_cooccurrence)
|
// Clear existing data (tags will cascade delete project_tags and tag_cooccurrence)
|
||||||
sqlx::query("DELETE FROM tags").execute(&pool).await?;
|
sqlx::query("DELETE FROM tags").execute(pool).await?;
|
||||||
sqlx::query("DELETE FROM projects").execute(&pool).await?;
|
sqlx::query("DELETE FROM projects").execute(pool).await?;
|
||||||
|
|
||||||
// Seed projects with diverse data
|
// Seed projects with diverse data
|
||||||
let projects = vec![
|
let projects = vec![
|
||||||
@@ -87,11 +82,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.bind(status)
|
.bind(status)
|
||||||
.bind(repo)
|
.bind(repo)
|
||||||
.bind(demo)
|
.bind(demo)
|
||||||
.execute(&pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✅ Seeded {} projects", project_count);
|
println!(" Seeded {} projects", project_count);
|
||||||
|
|
||||||
// Seed tags
|
// Seed tags
|
||||||
let tags = vec![
|
let tags = vec![
|
||||||
@@ -122,13 +117,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
name,
|
name,
|
||||||
icon
|
icon
|
||||||
)
|
)
|
||||||
.fetch_one(&pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
tag_ids.insert(slug, result.id);
|
tag_ids.insert(slug, result.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("✅ Seeded {} tags", tag_ids.len());
|
println!(" Seeded {} tags", tag_ids.len());
|
||||||
|
|
||||||
// Associate tags with projects
|
// Associate tags with projects
|
||||||
let project_tag_associations = vec![
|
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 {
|
for (project_slug, tag_slugs) in project_tag_associations {
|
||||||
let project_id = sqlx::query!("SELECT id FROM projects WHERE slug = $1", project_slug)
|
let project_id = sqlx::query!("SELECT id FROM projects WHERE slug = $1", project_slug)
|
||||||
.fetch_one(&pool)
|
.fetch_one(pool)
|
||||||
.await?
|
.await?
|
||||||
.id;
|
.id;
|
||||||
|
|
||||||
@@ -170,7 +165,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
project_id,
|
project_id,
|
||||||
tag_id
|
tag_id
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
association_count += 1;
|
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
|
// Recalculate tag cooccurrence
|
||||||
sqlx::query!("DELETE FROM tag_cooccurrence")
|
sqlx::query!("DELETE FROM tag_cooccurrence")
|
||||||
.execute(&pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
@@ -199,10 +194,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
HAVING COUNT(*) > 0
|
HAVING COUNT(*) > 0
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.execute(&pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
println!("✅ Recalculated tag cooccurrence");
|
println!(" Recalculated tag cooccurrence");
|
||||||
|
println!("Database seeded successfully!");
|
||||||
|
|
||||||
Ok(())
|
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
|
// Request types for updates
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct UpdateSiteIdentityRequest {
|
pub struct UpdateSiteIdentityRequest {
|
||||||
#[serde(rename = "displayName")]
|
#[serde(rename = "displayName")]
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
@@ -70,7 +70,7 @@ pub struct UpdateSiteIdentityRequest {
|
|||||||
pub site_title: String,
|
pub site_title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct UpdateSocialLinkRequest {
|
pub struct UpdateSocialLinkRequest {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub platform: String,
|
pub platform: String,
|
||||||
@@ -82,7 +82,7 @@ pub struct UpdateSocialLinkRequest {
|
|||||||
pub display_order: i32,
|
pub display_order: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
pub struct UpdateSiteSettingsRequest {
|
pub struct UpdateSiteSettingsRequest {
|
||||||
pub identity: UpdateSiteIdentityRequest,
|
pub identity: UpdateSiteIdentityRequest,
|
||||||
#[serde(rename = "socialLinks")]
|
#[serde(rename = "socialLinks")]
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ pub use tags::*;
|
|||||||
|
|
||||||
// Request/Response types used by handlers
|
// Request/Response types used by handlers
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
pub struct CreateTagRequest {
|
pub struct CreateTagRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
@@ -23,7 +23,7 @@ pub struct CreateTagRequest {
|
|||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize, serde::Serialize)]
|
||||||
pub struct UpdateTagRequest {
|
pub struct UpdateTagRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub slug: Option<String>,
|
pub slug: Option<String>,
|
||||||
|
|||||||
+48
-209
@@ -1,14 +1,10 @@
|
|||||||
use clap::Parser;
|
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};
|
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod assets;
|
mod assets;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod cache;
|
mod cache;
|
||||||
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
mod db;
|
mod db;
|
||||||
mod formatter;
|
mod formatter;
|
||||||
@@ -24,13 +20,8 @@ mod state;
|
|||||||
mod tarpit;
|
mod tarpit;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use cache::{IsrCache, IsrCacheConfig};
|
use cli::{Cli, Command};
|
||||||
use config::{Args, ListenAddr};
|
|
||||||
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
|
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
|
||||||
use health::HealthChecker;
|
|
||||||
use middleware::RequestIdLayer;
|
|
||||||
use state::AppState;
|
|
||||||
use tarpit::{TarpitConfig, TarpitState};
|
|
||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
let use_json = std::env::var("LOG_JSON")
|
let use_json = std::env::var("LOG_JSON")
|
||||||
@@ -75,212 +66,60 @@ async fn main() {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
// Parse args early to allow --help to work without database
|
// 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 pool = db::create_pool(&database_url)
|
||||||
let database_url =
|
.await
|
||||||
std::env::var("DATABASE_URL").expect("DATABASE_URL must be set in environment");
|
.expect("Failed to connect to database");
|
||||||
|
|
||||||
// Create connection pool
|
// Run migrations first
|
||||||
let pool = db::create_pool(&database_url)
|
sqlx::migrate!()
|
||||||
.await
|
.run(&pool)
|
||||||
.expect("Failed to connect to database");
|
.await
|
||||||
|
.expect("Failed to run migrations");
|
||||||
|
|
||||||
// Check and run migrations on startup
|
if let Err(e) = cli::seed::run(&pool).await {
|
||||||
let migrator = sqlx::migrate!();
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
// 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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
Some(Command::Api(api_args)) => {
|
||||||
|
// API client commands - no tracing needed
|
||||||
if args.listen.is_empty() {
|
if let Err(e) = cli::api::run(api_args).await {
|
||||||
eprintln!("Error: At least one --listen address is required");
|
eprintln!("Error: {}", e);
|
||||||
std::process::exit(1);
|
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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
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 {
|
let downstream = match args.downstream {
|
||||||
task.await.expect("Listener task panicked");
|
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...");
|
console.log("Starting Rust API...");
|
||||||
const rustProc = spawn({
|
const rustProc = spawn({
|
||||||
cmd: [
|
cmd: [
|
||||||
"/app/api",
|
"/app/xevion",
|
||||||
"--listen",
|
"--listen",
|
||||||
`[::]:${PORT}`,
|
`[::]:${PORT}`,
|
||||||
"--listen",
|
"--listen",
|
||||||
|
|||||||
Reference in New Issue
Block a user