feat: HTTP git push with API key auth
Replaces SSH as the primary git push path — no key generation needed. # Admin UI: Users → Generate key (shown once) git remote add hiy http://hiy:API_KEY@myserver/git/myapp git push hiy main What was added: - api_keys DB table (id, user_id, label, key_hash/SHA-256, created_at) Keys are stored as SHA-256 hashes; the plaintext is shown once on creation and never stored. - routes/api_keys.rs GET/POST /api/users/:id/api-keys — list / generate DELETE /api/api-keys/:key_id — revoke - HTTP Smart Protocol endpoints (public, auth via Basic + API key) GET /git/:app/info/refs — ref advertisement POST /git/:app/git-receive-pack — receive pack, runs post-receive hook Authentication: HTTP Basic where the password is the API key. git prompts once and caches via the OS credential store. post-receive hook fires as normal and queues the build. - Admin UI: API keys section per user with generate/revoke and a one-time reveal box showing the ready-to-use git remote command. SSH path (git-shell + authorized_keys) is still functional for users who prefer it; both paths feed the same build queue. https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
parent
cb0795617f
commit
0c995f9a0a
8 changed files with 446 additions and 47 deletions
|
|
@ -26,3 +26,4 @@ async-stream = "0.3"
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
base64 = "0.22"
|
||||||
|
|
|
||||||
|
|
@ -89,5 +89,17 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
// SSH key management (admin only)
|
// SSH key management (admin only)
|
||||||
.route("/api/users/:id/ssh-keys", get(routes::ssh_keys::list).post(routes::ssh_keys::add))
|
.route("/api/users/:id/ssh-keys", get(routes::ssh_keys::list).post(routes::ssh_keys::add))
|
||||||
.route("/api/ssh-keys/:key_id", delete(routes::ssh_keys::remove))
|
.route("/api/ssh-keys/:key_id", delete(routes::ssh_keys::remove))
|
||||||
|
// API key management (admin only)
|
||||||
|
.route("/api/users/:id/api-keys", get(routes::api_keys::list).post(routes::api_keys::create))
|
||||||
|
.route("/api/api-keys/:key_id", delete(routes::api_keys::revoke))
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth::auth_middleware));
|
||||||
|
|
||||||
// ── Public routes ─────────────────────────────────────────────────────────
|
// ── Public routes ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -178,6 +181,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
.route("/auth/verify", get(auth::verify))
|
.route("/auth/verify", get(auth::verify))
|
||||||
// GitHub webhooks use HMAC-SHA256 — no session needed.
|
// GitHub webhooks use HMAC-SHA256 — no session needed.
|
||||||
.route("/webhook/:app_id", post(routes::webhooks::github))
|
.route("/webhook/:app_id", post(routes::webhooks::github))
|
||||||
|
// HTTP Smart Protocol for git push — authenticated by API key.
|
||||||
|
.route("/git/:app/info/refs", get(routes::git::http_info_refs))
|
||||||
|
.route("/git/:app/git-receive-pack", post(routes::git::http_receive_pack))
|
||||||
// Internal routes: called by hiy-git-shell and post-receive hooks.
|
// Internal routes: called by hiy-git-shell and post-receive hooks.
|
||||||
.route("/internal/git/auth", get(routes::git::auth_check))
|
.route("/internal/git/auth", get(routes::git::auth_check))
|
||||||
.route("/internal/git/:app_id/push", post(routes::git::push_trigger));
|
.route("/internal/git/:app_id/push", post(routes::git::push_trigger));
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,17 @@ pub struct CreateSshKey {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
pub struct ApiKey {
|
||||||
|
pub id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub created_at: String,
|
||||||
|
// key_hash is intentionally not exposed in serialised output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CreateApiKey {
|
||||||
|
pub label: String,
|
||||||
|
}
|
||||||
|
|
|
||||||
108
server/src/routes/api_keys.rs
Normal file
108
server/src/routes/api_keys.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::{models::{ApiKey, CreateApiKey}, AppState};
|
||||||
|
|
||||||
|
// ── List API keys for a user (hashes never returned) ─────────────────────────
|
||||||
|
|
||||||
|
pub async fn list(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(user_id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let keys: Vec<ApiKey> =
|
||||||
|
sqlx::query_as("SELECT id, user_id, label, created_at FROM api_keys WHERE user_id = ? ORDER BY created_at")
|
||||||
|
.bind(&user_id)
|
||||||
|
.fetch_all(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
Json(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generate a new API key ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn create(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(user_id): Path<String>,
|
||||||
|
Json(body): Json<CreateApiKey>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let label = body.label.trim().to_string();
|
||||||
|
if label.is_empty() {
|
||||||
|
return (StatusCode::BAD_REQUEST, Json(json!({"error": "label required"}))).into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a random key: "hiy_" + 32 random bytes as hex (68 chars total).
|
||||||
|
let random_bytes: Vec<u8> = (0..32).map(|_| rand_byte()).collect();
|
||||||
|
let raw_key = format!("hiy_{}", hex::encode(&random_bytes));
|
||||||
|
|
||||||
|
// Store SHA-256 of the raw key — never the key itself.
|
||||||
|
let hash = hex::encode(Sha256::digest(raw_key.as_bytes()));
|
||||||
|
|
||||||
|
let id = Uuid::new_v4().to_string();
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
|
|
||||||
|
let res = sqlx::query(
|
||||||
|
"INSERT INTO api_keys (id, user_id, label, key_hash, created_at) VALUES (?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(&user_id)
|
||||||
|
.bind(&label)
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(_) => (
|
||||||
|
StatusCode::CREATED,
|
||||||
|
// Return the plaintext key once — it cannot be recovered after this.
|
||||||
|
Json(json!({"id": id, "key": raw_key, "label": label})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("create api key: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "db error"}))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Revoke an API key ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn revoke(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(key_id): Path<String>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
sqlx::query("DELETE FROM api_keys WHERE id = ?")
|
||||||
|
.bind(&key_id)
|
||||||
|
.execute(&s.db)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lookup by raw key value (used by HTTP git auth) ───────────────────────────
|
||||||
|
|
||||||
|
/// Returns the user_id if the raw key matches a stored hash.
|
||||||
|
pub async fn verify_key(s: &AppState, raw_key: &str) -> Option<String> {
|
||||||
|
let hash = hex::encode(Sha256::digest(raw_key.as_bytes()));
|
||||||
|
sqlx::query_scalar("SELECT user_id FROM api_keys WHERE key_hash = ?")
|
||||||
|
.bind(&hash)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tiny CSPRNG using uuid entropy ───────────────────────────────────────────
|
||||||
|
|
||||||
|
fn rand_byte() -> u8 {
|
||||||
|
// uuid v4 is generated from the OS CSPRNG; pulling one byte at a time is
|
||||||
|
// wasteful but avoids adding a rand dependency just for this.
|
||||||
|
Uuid::new_v4().as_bytes()[0]
|
||||||
|
}
|
||||||
|
|
@ -1,17 +1,23 @@
|
||||||
/// Internal routes called by hiy-git-shell and post-receive hooks.
|
/// Git-related routes:
|
||||||
/// Authenticated by the shared `internal_token` (X-Hiy-Token header).
|
/// - Internal routes (X-Hiy-Token) used by hiy-git-shell and post-receive hooks.
|
||||||
|
/// - HTTP Smart Protocol routes for `git push http://...` with API key auth.
|
||||||
use axum::{
|
use axum::{
|
||||||
|
body::Body,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, Response, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
|
use base64::Engine as _;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tokio::io::AsyncWriteExt as _;
|
||||||
|
|
||||||
use crate::{builder, AppState};
|
use crate::{builder, routes::api_keys, AppState};
|
||||||
|
|
||||||
// ── Token check ───────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal token check (for post-receive hooks and git-shell)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
|
fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
|
||||||
headers
|
headers
|
||||||
|
|
@ -21,7 +27,230 @@ fn check_token(state: &AppState, headers: &HeaderMap) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET /internal/git/auth — can this user push to this app? ─────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP Basic Auth (for git push over HTTP)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Extracts and verifies an API key from the `Authorization: Basic` header.
|
||||||
|
/// git sends Basic Auth where the password field is the API key.
|
||||||
|
/// Returns the user_id on success.
|
||||||
|
async fn http_authenticate(s: &AppState, headers: &HeaderMap) -> Option<String> {
|
||||||
|
let value = headers.get("authorization")?.to_str().ok()?;
|
||||||
|
let encoded = value.strip_prefix("Basic ")?;
|
||||||
|
let decoded = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.ok()?;
|
||||||
|
let credentials = std::str::from_utf8(&decoded).ok()?;
|
||||||
|
// credentials is "username:password" — the password IS the API key.
|
||||||
|
let api_key = credentials.splitn(2, ':').nth(1)?;
|
||||||
|
api_keys::verify_key(s, api_key).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unauthorized() -> Response<Body> {
|
||||||
|
Response::builder()
|
||||||
|
.status(401)
|
||||||
|
.header("WWW-Authenticate", r#"Basic realm="HIY""#)
|
||||||
|
.header("Content-Type", "text/plain")
|
||||||
|
.body(Body::from("Authentication required"))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forbidden() -> Response<Body> {
|
||||||
|
Response::builder()
|
||||||
|
.status(403)
|
||||||
|
.body(Body::from("Forbidden"))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves an app name/id and checks whether the user may push to it.
|
||||||
|
/// Returns the app_id on success.
|
||||||
|
async fn check_push_access(s: &AppState, user_id: &str, app: &str) -> Option<String> {
|
||||||
|
let app_id: String =
|
||||||
|
sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?")
|
||||||
|
.bind(app)
|
||||||
|
.bind(app)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)?;
|
||||||
|
|
||||||
|
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if is_admin != 0 {
|
||||||
|
return Some(app_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted: Option<i64> =
|
||||||
|
sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?")
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&app_id)
|
||||||
|
.fetch_optional(&s.db)
|
||||||
|
.await
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
granted.map(|_| app_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP Smart Protocol — push only
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// git push http://myserver/git/myapp main
|
||||||
|
//
|
||||||
|
// 1. GET /git/:app/info/refs?service=git-receive-pack
|
||||||
|
// → advertise what refs the server has
|
||||||
|
// 2. POST /git/:app/git-receive-pack
|
||||||
|
// → receive the pack, run hooks (post-receive queues the build)
|
||||||
|
|
||||||
|
/// pkt-line service announcement prepended to the info/refs response.
|
||||||
|
/// "# service=git-receive-pack\n" = 27 bytes → pkt-line length = 31 = 0x001f
|
||||||
|
const PKT_SERVICE_HEADER: &[u8] = b"001f# service=git-receive-pack\n0000";
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct InfoRefsParams {
|
||||||
|
service: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn http_info_refs(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_name): Path<String>,
|
||||||
|
Query(params): Query<InfoRefsParams>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Response<Body> {
|
||||||
|
// Authenticate.
|
||||||
|
let user_id = match http_authenticate(&s, &headers).await {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return unauthorized(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only push (receive-pack) is supported.
|
||||||
|
if params.service.as_deref() != Some("git-receive-pack") {
|
||||||
|
return Response::builder()
|
||||||
|
.status(403)
|
||||||
|
.body(Body::from("Only git push is supported"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let app_id = match check_push_access(&s, &user_id, &app_name).await {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return forbidden(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo_path = format!("{}/repos/{}.git", s.data_dir, app_id);
|
||||||
|
|
||||||
|
let output = match tokio::process::Command::new("git")
|
||||||
|
.args(["receive-pack", "--stateless-rpc", "--advertise-refs", &repo_path])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("git receive-pack advertise-refs: {}", e);
|
||||||
|
return Response::builder()
|
||||||
|
.status(500)
|
||||||
|
.body(Body::from("git error"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
tracing::error!(
|
||||||
|
"git receive-pack advertise-refs failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
return Response::builder()
|
||||||
|
.status(500)
|
||||||
|
.body(Body::from("git error"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body = PKT_SERVICE_HEADER.to_vec();
|
||||||
|
body.extend_from_slice(&output.stdout);
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("Content-Type", "application/x-git-receive-pack-advertisement")
|
||||||
|
.header("Cache-Control", "no-cache, max-age=0, must-revalidate")
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn http_receive_pack(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(app_name): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: axum::body::Bytes,
|
||||||
|
) -> Response<Body> {
|
||||||
|
// Authenticate.
|
||||||
|
let user_id = match http_authenticate(&s, &headers).await {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return unauthorized(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let app_id = match check_push_access(&s, &user_id, &app_name).await {
|
||||||
|
Some(id) => id,
|
||||||
|
None => return forbidden(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let repo_path = format!("{}/repos/{}.git", s.data_dir, app_id);
|
||||||
|
|
||||||
|
let mut child = match tokio::process::Command::new("git")
|
||||||
|
.args(["receive-pack", "--stateless-rpc", &repo_path])
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("spawn git receive-pack: {}", e);
|
||||||
|
return Response::builder()
|
||||||
|
.status(500)
|
||||||
|
.body(Body::from("git error"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write the request body to git's stdin.
|
||||||
|
if let Some(mut stdin) = child.stdin.take() {
|
||||||
|
let _ = stdin.write_all(&body).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = match child.wait_with_output().await {
|
||||||
|
Ok(o) => o,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("wait git receive-pack: {}", e);
|
||||||
|
return Response::builder()
|
||||||
|
.status(500)
|
||||||
|
.body(Body::from("git error"))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The post-receive hook queues the build automatically.
|
||||||
|
// Log any stderr for debugging.
|
||||||
|
if !output.stderr.is_empty() {
|
||||||
|
tracing::debug!(
|
||||||
|
"git receive-pack stderr: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(200)
|
||||||
|
.header("Content-Type", "application/x-git-receive-pack-result")
|
||||||
|
.header("Cache-Control", "no-cache, max-age=0, must-revalidate")
|
||||||
|
.body(Body::from(output.stdout))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Internal routes (called by post-receive hook and hiy-git-shell)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct AuthParams {
|
pub struct AuthParams {
|
||||||
|
|
@ -43,49 +272,13 @@ pub async fn auth_check(
|
||||||
return StatusCode::UNAUTHORIZED.into_response();
|
return StatusCode::UNAUTHORIZED.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve app by name (the app ID is the lowercased name slug).
|
let app_id = match check_push_access(&s, ¶ms.user_id, ¶ms.app).await {
|
||||||
let app_id: Option<String> =
|
|
||||||
sqlx::query_scalar("SELECT id FROM apps WHERE id = ? OR name = ?")
|
|
||||||
.bind(¶ms.app)
|
|
||||||
.bind(¶ms.app)
|
|
||||||
.fetch_optional(&s.db)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
|
|
||||||
let app_id = match app_id {
|
|
||||||
Some(id) => id,
|
Some(id) => id,
|
||||||
None => return StatusCode::NOT_FOUND.into_response(),
|
None => return StatusCode::FORBIDDEN.into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Admins can push to any app.
|
|
||||||
let is_admin: i64 = sqlx::query_scalar("SELECT is_admin FROM users WHERE id = ?")
|
|
||||||
.bind(¶ms.user_id)
|
|
||||||
.fetch_optional(&s.db)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if is_admin != 0 {
|
|
||||||
return Json(AuthResponse { app_id }).into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-admin: check user_apps grant.
|
|
||||||
let granted: Option<i64> =
|
|
||||||
sqlx::query_scalar("SELECT 1 FROM user_apps WHERE user_id = ? AND app_id = ?")
|
|
||||||
.bind(¶ms.user_id)
|
|
||||||
.bind(&app_id)
|
|
||||||
.fetch_optional(&s.db)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
|
|
||||||
if granted.is_some() {
|
|
||||||
Json(AuthResponse { app_id }).into_response()
|
Json(AuthResponse { app_id }).into_response()
|
||||||
} else {
|
|
||||||
StatusCode::FORBIDDEN.into_response()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ── POST /internal/git/:app_id/push — post-receive hook triggers a build ─────
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct PushBody {
|
pub struct PushBody {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod api_keys;
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod deploys;
|
pub mod deploys;
|
||||||
pub mod envvars;
|
pub mod envvars;
|
||||||
|
|
|
||||||
|
|
@ -699,9 +699,32 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
style="flex:1;font-size:0.82rem;padding:4px 8px">
|
style="flex:1;font-size:0.82rem;padding:4px 8px">
|
||||||
<button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button>
|
<button onclick="addSshKey('${{u.id}}')" style="font-size:0.82rem">Add key</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="api-keys-${{u.id}}" style="margin-top:12px">
|
||||||
|
<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span>
|
||||||
|
<span class="muted" style="font-size:0.82rem"> loading…</span>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:8px;gap:6px">
|
||||||
|
<input type="text" id="apikey-label-${{u.id}}" placeholder="label (e.g. laptop)"
|
||||||
|
style="max-width:140px;font-size:0.82rem;padding:4px 8px">
|
||||||
|
<button onclick="generateApiKey('${{u.id}}')" style="font-size:0.82rem">Generate key</button>
|
||||||
|
</div>
|
||||||
|
<div id="apikey-reveal-${{u.id}}" style="display:none;margin-top:8px;padding:10px;
|
||||||
|
background:#0f172a;border:1px solid #334155;border-radius:6px">
|
||||||
|
<div style="font-size:0.78rem;color:#94a3b8;margin-bottom:4px">
|
||||||
|
Copy this key now — it will not be shown again.
|
||||||
|
</div>
|
||||||
|
<code id="apikey-value-${{u.id}}" style="font-size:0.82rem;color:#a3e635;word-break:break-all"></code>
|
||||||
|
<br><br>
|
||||||
|
<div style="font-size:0.78rem;color:#94a3b8">Use it to push:</div>
|
||||||
|
<code style="font-size:0.78rem;color:#e2e8f0">
|
||||||
|
git remote add hiy http://hiy:<span id="apikey-hint-${{u.id}}"></span>@<span class="domain-hint"></span>/git/YOUR_APP<br>
|
||||||
|
git push hiy main
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
// Load SSH keys for each user asynchronously.
|
document.querySelectorAll('.domain-hint').forEach(el => el.textContent = location.host);
|
||||||
for (const u of users) loadSshKeys(u.id);
|
// Load SSH and API keys for each user asynchronously.
|
||||||
|
for (const u of users) {{ loadSshKeys(u.id); loadApiKeys(u.id); }}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
async function addUser() {{
|
async function addUser() {{
|
||||||
|
|
@ -764,6 +787,47 @@ pub async fn users_page(State(state): State<AppState>) -> impl IntoResponse {
|
||||||
load();
|
load();
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
async function loadApiKeys(userId) {{
|
||||||
|
const res = await fetch('/api/users/' + userId + '/api-keys');
|
||||||
|
const keys = await res.json();
|
||||||
|
const el = document.getElementById('api-keys-' + userId);
|
||||||
|
if (!el) return;
|
||||||
|
if (!keys.length) {{
|
||||||
|
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> <span class="muted" style="font-size:0.82rem">none</span>';
|
||||||
|
}} else {{
|
||||||
|
el.innerHTML = '<span class="muted" style="font-size:0.78rem">API keys (git push over HTTP):</span> ' +
|
||||||
|
keys.map(k => `
|
||||||
|
<span class="badge badge-unknown" style="margin:0 4px 4px 4px">
|
||||||
|
${{k.label}}
|
||||||
|
<a href="javascript:void(0)" style="color:#f87171;margin-left:4px"
|
||||||
|
onclick="revokeApiKey('${{k.id}}','${{userId}}')">✕</a>
|
||||||
|
</span>`).join('');
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function generateApiKey(userId) {{
|
||||||
|
const label = document.getElementById('apikey-label-' + userId).value.trim();
|
||||||
|
if (!label) {{ alert('Enter a label first.'); return; }}
|
||||||
|
const res = await fetch('/api/users/' + userId + '/api-keys', {{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {{'Content-Type': 'application/json'}},
|
||||||
|
body: JSON.stringify({{label}})
|
||||||
|
}});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('apikey-label-' + userId).value = '';
|
||||||
|
document.getElementById('apikey-value-' + userId).textContent = data.key;
|
||||||
|
document.getElementById('apikey-hint-' + userId).textContent = data.key;
|
||||||
|
document.getElementById('apikey-reveal-' + userId).style.display = '';
|
||||||
|
loadApiKeys(userId);
|
||||||
|
}}
|
||||||
|
|
||||||
|
async function revokeApiKey(keyId, userId) {{
|
||||||
|
await fetch('/api/api-keys/' + keyId, {{method: 'DELETE'}});
|
||||||
|
document.getElementById('apikey-reveal-' + userId).style.display = 'none';
|
||||||
|
loadApiKeys(userId);
|
||||||
|
}}
|
||||||
|
|
||||||
async function loadSshKeys(userId) {{
|
async function loadSshKeys(userId) {{
|
||||||
const res = await fetch('/api/users/' + userId + '/ssh-keys');
|
const res = await fetch('/api/users/' + userId + '/ssh-keys');
|
||||||
const keys = await res.json();
|
const keys = await res.json();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue