🔥 Okibi

yuki / okibi

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

git clone https://okibi.fly.dev/yuki/okibi.git

okibi / src / repos.rs

use crate::auth::User;
use crate::config::Config;
use crate::db::Db;
use crate::util::now;
use std::path::PathBuf;
use std::process::Command;

#[derive(Clone, Debug, sqlx::FromRow)]
pub struct Repo {
    pub id: i64,
    pub owner_id: i64,
    pub name: String,
    pub visibility: String,
    pub default_branch: String,
    pub description: String,
}

impl Repo {
    pub fn is_public(&self) -> bool {
        self.visibility == "public"
    }
}

/// On-disk path of a repo's bare git dir.
pub fn repo_path(cfg: &Config, owner_handle: &str, name: &str) -> PathBuf {
    cfg.repos_dir.join(owner_handle).join(format!("{name}.git"))
}

/// Insert a repo row (no disk side-effects).
pub async fn insert_repo_meta(
    db: &Db,
    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 (?, ?, ?, ?, ?, ?)",
    )
    .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();
    run_git(cfg, &["init", "--bare", "--initial-branch=main", &path_str])?;
    // 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)
}

/// 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> {
    sqlx::query_as::<_, Repo>(
        "SELECT id, owner_id, name, visibility, default_branch, description
         FROM repos WHERE owner_id = ? AND name = ?",
    )
    .bind(owner_id)
    .bind(name)
    .fetch_optional(db)
    .await
    .ok()
    .flatten()
}

pub async fn list_repos_for_owner(db: &Db, owner_id: i64) -> Vec<Repo> {
    sqlx::query_as::<_, Repo>(
        "SELECT id, owner_id, name, visibility, default_branch, description
         FROM repos WHERE owner_id = ? ORDER BY name",
    )
    .bind(owner_id)
    .fetch_all(db)
    .await
    .unwrap_or_default()
}

/// Repos visible to `viewer` (public ones + viewer's own). Newest first.
pub async fn list_visible(db: &Db, viewer: Option<&User>) -> Vec<(Repo, String)> {
    let rows = sqlx::query_as::<_, (i64, i64, String, String, String, String, String)>(
        "SELECT r.id, r.owner_id, r.name, r.visibility, r.default_branch, r.description, u.handle
         FROM repos r JOIN users u ON u.id = r.owner_id
         ORDER BY r.created_at DESC",
    )
    .fetch_all(db)
    .await
    .unwrap_or_default();
    rows.into_iter()
        .map(|(id, owner_id, name, visibility, default_branch, description, handle)| {
            (
                Repo { id, owner_id, name, visibility, default_branch, description },
                handle,
            )
        })
        .filter(|(r, _)| {
            r.is_public() || viewer.map(|v| v.id == r.owner_id).unwrap_or(false)
        })
        .collect()
}

/// Does the repo have any commits yet?
pub fn is_empty(cfg: &Config, owner_handle: &str, name: &str) -> bool {
    let path = repo_path(cfg, owner_handle, name);
    run_git_in(cfg, &path, &["rev-parse", "--verify", "HEAD"]).is_err()
}

/// `git ls-tree` a path at HEAD (or a ref). Returns (mode, type, name) rows.
pub fn ls_tree(cfg: &Config, owner_handle: &str, name: &str, rev: &str, subpath: &str) -> Vec<(String, String, String)> {
    let path = repo_path(cfg, owner_handle, name);
    let spec = if subpath.is_empty() {
        rev.to_string()
    } else {
        format!("{rev}:{subpath}")
    };
    let out = match run_git_in(cfg, &path, &["ls-tree", "--long", &spec]) {
        Ok(o) => o,
        Err(_) => return vec![],
    };
    let mut entries = vec![];
    for line in out.lines() {
        // <mode> <type> <object> <size>\t<name>
        if let Some((meta, fname)) = line.split_once('\t') {
            let cols: Vec<&str> = meta.split_whitespace().collect();
            if cols.len() >= 2 {
                entries.push((cols[0].to_string(), cols[1].to_string(), fname.to_string()));
            }
        }
    }
    // dirs first, then files, alpha
    entries.sort_by(|a, b| {
        let ad = a.1 == "tree";
        let bd = b.1 == "tree";
        bd.cmp(&ad).then(a.2.cmp(&b.2))
    });
    entries
}

/// Read a blob's bytes at `rev:path`.
pub fn read_blob(cfg: &Config, owner_handle: &str, name: &str, rev: &str, file: &str) -> Option<Vec<u8>> {
    let path = repo_path(cfg, owner_handle, name);
    let spec = format!("{rev}:{file}");
    let output = Command::new(&cfg.git_bin)
        .arg("-C")
        .arg(&path)
        .args(["cat-file", "blob", &spec])
        .output()
        .ok()?;
    if output.status.success() {
        Some(output.stdout)
    } else {
        None
    }
}

/// Latest commit (sha, subject, unix time) for a ref.
pub fn head_commit(cfg: &Config, owner_handle: &str, name: &str, rev: &str) -> Option<(String, String, i64)> {
    let path = repo_path(cfg, owner_handle, name);
    let out = run_git_in(cfg, &path, &["log", "-1", "--format=%H%x00%s%x00%ct", rev]).ok()?;
    let parts: Vec<&str> = out.trim().split('\u{0}').collect();
    if parts.len() == 3 {
        Some((parts[0].to_string(), parts[1].to_string(), parts[2].parse().unwrap_or(0)))
    } else {
        None
    }
}

fn run_git(cfg: &Config, args: &[&str]) -> anyhow::Result<String> {
    let out = Command::new(&cfg.git_bin).args(args).output()?;
    if !out.status.success() {
        anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
    }
    Ok(String::from_utf8_lossy(&out.stdout).to_string())
}

fn run_git_in(cfg: &Config, dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
    let out = Command::new(&cfg.git_bin).arg("-C").arg(dir).args(args).output()?;
    if !out.status.success() {
        anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
    }
    Ok(String::from_utf8_lossy(&out.stdout).to_string())
}