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
This commit is contained in:
parent
c7ed5cfe95
commit
eb9a500987
8 changed files with 142 additions and 7 deletions
|
|
@ -149,11 +149,20 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then
|
||||||
else
|
else
|
||||||
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
|
ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes"
|
||||||
|
|
||||||
# Route JSON uses Caddy's forward_auth pattern:
|
# Route JSON: public apps use plain reverse_proxy; private apps use forward_auth.
|
||||||
# 1. HIY server checks the session cookie and app-level permission at /auth/verify
|
if [ "${IS_PUBLIC:-0}" = "1" ]; then
|
||||||
# 2. On 2xx → Caddy proxies to the app container
|
ROUTE_JSON=$(python3 -c "
|
||||||
# 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client
|
import json, sys
|
||||||
ROUTE_JSON=$(python3 -c "
|
upstream = sys.argv[1]
|
||||||
|
app_host = sys.argv[2]
|
||||||
|
route = {
|
||||||
|
'match': [{'host': [app_host]}],
|
||||||
|
'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': upstream}]}]
|
||||||
|
}
|
||||||
|
print(json.dumps(route))
|
||||||
|
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
|
||||||
|
else
|
||||||
|
ROUTE_JSON=$(python3 -c "
|
||||||
import json, sys
|
import json, sys
|
||||||
upstream = sys.argv[1]
|
upstream = sys.argv[1]
|
||||||
app_host = sys.argv[2]
|
app_host = sys.argv[2]
|
||||||
|
|
@ -187,6 +196,7 @@ route = {
|
||||||
}
|
}
|
||||||
print(json.dumps(route))
|
print(json.dumps(route))
|
||||||
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
|
" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}")
|
||||||
|
fi
|
||||||
# Upsert the route for this app.
|
# Upsert the route for this app.
|
||||||
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
|
ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]")
|
||||||
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all.
|
# Remove existing route for the same host, rebuild list, keep dashboard as catch-all.
|
||||||
|
|
|
||||||
|
|
@ -28,3 +28,4 @@ aes-gcm = "0.10"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
||||||
.env("BUILD_DIR", &build_dir)
|
.env("BUILD_DIR", &build_dir)
|
||||||
.env("MEMORY_LIMIT", &app.memory_limit)
|
.env("MEMORY_LIMIT", &app.memory_limit)
|
||||||
.env("CPU_LIMIT", &app.cpu_limit)
|
.env("CPU_LIMIT", &app.cpu_limit)
|
||||||
|
.env("IS_PUBLIC", if app.is_public != 0 { "1" } else { "0" })
|
||||||
.env("DOMAIN_SUFFIX", &domain_suffix)
|
.env("DOMAIN_SUFFIX", &domain_suffix)
|
||||||
.env("CADDY_API_URL", &caddy_api_url)
|
.env("CADDY_API_URL", &caddy_api_url)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,8 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
.execute(pool).await;
|
.execute(pool).await;
|
||||||
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN git_token TEXT")
|
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN git_token TEXT")
|
||||||
.execute(pool).await;
|
.execute(pool).await;
|
||||||
|
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0")
|
||||||
|
.execute(pool).await;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"CREATE TABLE IF NOT EXISTS databases (
|
r#"CREATE TABLE IF NOT EXISTS databases (
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ pub struct App {
|
||||||
/// Encrypted git token for cloning private repos. Never serialised to API responses.
|
/// Encrypted git token for cloning private repos. Never serialised to API responses.
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
|
pub is_public: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -36,6 +37,7 @@ pub struct UpdateApp {
|
||||||
pub memory_limit: Option<String>,
|
pub memory_limit: Option<String>,
|
||||||
pub cpu_limit: Option<String>,
|
pub cpu_limit: Option<String>,
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
|
pub is_public: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,91 @@ use crate::{
|
||||||
AppState,
|
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> {
|
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")
|
let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC")
|
||||||
.fetch_all(&s.db)
|
.fetch_all(&s.db)
|
||||||
|
|
@ -112,6 +197,17 @@ pub async fn update(
|
||||||
.execute(&s.db).await
|
.execute(&s.db).await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.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 let Some(v) = payload.git_token {
|
||||||
if v.is_empty() {
|
if v.is_empty() {
|
||||||
sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?")
|
sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?")
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,14 @@ pub async fn app_detail(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let is_public = app.is_public != 0;
|
||||||
|
let visibility_badge = if is_public {
|
||||||
|
r#"<span class="badge badge-success">public</span>"#
|
||||||
|
} else {
|
||||||
|
r#"<span class="badge badge-unknown">private</span>"#
|
||||||
|
};
|
||||||
|
let visibility_toggle_label = if is_public { "Make private" } else { "Make public" };
|
||||||
|
|
||||||
let (git_token_status, git_token_clear_btn) = if app.git_token.is_some() {
|
let (git_token_status, git_token_clear_btn) = if app.git_token.is_some() {
|
||||||
(
|
(
|
||||||
r#"<span class="badge badge-success">Token configured</span>"#.to_string(),
|
r#"<span class="badge badge-success">Token configured</span>"#.to_string(),
|
||||||
|
|
@ -321,8 +329,11 @@ pub async fn app_detail(
|
||||||
.replace("{{env_rows}}", &env_rows)
|
.replace("{{env_rows}}", &env_rows)
|
||||||
.replace("{{c_badge}}", &container_badge(&container_state))
|
.replace("{{c_badge}}", &container_badge(&container_state))
|
||||||
.replace("{{db_card}}", &db_card)
|
.replace("{{db_card}}", &db_card)
|
||||||
.replace("{{git_token_status}}", &git_token_status)
|
.replace("{{git_token_status}}", &git_token_status)
|
||||||
.replace("{{git_token_clear_btn}}", &git_token_clear_btn);
|
.replace("{{git_token_clear_btn}}", &git_token_clear_btn)
|
||||||
|
.replace("{{visibility_badge}}", visibility_badge)
|
||||||
|
.replace("{{visibility_toggle_label}}", visibility_toggle_label)
|
||||||
|
.replace("{{is_public_js}}", if is_public { "true" } else { "false" });
|
||||||
|
|
||||||
Html(page(&app.name, &body)).into_response()
|
Html(page(&app.name, &body)).into_response()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
· branch <code>{{branch}}</code>
|
· branch <code>{{branch}}</code>
|
||||||
· port <code>{{port}}</code>
|
· port <code>{{port}}</code>
|
||||||
· <a href="http://{{name}}.{{host}}" target="_blank">{{name}}.{{host}}</a>
|
· <a href="http://{{name}}.{{host}}" target="_blank">{{name}}.{{host}}</a>
|
||||||
|
· {{visibility_badge}}
|
||||||
|
<button style="font-size:0.78rem;padding:2px 10px;margin-left:4px" onclick="toggleVisibility()">{{visibility_toggle_label}}</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -165,6 +167,16 @@ async function deprovisionDb() {
|
||||||
if (r.ok) window.location.reload();
|
if (r.ok) window.location.reload();
|
||||||
else alert('Error: ' + await r.text());
|
else alert('Error: ' + await r.text());
|
||||||
}
|
}
|
||||||
|
const IS_PUBLIC = {{is_public_js}};
|
||||||
|
async function toggleVisibility() {
|
||||||
|
const r = await fetch('/api/apps/' + APP_ID, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({is_public: !IS_PUBLIC}),
|
||||||
|
});
|
||||||
|
if (r.ok) window.location.reload();
|
||||||
|
else alert('Error updating visibility: ' + await r.text());
|
||||||
|
}
|
||||||
async function saveGitToken() {
|
async function saveGitToken() {
|
||||||
const tok = document.getElementById('git-token-input').value;
|
const tok = document.getElementById('git-token-input').value;
|
||||||
if (!tok) { alert('Enter a token first'); return; }
|
if (!tok) { alert('Enter a token first'); return; }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue