char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somsimplify + split www daemon3665650

main
6.6 KiB197 linesraw
1use axum::body::{Body, to_bytes};
2use axum::extract::{Path as AxumPath, RawQuery, State};
3use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
4use axum::response::{IntoResponse, Response};
5
6use crate::githttp;
7use crate::render::encode_path;
8
9use super::{AppState, WebResult, internal, x_accel};
10
11/// Read-only static access to an allowlisted subset of the bare repository,
12/// for the in-browser git client (and dumb-protocol clones). Objects are
13/// content-addressed and immutable; refs are never cached.
14pub(super) async fn git_dir(
15    State(state): State<AppState>,
16    AxumPath((user, repo, path)): AxumPath<(String, String, String)>,
17) -> WebResult<Response> {
18    if !allowed_git_path(&path) {
19        return Err((StatusCode::NOT_FOUND, "not served".into()));
20    }
21    let entry = state.resolve(&user, &repo)?;
22    let repo_dir = entry
23        .repository
24        .path
25        .file_name()
26        .ok_or_else(|| internal("repository path has no file name"))?
27        .to_string_lossy();
28    let mut response = x_accel(
29        &state.config.git_internal_prefix,
30        &format!(
31            "{}/{}/{}",
32            encode_path(&user),
33            encode_path(&repo_dir),
34            encode_path(&path)
35        ),
36    )?;
37    let cache_control = if path.starts_with("objects/") && path != "objects/info/packs" {
38        "public, max-age=31536000, immutable"
39    } else {
40        "no-cache"
41    };
42    response.headers_mut().insert(
43        header::CACHE_CONTROL,
44        HeaderValue::from_static(cache_control),
45    );
46    Ok(response)
47}
48
49/// `HEAD`, refs, and the object store; never `config` (may hold credentials
50/// for mirrors) or `hooks/`.
51fn allowed_git_path(path: &str) -> bool {
52    let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|byte| byte.is_ascii_hexdigit());
53    match path {
54        "HEAD" | "packed-refs" | "info/refs" | "objects/info/packs" => true,
55        path if path.starts_with("refs/") => path
56            .split('/')
57            .all(|component| !component.is_empty() && component != "." && component != ".."),
58        path => match path.strip_prefix("objects/") {
59            Some(rest) => match rest.strip_prefix("pack/") {
60                Some(pack) => {
61                    let stem = pack
62                        .strip_suffix(".pack")
63                        .or_else(|| pack.strip_suffix(".idx"))
64                        .and_then(|stem| stem.strip_prefix("pack-"));
65                    stem.is_some_and(is_hex)
66                }
67                None => matches!(
68                    rest.split_once('/'),
69                    Some((fan, name)) if fan.len() == 2 && is_hex(fan) && is_hex(name)
70                ),
71            },
72            None => false,
73        },
74    }
75}
76
77pub(super) async fn info_refs(
78    State(state): State<AppState>,
79    AxumPath((user, repo)): AxumPath<(String, String)>,
80    RawQuery(query): RawQuery,
81    headers: HeaderMap,
82) -> WebResult<Response> {
83    if query.as_deref() != Some("service=git-upload-pack") {
84        return Err((
85            StatusCode::FORBIDDEN,
86            "only git-upload-pack is supported".into(),
87        ));
88    }
89    let entry = state.resolve(&user, &repo)?;
90    githttp::advertise(&entry.repository.path, &headers, state.git_permit()?)
91}
92
93pub(super) async fn upload_pack(
94    State(state): State<AppState>,
95    AxumPath((user, repo)): AxumPath<(String, String)>,
96    request: Request<Body>,
97) -> WebResult<Response> {
98    let entry = state.resolve(&user, &repo)?;
99    let permit = state.git_permit()?;
100    let (parts, body) = request.into_parts();
101    let body = to_bytes(body, 64 << 20).await.map_err(|_| {
102        (
103            StatusCode::PAYLOAD_TOO_LARGE,
104            "git request body is too large".into(),
105        )
106    })?;
107    githttp::upload(&entry.repository.path, &parts.headers, body, permit)
108}
109
110pub(super) async fn raw_blob(
111    State(state): State<AppState>,
112    AxumPath((user, repo, oid, path)): AxumPath<(String, String, String, String)>,
113) -> WebResult<Response> {
114    let oid = gix::ObjectId::from_hex(oid.as_bytes())
115        .map_err(|_| (StatusCode::NOT_FOUND, "blob not found".into()))?;
116    let repo_path = state.resolve(&user, &repo)?.repository.path;
117    let permit = state.git_permit()?;
118    let data = tokio::task::spawn_blocking(move || -> anyhow::Result<Option<Vec<u8>>> {
119        let _permit = permit;
120        let repo = gix::open(repo_path)?;
121        let Ok(object) = repo.find_object(oid) else {
122            return Ok(None);
123        };
124        Ok((object.kind == gix::object::Kind::Blob).then(|| object.data.to_vec()))
125    })
126    .await
127    .map_err(internal)?
128    .map_err(internal)?
129    .ok_or_else(|| (StatusCode::NOT_FOUND, "blob not found".into()))?;
130
131    let content_type = raw_content_type(&path, &data);
132    let mut response = Body::from(data).into_response();
133    response.headers_mut().insert(
134        header::CONTENT_TYPE,
135        HeaderValue::from_str(&content_type).map_err(internal)?,
136    );
137    response.headers_mut().insert(
138        header::CONTENT_DISPOSITION,
139        HeaderValue::from_static("inline"),
140    );
141    response.headers_mut().insert(
142        "content-security-policy",
143        HeaderValue::from_static(
144            "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'",
145        ),
146    );
147    response.headers_mut().insert(
148        "x-content-type-options",
149        HeaderValue::from_static("nosniff"),
150    );
151    response.headers_mut().insert(
152        header::CACHE_CONTROL,
153        HeaderValue::from_static("public, max-age=31536000, immutable"),
154    );
155    Ok(response)
156}
157
158fn raw_content_type(path: &str, data: &[u8]) -> String {
159    if std::str::from_utf8(data).is_ok() {
160        return "text/plain; charset=utf-8".into();
161    }
162    mime_guess::from_path(path)
163        .first()
164        .filter(|mime| {
165            let essence = mime.essence_str();
166            essence.starts_with("audio/")
167                || essence.starts_with("video/")
168                || essence.starts_with("image/") && essence != "image/svg+xml"
169        })
170        .map(|mime| mime.to_string())
171        .unwrap_or_else(|| "application/octet-stream".into())
172}
173
174#[cfg(test)]
175mod tests {
176    use super::raw_content_type;
177
178    #[test]
179    fn raw_blobs_never_render_active_content() {
180        assert_eq!(
181            raw_content_type("page.html", b"<script>alert(1)</script>"),
182            "text/plain; charset=utf-8"
183        );
184        assert_eq!(
185            raw_content_type("image.svg", b"<svg><script/></svg>"),
186            "text/plain; charset=utf-8"
187        );
188        assert_eq!(
189            raw_content_type("page.html", b"\xff\xfe<html>"),
190            "application/octet-stream"
191        );
192        assert_eq!(
193            raw_content_type("image.png", b"\x89PNG\r\n\x1a\n"),
194            "image/png"
195        );
196    }
197}