use axum::{ extract::{Path, State}, http::StatusCode, Json, }; use chrono::Utc; use std::os::unix::fs::PermissionsExt; use uuid::Uuid; use crate::{ models::{App, CreateApp, UpdateApp}, AppState, }; /// Build the Caddy route JSON for an app. /// Public apps get a plain reverse_proxy; private apps get forward_auth via HIY. fn caddy_route(app_host: &str, upstream: &str, is_public: bool) -> serde_json::Value { if is_public { serde_json::json!({ "match": [{"host": [app_host]}], "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}] }) } else { serde_json::json!({ "match": [{"host": [app_host]}], "handle": [{ "handler": "subroute", "routes": [{ "handle": [{ "handler": "reverse_proxy", "rewrite": {"method": "GET", "uri": "/auth/verify"}, "headers": {"request": {"set": { "X-Forwarded-Method": ["{http.request.method}"], "X-Forwarded-Uri": ["{http.request.uri}"], "X-Forwarded-Host": ["{http.request.host}"], "X-Forwarded-Proto": ["{http.request.scheme}"] }}}, "upstreams": [{"dial": "server:3000"}], "handle_response": [{ "match": {"status_code": [2]}, "routes": [{"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}]}] }] }] }] }] }) } } /// Push a visibility change to Caddy without requiring a full redeploy. /// Best-effort: logs a warning on failure but does not surface an error to the caller. async fn push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) { if let Err(e) = try_push_visibility_to_caddy(app_id, port, is_public).await { tracing::warn!("caddy visibility update for {}: {}", app_id, e); } } async fn try_push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) -> anyhow::Result<()> { let caddy_api = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://caddy:2019".into()); let domain = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into()); let app_host = format!("{}.{}", app_id, domain); let upstream = format!("hiy-{}:{}", app_id, port); let client = reqwest::Client::new(); // Discover the Caddy server name (Caddyfile adapter names it "srv0"). let servers: serde_json::Value = client .get(format!("{}/config/apps/http/servers/", caddy_api)) .send().await? .json().await?; let server_name = servers.as_object() .and_then(|m| m.keys().next().cloned()) .ok_or_else(|| anyhow::anyhow!("no servers in Caddy config"))?; let routes_url = format!("{}/config/apps/http/servers/{}/routes", caddy_api, server_name); let routes: Vec = client.get(&routes_url).send().await?.json().await?; let dashboard = serde_json::json!({ "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "server:3000"}]}] }); let mut updated: Vec = routes.into_iter() .filter(|r| { let is_this_app = r.pointer("/match/0/host") .and_then(|h| h.as_array()) .map(|hosts| hosts.iter().any(|h| h.as_str() == Some(app_host.as_str()))) .unwrap_or(false); let is_catchall = r.get("match").is_none(); !is_this_app && !is_catchall }) .collect(); updated.insert(0, caddy_route(&app_host, &upstream, is_public)); updated.push(dashboard); client.patch(&routes_url).json(&updated).send().await?; Ok(()) } pub async fn list(State(s): State) -> Result>, StatusCode> { let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC") .fetch_all(&s.db) .await .map_err(|e| { tracing::error!("list apps: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(apps)) } pub async fn create( State(s): State, Json(payload): Json, ) -> Result<(StatusCode, Json), StatusCode> { // Use the name as the slug/id (must be URL-safe). let id = payload.name.to_lowercase().replace(' ', "-"); let now = Utc::now().to_rfc3339(); let branch = payload.branch.unwrap_or_else(|| "main".into()); let secret = Uuid::new_v4().to_string().replace('-', ""); let memory_limit = payload.memory_limit.unwrap_or_else(|| "512m".into()); let cpu_limit = payload.cpu_limit.unwrap_or_else(|| "0.5".into()); let git_token_enc = payload.git_token .as_deref() .filter(|t| !t.is_empty()) .map(crate::crypto::encrypt) .transpose() .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; sqlx::query( "INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, memory_limit, cpu_limit, git_token, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&payload.name) .bind(payload.repo_url.unwrap_or_default()) .bind(&branch) .bind(payload.port) .bind(&secret) .bind(&memory_limit) .bind(&cpu_limit) .bind(&git_token_enc) .bind(&now) .bind(&now) .execute(&s.db) .await .map_err(|e| { tracing::error!("create app: {}", e); StatusCode::UNPROCESSABLE_ENTITY })?; // Initialise a bare git repo for git-push deploys. if let Err(e) = init_bare_repo(&s, &id).await { tracing::warn!("Failed to init bare repo for {}: {}", id, e); } fetch_app(&s, &id).await.map(|a| (StatusCode::CREATED, Json(a))) } pub async fn get_one( State(s): State, Path(id): Path, ) -> Result, StatusCode> { fetch_app(&s, &id).await.map(Json) } pub async fn update( State(s): State, Path(id): Path, Json(payload): Json, ) -> Result, StatusCode> { let now = Utc::now().to_rfc3339(); if let Some(v) = payload.repo_url { sqlx::query("UPDATE apps SET repo_url = ?, updated_at = ? WHERE id = ?") .bind(v).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } if let Some(v) = payload.branch { sqlx::query("UPDATE apps SET branch = ?, updated_at = ? WHERE id = ?") .bind(v).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } if let Some(v) = payload.port { sqlx::query("UPDATE apps SET port = ?, updated_at = ? WHERE id = ?") .bind(v).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } if let Some(v) = payload.memory_limit { sqlx::query("UPDATE apps SET memory_limit = ?, updated_at = ? WHERE id = ?") .bind(v).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } if let Some(v) = payload.cpu_limit { sqlx::query("UPDATE apps SET cpu_limit = ?, updated_at = ? WHERE id = ?") .bind(v).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } if let Some(v) = payload.is_public { let flag: i64 = if v { 1 } else { 0 }; sqlx::query("UPDATE apps SET is_public = ?, updated_at = ? WHERE id = ?") .bind(flag).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; // Immediately reconfigure the Caddy route so the change takes effect // without a full redeploy. let app = fetch_app(&s, &id).await?; push_visibility_to_caddy(&id, app.port, v).await; } if let Some(v) = payload.git_token { if v.is_empty() { sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?") .bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } else { let enc = crate::crypto::encrypt(&v) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; sqlx::query("UPDATE apps SET git_token = ?, updated_at = ? WHERE id = ?") .bind(enc).bind(&now).bind(&id) .execute(&s.db).await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; } } fetch_app(&s, &id).await.map(Json) } pub async fn delete( State(s): State, Path(id): Path, ) -> Result { let res = sqlx::query("DELETE FROM apps WHERE id = ?") .bind(&id) .execute(&s.db) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if res.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } // Clean up the bare repo (best-effort). let repo_path = format!("{}/repos/{}.git", s.data_dir, id); if let Err(e) = std::fs::remove_dir_all(&repo_path) { tracing::warn!("Failed to remove bare repo {}: {}", repo_path, e); } Ok(StatusCode::NO_CONTENT) } pub async fn stop( State(_s): State, Path(id): Path, ) -> Result { let name = format!("hiy-{}", id); let out = tokio::process::Command::new("podman") .args(["stop", &name]) .output() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if out.status.success() { Ok(StatusCode::NO_CONTENT) } else { tracing::warn!("podman stop {}: {}", name, String::from_utf8_lossy(&out.stderr)); Err(StatusCode::INTERNAL_SERVER_ERROR) } } pub async fn restart( State(_s): State, Path(id): Path, ) -> Result { let name = format!("hiy-{}", id); let out = tokio::process::Command::new("podman") .args(["restart", &name]) .output() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if out.status.success() { Ok(StatusCode::NO_CONTENT) } else { tracing::warn!("podman restart {}: {}", name, String::from_utf8_lossy(&out.stderr)); Err(StatusCode::INTERNAL_SERVER_ERROR) } } async fn fetch_app(s: &AppState, id: &str) -> Result { sqlx::query_as::<_, App>("SELECT * FROM apps WHERE id = ?") .bind(id) .fetch_optional(&s.db) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND) } /// Initialise a bare git repo and write the post-receive hook for git-push deploys. async fn init_bare_repo(s: &AppState, app_id: &str) -> anyhow::Result<()> { let repos_dir = format!("{}/repos", s.data_dir); std::fs::create_dir_all(&repos_dir)?; let repo_path = format!("{}/{}.git", repos_dir, app_id); let out = tokio::process::Command::new("git") .args(["init", "--bare", &repo_path]) .output() .await?; if !out.status.success() { anyhow::bail!( "git init --bare failed: {}", String::from_utf8_lossy(&out.stderr) ); } // Write the post-receive hook that calls HIY's internal push endpoint. let hook_path = format!("{}/hooks/post-receive", repo_path); let hook = format!( r#"#!/usr/bin/env bash # HIY post-receive hook — queues a build after git push. set -euo pipefail HIY_API_URL="http://localhost:3000" HIY_INTERNAL_TOKEN="{token}" APP_ID="{app_id}" while read OLD_SHA NEW_SHA REF; do BRANCH="${{REF##refs/heads/}}" curl -sf -X POST \ -H "X-Hiy-Token: $HIY_INTERNAL_TOKEN" \ -H "Content-Type: application/json" \ -d "{{\"sha\":\"$NEW_SHA\",\"branch\":\"$BRANCH\"}}" \ "$HIY_API_URL/internal/git/$APP_ID/push" || true done echo "[hiy] Build queued. Watch progress in the dashboard." "#, token = s.internal_token, app_id = app_id, ); std::fs::write(&hook_path, hook)?; std::fs::set_permissions(&hook_path, std::fs::Permissions::from_mode(0o755))?; tracing::info!("Initialised bare repo at {}", repo_path); Ok(()) }