OXYGENHUB
◆ v0.9.8 oxygenhub / crates / hub / src / mirror.rs
8.3 KiB · Rust · last changed A durable work queue, first slice (refs #276) 15 days ago source blame history raw
1
//! The read-only Git mirror (hub spec §14): the default bookmark exported
2
//! with oxy's own `git export` into `<data>/mirrors/<owner>/<name>.git`
3
//! after every update, and served over Git's smart HTTP by `git
4
//! http-backend` behind the hub's access checks. Pushing to it is refused.
5
6
use crate::auth::{self, Access};
7
use crate::db::{self, repos::RepoRow};
8
use crate::repos::RepoName;
9
use crate::Shared;
10
use axum::body::Bytes;
11
use axum::extract::{Path, RawQuery, State};
12
use axum::http::{header, HeaderMap, Method, StatusCode};
13
use axum::response::{IntoResponse, Response};
14
use std::path::PathBuf;
15
use tokio::io::AsyncWriteExt;
16
17
pub fn mirror_dir(app: &Shared, repo: &RepoName) -> PathBuf {
18
    app.config.data.join("mirrors").join(&repo.owner).join(format!("{}.git", repo.name))
19
}
20
21
/// Export the default bookmark into the mirror, if the mirror is on.
22
pub async fn refresh(app: &Shared, row: &RepoRow) -> Result<(), String> {
23
    if !row.git_mirror {
24
        return Ok(());
25
    }
26
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return Ok(()) };
27
    let dir = mirror_dir(app, &repo);
28
    let bookmark = row.default_bookmark.clone();
29
    let result = app
30
        .repos
31
        .write(&repo, move |r, _| {
32
            let mut r = oxy_core::repo::Repo::open(r.root())?;
33
            r.config.bookmark = bookmark.clone();
34
            let report = r.git_export_as(&dir, &bookmark)?;
35
            // Point HEAD at the exported branch so clones check it out.
36
            std::fs::write(dir.join("HEAD"), format!("ref: refs/heads/{bookmark}\n"))?;
37
            let state = r.view()?.bookmarks.get(&bookmark).map(|s| s.to_hex()).unwrap_or_default();
38
            Ok((report.exported, state))
39
        })
40
        .await;
41
    match result {
42
        Ok((_, state)) => {
43
            db::repos::set_git_mirror_state(&app.db, row.id, &state).await.ok();
44
            Ok(())
45
        }
46
        Err(e) => {
47
            tracing::warn!("git mirror of {}: {e}", row.slug());
48
            Err(format!("git mirror of {}: {e}", row.slug()))
49
        }
50
    }
51
}
52
53
/// Who a Git client is: HTTP Basic with a personal access token as the
54
/// password (any user name), or anonymous.
55
async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result<Option<db::accounts::Account>, StatusCode> {
56
    let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) };
57
    let token = if let Some(b) = v.strip_prefix("Basic ") {
58
        let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?;
59
        let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?;
60
        s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)?
61
    } else if let Some(t) = v.strip_prefix("Bearer ") {
62
        t.trim().to_string()
63
    } else {
64
        return Err(StatusCode::UNAUTHORIZED);
65
    };
66
    let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
67
    match tok {
68
        Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => {
69
            db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
70
        }
71
        _ => Err(StatusCode::UNAUTHORIZED),
72
    }
73
}
74
75
fn base64_decode(s: &str) -> Option<Vec<u8>> {
76
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
77
    let mut out = Vec::new();
78
    let mut buf = 0u32;
79
    let mut bits = 0;
80
    for c in s.bytes().filter(|c| *c != b'=') {
81
        let v = T.iter().position(|x| *x == c)? as u32;
82
        buf = (buf << 6) | v;
83
        bits += 6;
84
        if bits >= 8 {
85
            bits -= 8;
86
            out.push((buf >> bits) as u8);
87
            buf &= (1 << bits) - 1;
88
        }
89
    }
90
    Some(out)
91
}
92
93
fn challenge() -> Response {
94
    (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"OxygenHub\"")], "authentication required").into_response()
95
}
96
97
/// Every smart-HTTP path under `/{owner}/{name}.git/`.
98
pub async fn serve(
99
    State(app): State<Shared>,
100
    Path((owner, name_git, rest)): Path<(String, String, String)>,
101
    method: Method,
102
    RawQuery(query): RawQuery,
103
    headers: HeaderMap,
104
    body: Bytes,
105
) -> Response {
106
    let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() };
107
    let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() };
108
    let query = query.unwrap_or_default();
109
    if rest.contains("git-receive-pack") || query.contains("git-receive-pack") {
110
        return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response();
111
    }
112
    let row = match db::repos::get(&app.db, &owner, name).await {
113
        Ok(Some(r)) if r.git_mirror => r,
114
        _ => return StatusCode::NOT_FOUND.into_response(),
115
    };
116
    let account = match git_caller(&app, &headers).await {
117
        Ok(a) => a,
118
        Err(_) => return challenge(),
119
    };
120
    let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten();
121
    if !auth::roles::allows(role, Access::Read) {
122
        return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() };
123
    }
124
    let dir = mirror_dir(&app, &repo);
125
    if !dir.join("HEAD").exists() {
126
        refresh(&app, &row).await.ok();
127
    }
128
    let root = app.config.data.join("mirrors");
129
    let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into());
130
    let mut cmd = tokio::process::Command::new(git);
131
    cmd.arg("http-backend")
132
        .env_clear()
133
        .env("PATH", std::env::var("PATH").unwrap_or_default())
134
        .env("GIT_PROJECT_ROOT", &root)
135
        .env("GIT_HTTP_EXPORT_ALL", "1")
136
        .env("PATH_INFO", format!("/{owner}/{name_git}/{rest}"))
137
        .env("REQUEST_METHOD", method.as_str())
138
        .env("QUERY_STRING", &query)
139
        .env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or(""))
140
        .env("CONTENT_LENGTH", body.len().to_string())
141
        .env("GIT_CONFIG_NOSYSTEM", "1")
142
        .env("HOME", &root)
143
        .stdin(std::process::Stdio::piped())
144
        .stdout(std::process::Stdio::piped())
145
        .stderr(std::process::Stdio::piped());
146
    if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) {
147
        cmd.env("HTTP_CONTENT_ENCODING", enc);
148
    }
149
    if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) {
150
        cmd.env("GIT_PROTOCOL", p);
151
    }
152
    let mut child = match cmd.spawn() {
153
        Ok(c) => c,
154
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(),
155
    };
156
    if let Some(mut stdin) = child.stdin.take() {
157
        let _ = stdin.write_all(&body).await;
158
    }
159
    let out = match child.wait_with_output().await {
160
        Ok(o) => o,
161
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
162
    };
163
    cgi_response(&out.stdout)
164
}
165
166
/// Turn CGI output (headers, blank line, body) into a response.
167
fn cgi_response(raw: &[u8]) -> Response {
168
    let split =
169
        raw.windows(4).position(|w| w == b"\r\n\r\n").map(|i| (i, 4)).or_else(|| raw.windows(2).position(|w| w == b"\n\n").map(|i| (i, 2)));
170
    let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() };
171
    let head = String::from_utf8_lossy(&raw[..at]);
172
    let body = raw[at + sep..].to_vec();
173
    let mut status = StatusCode::OK;
174
    let mut builder = Response::builder();
175
    for line in head.lines() {
176
        let Some((k, v)) = line.split_once(':') else { continue };
177
        let (k, v) = (k.trim(), v.trim());
178
        if k.eq_ignore_ascii_case("status") {
179
            if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::<u16>().ok()) {
180
                status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);
181
            }
182
        } else {
183
            builder = builder.header(k, v);
184
        }
185
    }
186
    builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
187
}
188
189
#[cfg(test)]
190
mod tests {
191
    use super::*;
192
193
    #[test]
194
    fn base64_and_cgi() {
195
        assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret");
196
        let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope");
197
        assert_eq!(r.status(), StatusCode::NOT_FOUND);
198
    }
199
}