Skip to content

Commit bbfd183

Browse files
committed
feat(api): add update endpoint and logic
1 parent c953e05 commit bbfd183

4 files changed

Lines changed: 260 additions & 0 deletions

File tree

src/api/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::sync::Arc;
55

66
pub mod metadata;
77
pub mod telemetry;
8+
pub mod update;
89
pub mod validation;
910

1011
pub fn app_router(
@@ -14,6 +15,7 @@ pub fn app_router(
1415
) -> Router {
1516
let mut router = Router::new()
1617
.nest("/telemetry", telemetry::router().with_state(pool))
18+
.nest("/update", update::router())
1719
.route("/", any(|_: Request<Body>| async { "Healthy" }));
1820

1921
if let Some(pool) = scrape_pool {

src/api/update/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
use axum::Router;
2+
3+
pub mod v1;
4+
5+
pub fn router() -> Router {
6+
Router::new().nest("/v1", v1::router())
7+
}

src/api/update/v1/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
pub mod update;
2+
3+
use axum::Router;
4+
5+
pub fn router() -> Router {
6+
update::router()
7+
}

src/api/update/v1/update.rs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
2+
use reqwest::Client;
3+
use serde_json::{Map, Value, json};
4+
use std::sync::Arc;
5+
use std::time::{Duration, Instant};
6+
use tokio::sync::RwLock;
7+
use tokio::time::timeout;
8+
9+
const REPO: &str = "vleerapp/vleer";
10+
const S3_BASE: &str = "https://vleer-releases.objects.eplg.cloud";
11+
const PLATFORMS: [(&str, &str); 4] = [
12+
("macos", "aarch64.dmg"),
13+
("windows", "x86_64.msi"),
14+
("linux", "x86_64.AppImage"),
15+
("linux-aarch64", "aarch64.AppImage"),
16+
];
17+
const HEAD_TIMEOUT: Duration = Duration::from_secs(5);
18+
const GITHUB_TTL: Duration = Duration::from_secs(60);
19+
20+
type CacheEntry = (String, Value, Map<String, Value>);
21+
22+
struct CacheData {
23+
entry: Option<CacheEntry>,
24+
last_checked: Option<Instant>,
25+
}
26+
27+
#[derive(Clone)]
28+
struct UpdateState {
29+
client: Client,
30+
cache: Arc<RwLock<CacheData>>,
31+
}
32+
33+
pub fn router() -> Router {
34+
let state = UpdateState {
35+
client: Client::new(),
36+
cache: Arc::new(RwLock::new(CacheData {
37+
entry: None,
38+
last_checked: None,
39+
})),
40+
};
41+
Router::new()
42+
.route("/check", get(update_handler))
43+
.with_state(state)
44+
}
45+
46+
fn error_response(status: StatusCode, message: &str) -> (StatusCode, Json<Value>) {
47+
(
48+
status,
49+
Json(json!({ "error": { "status": status.as_u16(), "message": message } })),
50+
)
51+
}
52+
53+
async fn fetch_platforms(client: Client, version: &str) -> Map<String, Value> {
54+
let handles: Vec<_> = PLATFORMS
55+
.iter()
56+
.map(|(key, suffix)| {
57+
let client = client.clone();
58+
let url = format!("{S3_BASE}/Vleer-{version}-{suffix}");
59+
let key = key.to_string();
60+
tokio::spawn(async move {
61+
let result = timeout(HEAD_TIMEOUT, client.head(&url).send()).await;
62+
(key, url, result)
63+
})
64+
})
65+
.collect();
66+
67+
let mut platforms = Map::new();
68+
for handle in handles {
69+
let Ok((key, url, result)) = handle.await else {
70+
continue;
71+
};
72+
let resp = match result {
73+
Ok(Ok(r)) => r,
74+
Ok(Err(e)) => {
75+
tracing::warn!("update: head request failed for {}: {}", url, e);
76+
continue;
77+
}
78+
Err(_) => {
79+
tracing::warn!("update: head request timed out for {}", url);
80+
continue;
81+
}
82+
};
83+
if !resp.status().is_success() {
84+
continue;
85+
}
86+
let size = resp
87+
.headers()
88+
.get(reqwest::header::CONTENT_LENGTH)
89+
.and_then(|v| v.to_str().ok())
90+
.and_then(|v| v.parse::<u64>().ok());
91+
let etag = resp
92+
.headers()
93+
.get(reqwest::header::ETAG)
94+
.and_then(|v| v.to_str().ok())
95+
.map(|s| s.to_string());
96+
platforms.insert(key, json!({ "url": url, "size": size, "etag": etag }));
97+
}
98+
platforms
99+
}
100+
101+
async fn update_handler(State(state): State<UpdateState>) -> impl IntoResponse {
102+
let client = state.client.clone();
103+
104+
{
105+
let cache = state.cache.read().await;
106+
if cache
107+
.last_checked
108+
.is_some_and(|t| t.elapsed() < GITHUB_TTL)
109+
{
110+
return match &cache.entry {
111+
Some((_, release, platforms)) => serve_response(release, platforms),
112+
None => error_response(StatusCode::NOT_FOUND, "No releases found")
113+
.into_response(),
114+
};
115+
}
116+
}
117+
118+
let etag = state
119+
.cache
120+
.read()
121+
.await
122+
.entry
123+
.as_ref()
124+
.map(|(e, _, _)| e.clone());
125+
126+
let mut req = client
127+
.get(format!(
128+
"https://api.github.com/repos/{REPO}/releases/latest"
129+
))
130+
.header("User-Agent", "vleer-api")
131+
.header("Accept", "application/vnd.github+json");
132+
133+
if let Some(ref e) = etag {
134+
req = req.header("If-None-Match", e);
135+
}
136+
137+
match req.send().await {
138+
Ok(resp) if resp.status() == reqwest::StatusCode::NOT_MODIFIED => {
139+
let mut cache = state.cache.write().await;
140+
cache.last_checked = Some(Instant::now());
141+
let (_, release, platforms) = cache.entry.as_ref().unwrap();
142+
serve_response(release, platforms)
143+
}
144+
Ok(resp) if resp.status().is_success() => {
145+
let new_etag = resp
146+
.headers()
147+
.get(reqwest::header::ETAG)
148+
.and_then(|v| v.to_str().ok())
149+
.map(|s| s.to_string());
150+
151+
let release: Value = match resp.json().await {
152+
Ok(v) => v,
153+
Err(e) => {
154+
tracing::error!("update: failed to parse release: {}", e);
155+
state.cache.write().await.last_checked = Some(Instant::now());
156+
return error_response(
157+
StatusCode::BAD_GATEWAY,
158+
"Failed to parse release metadata",
159+
)
160+
.into_response();
161+
}
162+
};
163+
164+
let tag = release.get("tag_name").and_then(Value::as_str).unwrap_or("");
165+
let version = tag.strip_prefix('v').unwrap_or(tag);
166+
if version.is_empty() {
167+
state.cache.write().await.last_checked = Some(Instant::now());
168+
return error_response(StatusCode::BAD_GATEWAY, "Release missing version")
169+
.into_response();
170+
}
171+
172+
let platforms = fetch_platforms(client.clone(), version).await;
173+
174+
let mut cache = state.cache.write().await;
175+
cache.last_checked = Some(Instant::now());
176+
if let Some(etag) = new_etag {
177+
cache.entry = Some((etag, release.clone(), platforms.clone()));
178+
}
179+
180+
serve_response(&release, &platforms)
181+
}
182+
Ok(resp) if resp.status() == reqwest::StatusCode::NOT_FOUND => {
183+
state.cache.write().await.last_checked = Some(Instant::now());
184+
error_response(StatusCode::NOT_FOUND, "No releases found").into_response()
185+
}
186+
Ok(resp) => {
187+
tracing::error!("update: github returned {}", resp.status());
188+
let mut cache = state.cache.write().await;
189+
cache.last_checked = Some(Instant::now());
190+
match &cache.entry {
191+
Some((_, release, platforms)) => serve_response(release, platforms),
192+
None => error_response(StatusCode::BAD_GATEWAY, "Failed to fetch release")
193+
.into_response(),
194+
}
195+
}
196+
Err(e) => {
197+
tracing::error!("update: github request failed: {}", e);
198+
let mut cache = state.cache.write().await;
199+
cache.last_checked = Some(Instant::now());
200+
match &cache.entry {
201+
Some((_, release, platforms)) => serve_response(release, platforms),
202+
None => error_response(StatusCode::BAD_GATEWAY, "Failed to fetch release")
203+
.into_response(),
204+
}
205+
}
206+
}
207+
}
208+
209+
fn serve_response(release: &Value, platforms: &Map<String, Value>) -> axum::response::Response {
210+
let tag = release
211+
.get("tag_name")
212+
.and_then(Value::as_str)
213+
.unwrap_or("");
214+
let version = tag.strip_prefix('v').unwrap_or(tag);
215+
216+
let pub_date = release
217+
.get("published_at")
218+
.and_then(Value::as_str)
219+
.and_then(|s| s.split('T').next())
220+
.unwrap_or("")
221+
.to_string();
222+
223+
let notes_url = release
224+
.get("html_url")
225+
.and_then(Value::as_str)
226+
.unwrap_or("")
227+
.to_string();
228+
229+
if platforms.is_empty() {
230+
return error_response(StatusCode::NOT_FOUND, "No release assets available")
231+
.into_response();
232+
}
233+
234+
(
235+
StatusCode::OK,
236+
Json(json!({
237+
"version": version,
238+
"pub_date": pub_date,
239+
"notes_url": notes_url,
240+
"platforms": platforms,
241+
})),
242+
)
243+
.into_response()
244+
}

0 commit comments

Comments
 (0)