mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-07-19 14:28:11 -07:00
* add `auto_check_updates` config value * add `auto_check_updates` to dist config * add `Update` `NotificationType` * implement update checker and worker * add endpoint, add to documentation, add worker * clone update_status_lock Arc * fmt * add more tests * remove todo * add to docs * frontend update notice * improve name in documentation * add user-agent to update check request * add update check request timeout * openapi trait bound * do not enable `auto_check_updates` by default * remove redundant documentation * surface fetch of update status error * fail on version with pre-release for now, add additional test cases * Update configuration.md --------- Co-authored-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
use chrono::{DateTime, Local};
|
||||
use log::{error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tokio::select;
|
||||
use tokio::sync::{RwLock, mpsc::Sender};
|
||||
use tokio::time;
|
||||
use tokio::time::{Duration, MissedTickBehavior};
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
|
||||
use crate::notifications::{Notification, NotificationType};
|
||||
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60);
|
||||
const GITHUB_LATEST_RELEASE_URL: &str =
|
||||
"https://api.github.com/repos/EFForg/rayhunter/releases/latest";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[cfg_attr(feature = "apidocs", derive(utoipa::ToSchema))]
|
||||
pub struct UpdateStatus {
|
||||
pub current_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub latest_release_url: Option<String>,
|
||||
pub update_available: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "apidocs", schema(value_type = Option<String>, format = "date-time"))]
|
||||
pub last_checked: Option<DateTime<Local>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for UpdateStatus {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_version: get_current_version(),
|
||||
// To-be-populated by update check worker
|
||||
latest_version: None,
|
||||
latest_release_url: None,
|
||||
update_available: false,
|
||||
last_checked: None,
|
||||
last_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubReleaseResponse {
|
||||
tag_name: String,
|
||||
html_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct VersionParts {
|
||||
major: u64,
|
||||
minor: u64,
|
||||
patch: u64,
|
||||
}
|
||||
|
||||
fn get_current_version() -> String {
|
||||
// See https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
|
||||
env!("CARGO_PKG_VERSION").to_owned()
|
||||
}
|
||||
|
||||
fn parse_release_tagname(version: &str) -> Option<(VersionParts, String)> {
|
||||
// Trim whitespace and leading `v`, if any
|
||||
let trimmed_version = version.trim().trim_start_matches('v');
|
||||
let mut parts = trimmed_version.split('.');
|
||||
|
||||
// Fail on versions with pre-release metadata: https://github.com/EFForg/rayhunter/pull/1054#issuecomment-4528407281
|
||||
let major = parts.next()?.parse::<u64>().ok()?;
|
||||
let minor = parts.next()?.parse::<u64>().ok()?;
|
||||
let patch = parts.next()?.parse::<u64>().ok()?;
|
||||
// Expect only major.minor.patch format
|
||||
if parts.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version = format!("{}.{}.{}", major, minor, patch);
|
||||
Some((
|
||||
VersionParts {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
},
|
||||
version.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn format_update_message(current_version: &str, latest_version: &str, release_url: &str) -> String {
|
||||
format!(
|
||||
"Rayhunter {current_version} is installed, but {latest_version} is available. Open {release_url} to download the update."
|
||||
)
|
||||
}
|
||||
|
||||
async fn refresh_update_status(
|
||||
status_lock: &Arc<RwLock<UpdateStatus>>,
|
||||
http_client: &reqwest::Client,
|
||||
) -> Result<Option<(String, String)>, String> {
|
||||
let response = http_client
|
||||
.get(GITHUB_LATEST_RELEASE_URL)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.header(reqwest::header::USER_AGENT, "rayhunter-update-checker")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to query GitHub releases: {err}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"GitHub release check returned {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let response_text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("failed to read GitHub release response: {err}"))?;
|
||||
let release: GitHubReleaseResponse = serde_json::from_str(&response_text)
|
||||
.map_err(|err| format!("failed to parse GitHub release response: {err}"))?;
|
||||
|
||||
let current_version = get_current_version();
|
||||
let (current_version_parts, current_version) = parse_release_tagname(¤t_version)
|
||||
.ok_or_else(|| format!("failed to parse current version {current_version}"))?;
|
||||
let (latest_version_parts, latest_version) = parse_release_tagname(&release.tag_name)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"failed to parse latest release version {}",
|
||||
release.tag_name
|
||||
)
|
||||
})?;
|
||||
|
||||
let update_available = latest_version_parts > current_version_parts;
|
||||
{
|
||||
let mut status = status_lock.write().await;
|
||||
status.current_version = current_version;
|
||||
status.latest_version = Some(latest_version.to_owned());
|
||||
status.latest_release_url = Some(release.html_url.to_owned());
|
||||
status.update_available = update_available;
|
||||
status.last_checked = Some(Local::now());
|
||||
status.last_error = None;
|
||||
}
|
||||
|
||||
if update_available {
|
||||
Ok(Some((latest_version, release.html_url)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_update_check_worker(
|
||||
task_tracker: &TaskTracker,
|
||||
shutdown_token: CancellationToken,
|
||||
update_status_lock: Arc<RwLock<UpdateStatus>>,
|
||||
notification_sender: Sender<Notification>,
|
||||
enabled_notifications: Vec<NotificationType>,
|
||||
) {
|
||||
task_tracker.spawn(async move {
|
||||
let http_client = match reqwest::Client::builder().build() {
|
||||
Ok(client) => client,
|
||||
Err(err) => {
|
||||
error!("failed to create update check client: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut interval = time::interval(UPDATE_CHECK_INTERVAL);
|
||||
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
|
||||
|
||||
// Keep track of last notified version
|
||||
let mut last_notified_version: Option<String> = None;
|
||||
|
||||
loop {
|
||||
if shutdown_token.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
|
||||
match refresh_update_status(&update_status_lock, &http_client).await {
|
||||
Ok(Some((latest_version, latest_release_url))) => {
|
||||
if last_notified_version.as_deref() != Some(latest_version.as_str()) {
|
||||
let current_version =
|
||||
update_status_lock.read().await.current_version.clone();
|
||||
let message = format_update_message(
|
||||
¤t_version,
|
||||
&latest_version,
|
||||
&latest_release_url,
|
||||
);
|
||||
if enabled_notifications.contains(&NotificationType::Update) {
|
||||
if let Err(err) = notification_sender
|
||||
.send(Notification::new(NotificationType::Update, message, None))
|
||||
.await
|
||||
{
|
||||
error!("failed to enqueue update notification: {err}");
|
||||
} else {
|
||||
info!("notified about Rayhunter update {latest_version}");
|
||||
}
|
||||
}
|
||||
last_notified_version = Some(latest_version);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
last_notified_version = None;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("update check failed: {err}");
|
||||
let mut status = update_status_lock.write().await;
|
||||
status.last_error = Some(err);
|
||||
status.last_checked = Some(Local::now());
|
||||
}
|
||||
}
|
||||
|
||||
select! {
|
||||
_ = shutdown_token.cancelled() => break,
|
||||
_ = interval.tick() => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_release_tagname;
|
||||
|
||||
#[test]
|
||||
fn parses_simple_versions() {
|
||||
let (parts, version) = parse_release_tagname("0.11.1").unwrap();
|
||||
assert_eq!(parts.major, 0);
|
||||
assert_eq!(parts.minor, 11);
|
||||
assert_eq!(parts.patch, 1);
|
||||
assert_eq!(version, "0.11.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_invalid_versions() {
|
||||
assert!(parse_release_tagname("invalid").is_none());
|
||||
assert!(parse_release_tagname("v1.2").is_none());
|
||||
assert!(parse_release_tagname("v1.2.x").is_none());
|
||||
assert!(parse_release_tagname("v1.2.3.4").is_none());
|
||||
assert!(parse_release_tagname("v1.2.-3").is_none());
|
||||
assert!(parse_release_tagname("v1.2.3-beta").is_none());
|
||||
assert!(parse_release_tagname("v1.2.3-beta.1").is_none());
|
||||
assert!(parse_release_tagname("1.2").is_none());
|
||||
assert!(parse_release_tagname("1.2.x").is_none());
|
||||
assert!(parse_release_tagname("1.2.3.4").is_none());
|
||||
assert!(parse_release_tagname("1.2.-3").is_none());
|
||||
assert!(parse_release_tagname("1.2.3-beta").is_none());
|
||||
assert!(parse_release_tagname("1.2.3-beta.1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_versions_numerically() {
|
||||
let (newer_version_parts, newer_version) = parse_release_tagname("v0.11.2").unwrap();
|
||||
let (older_version_parts, older_version) = parse_release_tagname("v0.11.1").unwrap();
|
||||
assert!(newer_version_parts > older_version_parts);
|
||||
assert_eq!(newer_version, "0.11.2");
|
||||
assert_eq!(older_version, "0.11.1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_major_minor_patch_correctly() {
|
||||
let (v1_parts, v1) = parse_release_tagname("v1.0.0").unwrap();
|
||||
let (v2_parts, v2) = parse_release_tagname("v1.0.1").unwrap();
|
||||
let (v3_parts, v3) = parse_release_tagname("v1.1.0").unwrap();
|
||||
let (v4_parts, v4) = parse_release_tagname("v2.0.0").unwrap();
|
||||
|
||||
assert!(v2_parts > v1_parts);
|
||||
assert!(v3_parts > v2_parts);
|
||||
assert!(v4_parts > v3_parts);
|
||||
|
||||
assert_eq!(v1, "1.0.0");
|
||||
assert_eq!(v2, "1.0.1");
|
||||
assert_eq!(v3, "1.1.0");
|
||||
assert_eq!(v4, "2.0.0");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user