anvilsign in

collin/anvil

main / crates / anvil-web / src / artifacts.rs
1//! Serving CI artifacts (see `docs/ci-artifacts.md`).
2//!
3//! Three routes:
4//! - `/{owner}/{repo}/ci/{run}/artifacts/{name}` — run-scoped download.
5//! - `/{owner}/{repo}/artifacts/{rev}/{name}` — alias resolving `rev` (branch,
6//! tag, or commit) to that commit's newest artifact; redirects to the
7//! canonical location. With CI running on every push tip, a branch name
8//! here is "latest on branch".
9//! - `/{owner}/{repo}/artifacts/{rev}/{name}/{*path}` — browsable artifacts
10//! (`browse: true`): files served like a pages site, with `index.html`
11//! resolution. Same forge-origin caveat as pages (`docs/untrusted-mode.md`
12//! §2).
13//!
14//! Access follows repository visibility, like CI logs and pages.
15
16use std::path::{
17 Component,
18 PathBuf,
19};
20
21use anvil_core::{
22 App,
23 CiArtifact,
24 ci,
25 storage,
26};
27use anvil_git::browse;
28use axum::{
29 Router,
30 extract::{
31 Path,
32 State,
33 },
34 http::header,
35 response::{
36 IntoResponse,
37 Redirect,
38 Response,
39 },
40 routing::get,
41};
42
43use crate::{
44 auth::CurrentUser,
45 pages::file_response,
46 ui::{
47 not_found,
48 resolve_repo,
49 server_error,
50 },
51};
52
53/// Mount the artifact routes.
54pub fn routes(router: Router<App>) -> Router<App> {
55 router
56 .route(
57 "/{owner}/{repo}/ci/{run}/artifacts/{name}",
58 get(download_for_run),
59 )
60 .route("/{owner}/{repo}/artifacts/{rev}/{name}", get(alias))
61 .route(
62 "/{owner}/{repo}/artifacts/{rev}/{name}/{*path}",
63 get(browse_serve),
64 )
65}
66
67/// The on-disk location of a stored artifact (file or tarball form).
68fn stored_path(app: &App, a: &CiArtifact) -> PathBuf {
69 let dir = storage::artifact_commit_dir(&app.config.artifacts_dir(), a.repo_id, &a.commit);
70 if a.is_dir && !a.browse {
71 dir.join(format!("{}.tar.gz", a.name))
72 } else {
73 dir.join(&a.name)
74 }
75}
76
77/// `GET /{owner}/{repo}/ci/{run}/artifacts/{name}` — download one artifact of
78/// one run. Browsable artifacts redirect to their site root instead.
79async fn download_for_run(
80 State(app): State<App>,
81 CurrentUser(user): CurrentUser,
82 Path((owner, repo, run_id, name)): Path<(String, String, i64, String)>,
83) -> Response {
84 let (_, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
85 Ok(v) => v,
86 Err(resp) => return resp,
87 };
88 let artifacts = match ci::artifacts_for_run(&app.db, run_id).await {
89 Ok(a) => a,
90 Err(e) => return server_error(e),
91 };
92 let Some(artifact) = artifacts
93 .into_iter()
94 .find(|a| a.name == name && a.repo_id == meta.id)
95 else {
96 return not_found("no such artifact");
97 };
98 if artifact.browse {
99 return Redirect::to(&format!(
100 "/{owner}/{repo}/artifacts/{}/{}/index.html",
101 artifact.commit, artifact.name
102 ))
103 .into_response();
104 }
105 serve_download(&app, &artifact)
106}
107
108/// `GET /{owner}/{repo}/artifacts/{rev}/{name}` — resolve `rev` and serve the
109/// newest artifact for that commit (downloads directly; browsable artifacts
110/// redirect to their site root under the same rev, keeping the URL stable).
111async fn alias(
112 State(app): State<App>,
113 CurrentUser(user): CurrentUser,
114 Path((owner, repo, rev, name)): Path<(String, String, String, String)>,
115) -> Response {
116 let (repo_path, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
117 Ok(v) => v,
118 Err(resp) => return resp,
119 };
120 let artifact = match lookup(&app, &repo_path, meta.id, &rev, &name).await {
121 Ok(a) => a,
122 Err(resp) => return resp,
123 };
124 if artifact.browse {
125 return Redirect::to(&format!(
126 "/{owner}/{repo}/artifacts/{}/{}/index.html",
127 crate::ui::enc_ref(&rev),
128 artifact.name
129 ))
130 .into_response();
131 }
132 serve_download(&app, &artifact)
133}
134
135/// `GET /{owner}/{repo}/artifacts/{rev}/{name}/{*path}` — serve a file from a
136/// browsable artifact's extracted tree. Directory paths resolve to their
137/// `index.html`, redirecting to the trailing-slash form first so the site's
138/// relative links work.
139async fn browse_serve(
140 State(app): State<App>,
141 CurrentUser(user): CurrentUser,
142 Path((owner, repo, rev, name, path)): Path<(String, String, String, String, String)>,
143) -> Response {
144 let (repo_path, meta) = match resolve_repo(&app, user.as_ref(), &owner, &repo).await {
145 Ok(v) => v,
146 Err(resp) => return resp,
147 };
148 let artifact = match lookup(&app, &repo_path, meta.id, &rev, &name).await {
149 Ok(a) => a,
150 Err(resp) => return resp,
151 };
152 if !artifact.browse {
153 return not_found("not a browsable artifact");
154 }
155
156 let root = stored_path(&app, &artifact);
157 let trimmed = path.trim_end_matches('/');
158 let Some(rel) = sanitize(trimmed) else {
159 return not_found("no such file");
160 };
161
162 let file = root.join(&rel);
163 if file.is_file() {
164 return match std::fs::read(&file) {
165 Ok(bytes) => file_response(trimmed, bytes),
166 Err(e) => server_error(e),
167 };
168 }
169 // A directory (or the artifact root, when `path` was only slashes):
170 // resolve its index.html behind a trailing-slash redirect.
171 let index = file.join("index.html");
172 if index.is_file() {
173 if !path.ends_with('/') {
174 return Redirect::to(&format!(
175 "/{owner}/{repo}/artifacts/{}/{name}/{trimmed}/",
176 crate::ui::enc_ref(&rev)
177 ))
178 .into_response();
179 }
180 return match std::fs::read(&index) {
181 Ok(bytes) => file_response("index.html", bytes),
182 Err(e) => server_error(e),
183 };
184 }
185 not_found("no such file")
186}
187
188/// Resolve `rev` to a commit and find that commit's newest artifact `name`.
189async fn lookup(
190 app: &App,
191 repo_path: &std::path::Path,
192 repo_id: i64,
193 rev: &str,
194 name: &str,
195) -> Result<CiArtifact, Response> {
196 let commit = browse::resolve_commit(repo_path, rev)
197 .map_err(|_| not_found("no such branch, tag, or commit"))?;
198 ci::latest_artifact(&app.db, repo_id, &commit, name)
199 .await
200 .map_err(server_error)?
201 .ok_or_else(|| not_found("no artifact with that name for this commit"))
202}
203
204/// Attachment response for a stored file/tarball artifact.
205fn serve_download(app: &App, artifact: &CiArtifact) -> Response {
206 let path = stored_path(app, artifact);
207 let bytes = match std::fs::read(&path) {
208 Ok(b) => b,
209 Err(_) => return not_found("artifact data missing on disk"),
210 };
211 let filename = path
212 .file_name()
213 .map(|n| n.to_string_lossy().into_owned())
214 .unwrap_or_else(|| artifact.name.clone());
215 (
216 [
217 (header::CONTENT_TYPE, "application/octet-stream".to_string()),
218 (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
219 (
220 header::CONTENT_DISPOSITION,
221 format!("attachment; filename=\"{filename}\""),
222 ),
223 ],
224 bytes,
225 )
226 .into_response()
227}
228
229/// Normalize a request path to a safe relative path (no `..`, no absolutes,
230/// no empty/`.` segments). `None` rejects the request.
231fn sanitize(path: &str) -> Option<PathBuf> {
232 let mut out = PathBuf::new();
233 for c in std::path::Path::new(path).components() {
234 match c {
235 Component::Normal(p) => out.push(p),
236 Component::CurDir => {}
237 _ => return None,
238 }
239 }
240 Some(out)
241}