OXYGENHUB
# 2cbf6f348595 oxygenhub / crates / hub / src / mirror.rs
8.2 KiB · Rust · last changed Write the forge's name as OxygenHub in everything people read 1 month 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) {
23
    if !row.git_mirror {
24
        return;
25
    }
26
    let Some(repo) = RepoName::new(&row.owner, &row.name) else { return };
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
        }
45
        Err(e) => tracing::warn!("git mirror of {}: {e}", row.slug()),
46
    }
47
}
48
49
/// Who a Git client is: HTTP Basic with a personal access token as the
50
/// password (any user name), or anonymous.
51
async fn git_caller(app: &Shared, headers: &HeaderMap) -> Result<Option<db::accounts::Account>, StatusCode> {
52
    let Some(v) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()) else { return Ok(None) };
53
    let token = if let Some(b) = v.strip_prefix("Basic ") {
54
        let decoded = base64_decode(b.trim()).ok_or(StatusCode::UNAUTHORIZED)?;
55
        let s = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?;
56
        s.split_once(':').map(|(_, p)| p.to_string()).ok_or(StatusCode::UNAUTHORIZED)?
57
    } else if let Some(t) = v.strip_prefix("Bearer ") {
58
        t.trim().to_string()
59
    } else {
60
        return Err(StatusCode::UNAUTHORIZED);
61
    };
62
    let tok = db::accounts::token_by_hash(&app.db, &auth::token_hash(&token)).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
63
    match tok {
64
        Some(t) if !t.expired(db::now_ms()) && (t.has_scope("repo:read") || t.has_scope("repo:write") || t.has_scope("repo:admin")) => {
65
            db::accounts::account_by_id(&app.db, t.account_id).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
66
        }
67
        _ => Err(StatusCode::UNAUTHORIZED),
68
    }
69
}
70
71
fn base64_decode(s: &str) -> Option<Vec<u8>> {
72
    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
73
    let mut out = Vec::new();
74
    let mut buf = 0u32;
75
    let mut bits = 0;
76
    for c in s.bytes().filter(|c| *c != b'=') {
77
        let v = T.iter().position(|x| *x == c)? as u32;
78
        buf = (buf << 6) | v;
79
        bits += 6;
80
        if bits >= 8 {
81
            bits -= 8;
82
            out.push((buf >> bits) as u8);
83
            buf &= (1 << bits) - 1;
84
        }
85
    }
86
    Some(out)
87
}
88
89
fn challenge() -> Response {
90
    (StatusCode::UNAUTHORIZED, [(header::WWW_AUTHENTICATE, "Basic realm=\"OxygenHub\"")], "authentication required").into_response()
91
}
92
93
/// Every smart-HTTP path under `/{owner}/{name}.git/`.
94
pub async fn serve(
95
    State(app): State<Shared>,
96
    Path((owner, name_git, rest)): Path<(String, String, String)>,
97
    method: Method,
98
    RawQuery(query): RawQuery,
99
    headers: HeaderMap,
100
    body: Bytes,
101
) -> Response {
102
    let Some(name) = name_git.strip_suffix(".git") else { return StatusCode::NOT_FOUND.into_response() };
103
    let Some(repo) = RepoName::new(&owner, name) else { return StatusCode::NOT_FOUND.into_response() };
104
    let query = query.unwrap_or_default();
105
    if rest.contains("git-receive-pack") || query.contains("git-receive-pack") {
106
        return (StatusCode::FORBIDDEN, "This is a read-only Git mirror of an Oxygen repository; push with oxy instead.").into_response();
107
    }
108
    let row = match db::repos::get(&app.db, &owner, name).await {
109
        Ok(Some(r)) if r.git_mirror => r,
110
        _ => return StatusCode::NOT_FOUND.into_response(),
111
    };
112
    let account = match git_caller(&app, &headers).await {
113
        Ok(a) => a,
114
        Err(_) => return challenge(),
115
    };
116
    let role = auth::roles::role_for(&app.db, account.as_ref(), &row).await.ok().flatten();
117
    if !auth::roles::allows(role, Access::Read) {
118
        return if account.is_none() { challenge() } else { StatusCode::NOT_FOUND.into_response() };
119
    }
120
    let dir = mirror_dir(&app, &repo);
121
    if !dir.join("HEAD").exists() {
122
        refresh(&app, &row).await;
123
    }
124
    let root = app.config.data.join("mirrors");
125
    let git = std::env::var("OXYHUB_GIT").unwrap_or_else(|_| "git".into());
126
    let mut cmd = tokio::process::Command::new(git);
127
    cmd.arg("http-backend")
128
        .env_clear()
129
        .env("PATH", std::env::var("PATH").unwrap_or_default())
130
        .env("GIT_PROJECT_ROOT", &root)
131
        .env("GIT_HTTP_EXPORT_ALL", "1")
132
        .env("PATH_INFO", format!("/{owner}/{name_git}/{rest}"))
133
        .env("REQUEST_METHOD", method.as_str())
134
        .env("QUERY_STRING", &query)
135
        .env("CONTENT_TYPE", headers.get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or(""))
136
        .env("CONTENT_LENGTH", body.len().to_string())
137
        .env("GIT_CONFIG_NOSYSTEM", "1")
138
        .env("HOME", &root)
139
        .stdin(std::process::Stdio::piped())
140
        .stdout(std::process::Stdio::piped())
141
        .stderr(std::process::Stdio::piped());
142
    if let Some(enc) = headers.get(header::CONTENT_ENCODING).and_then(|v| v.to_str().ok()) {
143
        cmd.env("HTTP_CONTENT_ENCODING", enc);
144
    }
145
    if let Some(p) = headers.get("git-protocol").and_then(|v| v.to_str().ok()) {
146
        cmd.env("GIT_PROTOCOL", p);
147
    }
148
    let mut child = match cmd.spawn() {
149
        Ok(c) => c,
150
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("git http-backend: {e}")).into_response(),
151
    };
152
    if let Some(mut stdin) = child.stdin.take() {
153
        let _ = stdin.write_all(&body).await;
154
    }
155
    let out = match child.wait_with_output().await {
156
        Ok(o) => o,
157
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
158
    };
159
    cgi_response(&out.stdout)
160
}
161
162
/// Turn CGI output (headers, blank line, body) into a response.
163
fn cgi_response(raw: &[u8]) -> Response {
164
    let split =
165
        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)));
166
    let Some((at, sep)) = split else { return (StatusCode::BAD_GATEWAY, "malformed CGI output").into_response() };
167
    let head = String::from_utf8_lossy(&raw[..at]);
168
    let body = raw[at + sep..].to_vec();
169
    let mut status = StatusCode::OK;
170
    let mut builder = Response::builder();
171
    for line in head.lines() {
172
        let Some((k, v)) = line.split_once(':') else { continue };
173
        let (k, v) = (k.trim(), v.trim());
174
        if k.eq_ignore_ascii_case("status") {
175
            if let Some(code) = v.split_whitespace().next().and_then(|c| c.parse::<u16>().ok()) {
176
                status = StatusCode::from_u16(code).unwrap_or(StatusCode::OK);
177
            }
178
        } else {
179
            builder = builder.header(k, v);
180
        }
181
    }
182
    builder.status(status).body(axum::body::Body::from(body)).unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response())
183
}
184
185
#[cfg(test)]
186
mod tests {
187
    use super::*;
188
189
    #[test]
190
    fn base64_and_cgi() {
191
        assert_eq!(base64_decode("dXNlcjpzM2NyZXQ=").unwrap(), b"user:s3cret");
192
        let r = cgi_response(b"Status: 404 Not Found\r\nContent-Type: text/plain\r\n\r\nnope");
193
        assert_eq!(r.status(), StatusCode::NOT_FOUND);
194
    }
195
}