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) {