🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://git.takibi.wtf/yuki/okibi.git

← コミット一覧

35b0a7f0ddbfffb6c098603f5cb29c197bf33a22
yuki
1781057140
admin /admin/import: server-side mirror-clone import + 2GB RAM

---
 fly.toml     |  2 +-
 src/main.rs  |  1 +
 src/repos.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++-------
 src/web.rs   | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 149 insertions(+), 10 deletions(-)

diff --git a/fly.toml b/fly.toml
index fa8cbf4..e2c9b30 100644
--- a/fly.toml
+++ b/fly.toml
@@ -33,4 +33,4 @@ primary_region = "nrt"
 [[vm]]
   cpu_kind = "shared"
   cpus = 1
-  memory_mb = 512
+  memory_mb = 2048
diff --git a/src/main.rs b/src/main.rs
index a2e2d29..f1a604e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -48,6 +48,7 @@ async fn main() -> anyhow::Result<()> {
         .route("/auth/bootstrap", get(web::bootstrap))
         .route("/members", get(web::members_page))
         .route("/members/invite", post(web::invite_submit))
+        .route("/admin/import", post(web::admin_import))
         .route("/logout", post(web::logout))
         .route("/new", get(web::new_repo_form).post(web::new_repo_submit))
         .route("/settings", get(web::settings))
diff --git a/src/repos.rs b/src/repos.rs
index 4cea6a3..b23271e 100644
--- a/src/repos.rs
+++ b/src/repos.rs
@@ -26,28 +26,43 @@ pub fn repo_path(cfg: &Config, owner_handle: &str, name: &str) -> PathBuf {
     cfg.repos_dir.join(owner_handle).join(format!("{name}.git"))
 }
 
-/// Create repo metadata + initialise a bare git repo on disk.
-pub async fn create_repo(
+/// Insert a repo row (no disk side-effects).
+pub async fn insert_repo_meta(
     db: &Db,
-    cfg: &Config,
-    owner: &User,
+    owner_id: i64,
     name: &str,
     visibility: &str,
     description: &str,
+    default_branch: &str,
 ) -> anyhow::Result<Repo> {
     let visibility = if visibility == "public" { "public" } else { "private" };
     sqlx::query(
         "INSERT INTO repos (owner_id, name, visibility, default_branch, description, created_at)
-         VALUES (?, ?, ?, 'main', ?, ?)",
+         VALUES (?, ?, ?, ?, ?, ?)",
     )
-    .bind(owner.id)
+    .bind(owner_id)
     .bind(name)
     .bind(visibility)
+    .bind(default_branch)
     .bind(description)
     .bind(now())
     .execute(db)
     .await?;
+    get_repo(db, owner_id, name)
+        .await
+        .ok_or_else(|| anyhow::anyhow!("repo vanished after insert"))
+}
 
+/// Create repo metadata + initialise a bare git repo on disk.
+pub async fn create_repo(
+    db: &Db,
+    cfg: &Config,
+    owner: &User,
+    name: &str,
+    visibility: &str,
+    description: &str,
+) -> anyhow::Result<Repo> {
+    let repo = insert_repo_meta(db, owner.id, name, visibility, description, "main").await?;
     let path = repo_path(cfg, &owner.handle, name);
     std::fs::create_dir_all(&path)?;
     let path_str = path.to_string_lossy().to_string();
@@ -55,10 +70,62 @@ pub async fn create_repo(
     // Allow smart-HTTP push/pull for this repo.
     run_git_in(cfg, &path, &["config", "http.receivepack", "true"])?;
     run_git_in(cfg, &path, &["config", "http.uploadpack", "true"])?;
+    Ok(repo)
+}
 
-    get_repo(db, owner.id, name)
-        .await
-        .ok_or_else(|| anyhow::anyhow!("repo vanished after insert"))
+/// Server-side import: the host mirror-clones `git_url` into the repo's bare
+/// path, then scrubs any embedded credentials. Blocking (run in spawn_blocking).
+pub fn import_clone(cfg: &Config, owner_handle: &str, name: &str, git_url: &str) -> anyhow::Result<()> {
+    let path = repo_path(cfg, owner_handle, name);
+    if path.exists() {
+        anyhow::bail!("path already exists");
+    }
+    if let Some(parent) = path.parent() {
+        std::fs::create_dir_all(parent)?;
+    }
+    let out = Command::new(&cfg.git_bin)
+        .env("GIT_TERMINAL_PROMPT", "0")
+        .args(["clone", "--mirror", "--quiet", git_url])
+        .arg(&path)
+        .output()?;
+    if !out.status.success() {
+        let _ = std::fs::remove_dir_all(&path);
+        anyhow::bail!("clone failed: {}", scrub(&String::from_utf8_lossy(&out.stderr)));
+    }
+    // Drop the origin remote so no token persists in the bare repo's config.
+    let _ = run_git_in(cfg, &path, &["remote", "remove", "origin"]);
+    run_git_in(cfg, &path, &["config", "http.receivepack", "true"])?;
+    run_git_in(cfg, &path, &["config", "http.uploadpack", "true"])?;
+    Ok(())
+}
+
+/// Detect the default branch of an imported bare repo (HEAD target).
+pub fn detect_head(cfg: &Config, owner_handle: &str, name: &str) -> Option<String> {
+    let path = repo_path(cfg, owner_handle, name);
+    let out = run_git_in(cfg, &path, &["symbolic-ref", "--short", "HEAD"]).ok()?;
+    let b = out.trim().to_string();
+    if b.is_empty() {
+        None
+    } else {
+        Some(b)
+    }
+}
+
+/// Redact `scheme://user:pass@host` credentials from each whitespace token
+/// before logging, so tokens never reach the logs.
+fn scrub(s: &str) -> String {
+    s.split_whitespace()
+        .map(|tok| {
+            if let Some(scheme_end) = tok.find("://") {
+                let after = &tok[scheme_end + 3..];
+                if let Some(at) = after.find('@') {
+                    return format!("{}://***@{}", &tok[..scheme_end], &after[at + 1..]);
+                }
+            }
+            tok.to_string()
+        })
+        .collect::<Vec<_>>()
+        .join(" ")
 }
 
 pub async fn get_repo(db: &Db, owner_id: i64, name: &str) -> Option<Repo> {
diff --git a/src/web.rs b/src/web.rs
index f7a5929..2363703 100644
--- a/src/web.rs
+++ b/src/web.rs
@@ -226,6 +226,16 @@ pub async fn members_page(State(state): State<AppState>, headers: HeaderMap) ->
                 button type="submit" { "発行" }
             }
         }
+        section.card {
+            h2 { "プロジェクトを取り込む(サーバー側 mirror clone)" }
+            p.muted { "GitHub 等の URL を Okibi マシンが直接クローンします。private は URL に token を埋めてください(保存後に scrub されます)。" }
+            form method="post" action="/admin/import" class="row" {
+                input type="text" name="name" placeholder="repo名" required pattern="[A-Za-z0-9_.-]+";
+                input type="text" name="git_url" placeholder="https://token@github.com/owner/repo.git" required;
+                label.row { input type="checkbox" name="public" value="1"; " public" }
+                button type="submit" { "取り込み" }
+            }
+        }
         section.card {
             h2 { "現在のメンバー" }
             ul {
@@ -282,6 +292,67 @@ pub async fn invite_submit(
     Html(layout("招待", &Some(user), body).into_string()).into_response()
 }
 
+#[derive(Deserialize)]
+pub struct ImportInput {
+    name: String,
+    git_url: String,
+    #[serde(default)]
+    public: Option<String>,
+}
+
+// POST /admin/import — admin only. Server-side mirror-clone of an external repo.
+pub async fn admin_import(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(input): Form<ImportInput>,
+) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) if u.is_admin != 0 => u,
+        Some(_) => return (StatusCode::FORBIDDEN, "admin only\n").into_response(),
+        None => return (StatusCode::UNAUTHORIZED, "login required\n").into_response(),
+    };
+    let name = input.name.trim().to_string();
+    if !valid_name(&name) {
+        return (StatusCode::BAD_REQUEST, "bad name\n").into_response();
+    }
+    if repos::get_repo(&state.db, user.id, &name).await.is_some() {
+        return (StatusCode::CONFLICT, "repo already exists\n").into_response();
+    }
+    let git_url = input.git_url.trim().to_string();
+    if !git_url.starts_with("http://") && !git_url.starts_with("https://") {
+        return (StatusCode::BAD_REQUEST, "only http(s) urls\n").into_response();
+    }
+    let vis = if input.public.is_some() { "public" } else { "private" };
+    // Insert metadata now; clone fills the bare path in the background.
+    if let Err(e) = repos::insert_repo_meta(&state.db, user.id, &name, vis, "imported", "main").await {
+        return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}\n")).into_response();
+    }
+    let st = state.clone();
+    let handle = user.handle.clone();
+    let nm = name.clone();
+    tokio::task::spawn_blocking(move || {
+        match repos::import_clone(&st.cfg, &handle, &nm, &git_url) {
+            Ok(()) => {
+                // fix default_branch to the imported HEAD
+                if let Some(br) = repos::detect_head(&st.cfg, &handle, &nm) {
+                    let db = st.db.clone();
+                    let nm2 = nm.clone();
+                    tokio::spawn(async move {
+                        let _ = sqlx::query("UPDATE repos SET default_branch = ? WHERE name = ?")
+                            .bind(br)
+                            .bind(nm2)
+                            .execute(&db)
+                            .await;
+                    });
+                }
+                tracing::info!("imported {handle}/{nm}");
+            }
+            Err(e) => tracing::error!("import {handle}/{nm} failed: {e}"),
+        }
+    });
+    (StatusCode::ACCEPTED, format!("import started: {name}\n")).into_response()
+}
+
 // POST /logout
 pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
     if let Some(tok) = auth::cookie(&headers, SESSION_COOKIE) {