tirbofish/kitgit · diff
feat: add repository pull mirroring from remotes
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,15 @@ +-- Pull mirrors: fetch from a remote into the local bare repository. +CREATE TABLE IF NOT EXISTS repo_mirrors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + repo_id UUID NOT NULL UNIQUE REFERENCES repositories(id) ON DELETE CASCADE, + remote_url TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_synced_at TIMESTAMPTZ, + last_error TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT repo_mirrors_remote_url_nonempty CHECK (char_length(trim(remote_url)) > 0) +); +CREATE INDEX IF NOT EXISTS repo_mirrors_enabled_idx + ON repo_mirrors(enabled) WHERE enabled = TRUE; @@ -101,6 +101,19 @@ pub struct BranchRule { pub created_at: DateTime<Utc>, } +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct RepoMirror { + pub id: Uuid, + pub repo_id: Uuid, + pub remote_url: String, + pub enabled: bool, + pub last_synced_at: Option<DateTime<Utc>>, + pub last_error: Option<String>, + pub created_by: Option<Uuid>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + #[derive(Debug, Clone, FromRow)] pub struct CommentReaction { pub comment_id: Uuid, @@ -2349,3 +2349,82 @@ pub async fn lfs_object_size(pool: &PgPool, oid: &str) -> Result<Option<i64>> { .await?; Ok(row.map(|r| r.0)) } + +// ── repo mirrors ───────────────────────────────────────────────────────────── + +pub async fn get_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<Option<RepoMirror>> { + Ok(sqlx::query_as::<_, RepoMirror>( + "SELECT * FROM repo_mirrors WHERE repo_id = $1", + ) + .bind(repo_id) + .fetch_optional(pool) + .await?) +} + +pub async fn upsert_repo_mirror( + pool: &PgPool, + repo_id: Uuid, + remote_url: &str, + enabled: bool, + created_by: Uuid, +) -> Result<RepoMirror> { + Ok(sqlx::query_as::<_, RepoMirror>( + r#" + INSERT INTO repo_mirrors (repo_id, remote_url, enabled, created_by) + VALUES ($1, $2, $3, $4) + ON CONFLICT (repo_id) DO UPDATE SET + remote_url = EXCLUDED.remote_url, + enabled = EXCLUDED.enabled, + updated_at = now() + RETURNING * + "#, + ) + .bind(repo_id) + .bind(remote_url) + .bind(enabled) + .bind(created_by) + .fetch_one(pool) + .await?) +} + +pub async fn set_mirror_sync_result( + pool: &PgPool, + repo_id: Uuid, + ok: bool, + error: Option<&str>, +) -> Result<RepoMirror> { + if ok { + Ok(sqlx::query_as::<_, RepoMirror>( + r#" + UPDATE repo_mirrors + SET last_synced_at = now(), last_error = NULL, updated_at = now() + WHERE repo_id = $1 + RETURNING * + "#, + ) + .bind(repo_id) + .fetch_one(pool) + .await?) + } else { + Ok(sqlx::query_as::<_, RepoMirror>( + r#" + UPDATE repo_mirrors + SET last_error = $2, updated_at = now() + WHERE repo_id = $1 + RETURNING * + "#, + ) + .bind(repo_id) + .bind(error) + .fetch_one(pool) + .await?) + } +} + +pub async fn delete_repo_mirror(pool: &PgPool, repo_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM repo_mirrors WHERE repo_id = $1") + .bind(repo_id) + .execute(pool) + .await?; + Ok(()) +} @@ -79,6 +79,83 @@ pub fn rename_branch(repo: &G2Repo, old: &str, new: &str) -> Result<()> { Ok(()) } +/// Whether `url` is an allowed remote for pull-mirroring (no local/file paths). +pub fn is_safe_mirror_url(url: &str) -> bool { + let url = url.trim(); + if url.is_empty() || url.contains('\0') || url.contains('\n') || url.contains('\r') { + return false; + } + let lower = url.to_ascii_lowercase(); + if lower.starts_with("file:") + || lower.starts_with('/') + || lower.starts_with("\\\\") + || (url.len() >= 2 && url.as_bytes()[1] == b':') + { + return false; + } + lower.starts_with("https://") + || lower.starts_with("http://") + || lower.starts_with("git://") + || lower.starts_with("ssh://") + || (url.contains('@') && url.contains(':') && !lower.contains("://")) +} + +/// Fetch refs from `remote_url` into the existing bare repo (pull mirror). +pub fn mirror_fetch(repos_root: &Path, owner: &str, name: &str, remote_url: &str) -> Result<()> { + if !is_safe_mirror_url(remote_url) { + return Err(anyhow!("unsupported or unsafe mirror URL")); + } + let path = bare_path(repos_root, owner, name); + if !path.exists() { + return Err(anyhow!("local bare repository missing")); + } + let path_str = path + .to_str() + .ok_or_else(|| anyhow!("repository path is not valid UTF-8"))?; + let url = remote_url.trim(); + + // Dedicated remote so we do not clobber a user's `origin`. + let _ = std::process::Command::new("git") + .args(["-C", path_str, "remote", "remove", "kitgit-mirror"]) + .output(); + let add = std::process::Command::new("git") + .args(["-C", path_str, "remote", "add", "kitgit-mirror", url]) + .output() + .with_context(|| "git remote add")?; + if !add.status.success() { + return Err(anyhow!( + "git remote add failed: {}", + String::from_utf8_lossy(&add.stderr).trim() + )); + } + + let fetch = std::process::Command::new("git") + .args([ + "-C", + path_str, + "fetch", + "kitgit-mirror", + "+refs/heads/*:refs/heads/*", + "+refs/tags/*:refs/tags/*", + "--prune", + "--force", + ]) + .output() + .with_context(|| "git fetch mirror")?; + if !fetch.status.success() { + return Err(anyhow!( + "git fetch failed: {}", + String::from_utf8_lossy(&fetch.stderr).trim() + )); + } + + if let Ok(repo) = G2Repo::open_bare(&path) { + let _ = repo.config()?.set_bool("http.receivepack", true); + let _ = repo.config()?.set_bool("http.uploadpack", true); + } + Ok(()) +} + /// Copy a bare repository on disk (for forks). pub fn clone_bare(src: &Path, dest: &Path) -> Result<()> { if dest.exists() { @@ -270,6 +270,18 @@ pub fn app_router(state: AppState) -> Router { post(routes_extra::branch_rule_delete), ) .route( + "/{owner}/{repo}/settings/mirror", + post(routes_extra::mirror_save), + ) + .route( + "/{owner}/{repo}/settings/mirror/sync", + post(routes_extra::mirror_sync), + ) + .route( + "/{owner}/{repo}/settings/mirror/delete", + post(routes_extra::mirror_delete), + ) + .route( "/{owner}/{repo}/settings/danger/archive", post(routes::repo_archive), ) @@ -3719,7 +3719,8 @@ pub async fn repo_settings( ) -> AppResult<impl IntoResponse> { let (repository, owner_user, viewer, access) = load_repo_context(&state, &owner, &repo, &headers).await?; - if !access.can_admin() { + let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false); + if !access.can_admin() && !is_site_admin { return Err(AppError::forbidden()); } let collaborators = queries::list_collaborators(&state.pool, repository.id).await?; @@ -3743,7 +3744,7 @@ pub async fn repo_settings( } let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo); let branch_rules = queries::list_branch_rules(&state.pool, repository.id).await?; - let is_site_admin = viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false); + let mirror = queries::get_repo_mirror(&state.pool, repository.id).await?; Ok(RepoSettingsTemplate { viewer, owner: owner_user, @@ -3754,6 +3755,7 @@ pub async fn repo_settings( clone_http, clone_ssh, branch_rules, + mirror, is_site_admin, error: None, }) @@ -800,6 +800,114 @@ pub async fn gpg_delete( Ok(redirect_see_other("/settings/keys")) } +fn can_manage_mirror(access: crate::db::models::Access, viewer: &Option<crate::db::models::User>) -> bool { + access.can_admin() || viewer.as_ref().map(|u| u.is_site_admin).unwrap_or(false) +} + +fn truncate_err(msg: &str) -> String { + const MAX: usize = 2000; + let msg = msg.trim(); + if msg.len() <= MAX { + msg.to_string() + } else { + format!("{}…", &msg[..MAX]) + } +} + +#[derive(Deserialize)] +pub struct MirrorForm { + pub remote_url: String, + pub enabled: Option<String>, +} + +pub async fn mirror_save( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, + Form(form): Form<MirrorForm>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let user = viewer.as_ref().ok_or_else(AppError::unauthorized)?; + if !can_manage_mirror(access, &viewer) { + return Err(AppError::forbidden()); + } + let url = form.remote_url.trim(); + if url.is_empty() { + return Err(AppError::bad("mirror URL required")); + } + if !git::is_safe_mirror_url(url) { + return Err(AppError::bad( + "mirror URL must be http(s), git://, ssh://, or git@host:path", + )); + } + queries::upsert_repo_mirror( + &state.pool, + repository.id, + url, + checkbox(&form.enabled), + user.id, + ) + .await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn mirror_sync( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _user = viewer.as_ref().ok_or_else(AppError::unauthorized)?; + if !can_manage_mirror(access, &viewer) { + return Err(AppError::forbidden()); + } + let mirror = queries::get_repo_mirror(&state.pool, repository.id) + .await? + .ok_or_else(|| AppError::bad("no mirror configured"))?; + if !mirror.enabled { + return Err(AppError::bad("mirror is disabled")); + } + + let repos_dir = state.config.repos_dir(); + let owner_s = owner.clone(); + let repo_s = repo.clone(); + let remote_url = mirror.remote_url.clone(); + let result = tokio::task::spawn_blocking(move || { + git::mirror_fetch(&repos_dir, &owner_s, &repo_s, &remote_url) + }) + .await + .map_err(|e| AppError::internal(format!("mirror sync join: {e}")))?; + + match result { + Ok(()) => { + queries::set_mirror_sync_result(&state.pool, repository.id, true, None).await?; + } + Err(e) => { + let msg = truncate_err(&format!("{e:#}")); + queries::set_mirror_sync_result(&state.pool, repository.id, false, Some(&msg)).await?; + return Err(AppError::bad(format!("mirror sync failed: {msg}"))); + } + } + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + +pub async fn mirror_delete( + State(state): State<AppState>, + headers: HeaderMap, + Path((owner, repo)): Path<(String, String)>, +) -> AppResult<Response> { + let (repository, _, viewer, access) = + load_repo_context(&state, &owner, &repo, &headers).await?; + let _user = viewer.as_ref().ok_or_else(AppError::unauthorized)?; + if !can_manage_mirror(access, &viewer) { + return Err(AppError::forbidden()); + } + queries::delete_repo_mirror(&state.pool, repository.id).await?; + Ok(redirect_see_other(&format!("/{owner}/{repo}/settings"))) +} + // silence unused import warning for avatar_url_for if not used #[allow(dead_code)] fn _use_avatar(u: &crate::db::models::User) -> String { @@ -1,7 +1,7 @@ use crate::og::SocialMeta; use crate::db::models::{ - Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User, - UserEmail, + Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, RepoMirror, Repository, + SshKey, User, UserEmail, }; use askama::Template; use askama_web::WebTemplate; @@ -521,6 +521,7 @@ pub struct RepoSettingsTemplate { pub collaborators: Vec<CollaboratorView>, pub branches: Vec<String>, pub branch_rules: Vec<BranchRule>, + pub mirror: Option<RepoMirror>, pub clone_http: String, pub clone_ssh: String, pub is_site_admin: bool, @@ -146,6 +146,64 @@ </div> <div class="kg-settings-section"> + <span class="kg-kicker">mirror</span> + <p class="kg-muted">Pull from another remote into this repository (fetch heads and tags).</p> + {% if let Some(m) = mirror %} + <p class="kg-meta" style="margin:0.5rem 0"> + {% if m.enabled %} + <span class="kg-badge">enabled</span> + {% else %} + <span class="kg-badge">disabled</span> + {% endif %} + {% if let Some(synced) = m.last_synced_at %} + · last synced {{ synced }} + {% else %} + · never synced + {% endif %} + </p> + {% if let Some(err) = m.last_error %} + <div class="kg-flash">{{ err }}</div> + {% endif %} + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror" style="margin-top:1rem"> + <label> + remote URL + <input type="text" name="remote_url" required value="{{ m.remote_url }}" placeholder="https://github.com/org/repo.git" /> + </label> + <label class="row"> + <input type="checkbox" name="enabled"{% if m.enabled %} checked{% endif %} /> + enabled + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save mirror</button> + </div> + </form> + <div class="kg-actions" style="margin-top:0.75rem;gap:0.5rem;display:flex;flex-wrap:wrap"> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror/sync"> + <button class="kg-btn kg-btn--ghost" type="submit">sync now</button> + </form> + <form method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror/delete"> + <button class="kg-btn kg-btn--danger-ghost" type="submit">remove mirror</button> + </form> + </div> + {% else %} + <p class="kg-muted">No mirror configured.</p> + <form class="kg-form" method="post" action="/{{ owner.username }}/{{ repo.name }}/settings/mirror" style="margin-top:1rem"> + <label> + remote URL + <input type="text" name="remote_url" required placeholder="https://github.com/org/repo.git" /> + </label> + <label class="row"> + <input type="checkbox" name="enabled" checked /> + enabled + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save mirror</button> + </div> + </form> + {% endif %} + </div> + + <div class="kg-settings-section"> <span class="kg-kicker">collaborators</span> {% if collaborators.is_empty() %} <p class="kg-muted">No collaborators.</p>