systemprompt_api/routes/gateway/
bridge_release.rs1use std::sync::Arc;
13
14use axum::Json;
15use axum::body::Body;
16use axum::extract::{Path, Query};
17use axum::http::{HeaderMap, StatusCode, header};
18use axum::response::{IntoResponse, Response};
19use serde::{Deserialize, Serialize};
20use systemprompt_config::ProfileBootstrap;
21use systemprompt_identifiers::JwtToken;
22use systemprompt_models::profile::BridgeReleasesSpec;
23
24use super::messages::extract_credential;
25use crate::services::middleware::JwtContextExtractor;
26
27const RELEASE_PAGE_SIZE: u8 = 30;
30
31#[derive(Debug, Deserialize)]
32pub struct LatestQuery {
33 pub platform: String,
34}
35
36#[derive(Debug, Serialize)]
41pub struct ReleaseManifest {
42 pub version: String,
43 pub sha256: String,
44 pub size: u64,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub notes_url: Option<String>,
47}
48
49#[derive(Debug, Deserialize)]
50struct GhRelease {
51 tag_name: String,
52 #[serde(default)]
53 html_url: Option<String>,
54 #[serde(default)]
55 draft: bool,
56 #[serde(default)]
57 prerelease: bool,
58 #[serde(default)]
59 assets: Vec<GhAsset>,
60}
61
62#[derive(Debug, Deserialize)]
63struct GhAsset {
64 name: String,
65 url: String,
66 #[serde(default)]
67 size: u64,
68}
69
70pub async fn latest(
71 jwt_extractor: Arc<JwtContextExtractor>,
72 headers: HeaderMap,
73 Query(query): Query<LatestQuery>,
74) -> Result<Json<ReleaseManifest>, (StatusCode, String)> {
75 authenticate(&jwt_extractor, &headers).await?;
76 let spec = releases_spec()?;
77
78 let asset_name = spec.assets.get(&query.platform).ok_or_else(|| {
79 (
80 StatusCode::NOT_FOUND,
81 format!("no published build for platform {}", query.platform),
82 )
83 })?;
84
85 let release = resolve_release(&spec).await?;
86 let asset = release
87 .assets
88 .iter()
89 .find(|a| a.name == *asset_name)
90 .ok_or_else(|| {
91 (
92 StatusCode::NOT_FOUND,
93 format!("release {} has no asset {asset_name}", release.tag_name),
94 )
95 })?;
96
97 let version = release
98 .tag_name
99 .strip_prefix(&spec.tag_prefix)
100 .unwrap_or(&release.tag_name)
101 .to_owned();
102 let sha256 = asset_digest(&spec, &release, asset_name).await?;
103
104 Ok(Json(ReleaseManifest {
105 version,
106 sha256,
107 size: asset.size,
108 notes_url: release.html_url.clone(),
109 }))
110}
111
112pub async fn download(
113 jwt_extractor: Arc<JwtContextExtractor>,
114 headers: HeaderMap,
115 Path(platform): Path<String>,
116) -> Result<Response, (StatusCode, String)> {
117 authenticate(&jwt_extractor, &headers).await?;
118 let spec = releases_spec()?;
119
120 let asset_name = spec.assets.get(&platform).ok_or_else(|| {
121 (
122 StatusCode::NOT_FOUND,
123 format!("no published build for platform {platform}"),
124 )
125 })?;
126
127 let release = resolve_release(&spec).await?;
128 let asset = release
129 .assets
130 .iter()
131 .find(|a| a.name == *asset_name)
132 .ok_or_else(|| {
133 (
134 StatusCode::NOT_FOUND,
135 format!("release {} has no asset {asset_name}", release.tag_name),
136 )
137 })?;
138
139 let upstream = github(&spec, &asset.url)
142 .header(header::ACCEPT, "application/octet-stream")
143 .send()
144 .await
145 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("asset fetch failed: {e}")))?;
146
147 if !upstream.status().is_success() {
148 return Err((
149 StatusCode::BAD_GATEWAY,
150 format!("asset fetch returned {}", upstream.status()),
151 ));
152 }
153
154 let body = Body::from_stream(upstream.bytes_stream());
157 Ok((
158 StatusCode::OK,
159 [
160 (header::CONTENT_TYPE, "application/octet-stream".to_owned()),
161 (
162 header::CONTENT_DISPOSITION,
163 format!("attachment; filename=\"{asset_name}\""),
164 ),
165 ],
166 body,
167 )
168 .into_response())
169}
170
171async fn authenticate(
172 jwt_extractor: &Arc<JwtContextExtractor>,
173 headers: &HeaderMap,
174) -> Result<(), (StatusCode, String)> {
175 let credential = extract_credential(headers).ok_or_else(|| {
176 (
177 StatusCode::UNAUTHORIZED,
178 "Missing Authorization or x-api-key credential".to_owned(),
179 )
180 })?;
181 jwt_extractor
182 .decode_for_gateway(&JwtToken::new(credential))
183 .await
184 .map_err(|e| (StatusCode::UNAUTHORIZED, e.to_string()))?;
185 Ok(())
186}
187
188fn releases_spec() -> Result<BridgeReleasesSpec, (StatusCode, String)> {
189 let profile = ProfileBootstrap::get().map_err(|e| {
190 (
191 StatusCode::SERVICE_UNAVAILABLE,
192 format!("Profile not ready: {e}"),
193 )
194 })?;
195 profile
196 .gateway
197 .as_ref()
198 .and_then(systemprompt_models::profile::GatewayState::resolved)
199 .and_then(|g| g.bridge_releases.clone())
200 .ok_or_else(|| {
201 (
202 StatusCode::NOT_FOUND,
203 "bridge releases are not configured on this gateway".to_owned(),
204 )
205 })
206}
207
208async fn resolve_release(spec: &BridgeReleasesSpec) -> Result<GhRelease, (StatusCode, String)> {
211 if let Some(pinned) = spec.pinned_version.as_deref() {
212 let tag = format!("{}{pinned}", spec.tag_prefix);
213 let url = format!(
214 "https://api.github.com/repos/{}/releases/tags/{tag}",
215 spec.repo
216 );
217 return fetch_json::<GhRelease>(spec, &url).await;
218 }
219
220 let url = format!(
221 "https://api.github.com/repos/{}/releases?per_page={RELEASE_PAGE_SIZE}",
222 spec.repo
223 );
224 let releases = fetch_json::<Vec<GhRelease>>(spec, &url).await?;
225 releases
226 .into_iter()
227 .find(|r| !r.draft && !r.prerelease && r.tag_name.starts_with(&spec.tag_prefix))
228 .ok_or_else(|| {
229 (
230 StatusCode::NOT_FOUND,
231 format!("no {}* release found in {}", spec.tag_prefix, spec.repo),
232 )
233 })
234}
235
236async fn asset_digest(
239 spec: &BridgeReleasesSpec,
240 release: &GhRelease,
241 asset_name: &str,
242) -> Result<String, (StatusCode, String)> {
243 let sums = release
244 .assets
245 .iter()
246 .find(|a| a.name == "SHA256SUMS")
247 .ok_or_else(|| {
248 (
249 StatusCode::BAD_GATEWAY,
250 format!("release {} publishes no SHA256SUMS", release.tag_name),
251 )
252 })?;
253
254 let body = github(spec, &sums.url)
255 .header(header::ACCEPT, "application/octet-stream")
256 .send()
257 .await
258 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("SHA256SUMS fetch: {e}")))?
259 .text()
260 .await
261 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("SHA256SUMS read: {e}")))?;
262
263 parse_sha256sums(&body, asset_name).ok_or_else(|| {
264 (
265 StatusCode::BAD_GATEWAY,
266 format!("SHA256SUMS has no entry for {asset_name}"),
267 )
268 })
269}
270
271pub fn parse_sha256sums(body: &str, asset_name: &str) -> Option<String> {
274 body.lines().find_map(|line| {
275 let (digest, name) = line.split_once(char::is_whitespace)?;
276 let name = name.trim_start_matches([' ', '*']);
277 (name == asset_name && digest.len() == 64).then(|| digest.to_ascii_lowercase())
278 })
279}
280
281async fn fetch_json<T: serde::de::DeserializeOwned>(
282 spec: &BridgeReleasesSpec,
283 url: &str,
284) -> Result<T, (StatusCode, String)> {
285 let resp = github(spec, url)
286 .send()
287 .await
288 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("github request: {e}")))?;
289 if !resp.status().is_success() {
290 return Err((
291 StatusCode::BAD_GATEWAY,
292 format!("github returned {} for {url}", resp.status()),
293 ));
294 }
295 resp.json::<T>()
296 .await
297 .map_err(|e| (StatusCode::BAD_GATEWAY, format!("github decode: {e}")))
298}
299
300fn github(spec: &BridgeReleasesSpec, url: &str) -> reqwest::RequestBuilder {
301 let mut req = reqwest::Client::new()
302 .get(url)
303 .header(header::USER_AGENT, "systemprompt-gateway")
305 .header("X-GitHub-Api-Version", "2022-11-28");
306 if let Some(token) = spec
307 .token_env
308 .as_deref()
309 .and_then(|k| std::env::var(k).ok())
310 {
311 req = req.bearer_auth(token);
312 }
313 req
314}