mirror of
https://github.com/EFForg/rayhunter.git
synced 2026-05-30 04:59:27 -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>
275 lines
9.8 KiB
Rust
275 lines
9.8 KiB
Rust
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");
|
|
}
|
|
}
|