Hostityourself/server/src/routes/apps.rs
Claude eb9a500987
feat: per-app public/private visibility toggle
Apps default to private (require login). Marking an app public bypasses
the forward_auth check so anyone can access it without logging in.

Changes:
- db.rs: is_public INTEGER NOT NULL DEFAULT 0 column (idempotent)
- models.rs: is_public: i64 on App; is_public: Option<bool> on UpdateApp
- Cargo.toml: add reqwest for Caddy admin API calls from Rust
- routes/apps.rs: PATCH is_public → save flag + immediately push updated
  Caddy route (no redeploy needed); caddy_route() builds correct JSON for
  both public (plain reverse_proxy) and private (forward_auth) cases
- builder.rs: pass IS_PUBLIC env var to build.sh
- build.sh: use IS_PUBLIC to select route type on deploy
- ui.rs + app_detail.html: private/public badge + toggle button in subtitle

https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
2026-03-26 08:55:58 +00:00

345 lines
12 KiB
Rust

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<serde_json::Value> = 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<serde_json::Value> = 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<AppState>) -> Result<Json<Vec<App>>, 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<AppState>,
Json(payload): Json<CreateApp>,
) -> Result<(StatusCode, Json<App>), 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<AppState>,
Path(id): Path<String>,
) -> Result<Json<App>, StatusCode> {
fetch_app(&s, &id).await.map(Json)
}
pub async fn update(
State(s): State<AppState>,
Path(id): Path<String>,
Json(payload): Json<UpdateApp>,
) -> Result<Json<App>, 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<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<AppState>,
Path(id): Path<String>,
) -> Result<StatusCode, StatusCode> {
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<App, StatusCode> {
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(())
}