Initial public release

This commit is contained in:
thakares committed 2026-06-11 20:19:03 +05:30
commit c1107147b4
92 files changed
+10562

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use crate::analytics::aggregate_day;
use super::{log_job_start, log_job_end};
pub async fn run_aggregator(db: Db, interval_mins: u64) {
loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator...");
let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
Err(e) => {
let err_str = e.to_string();
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
let date_range = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_visits_date_range(&conn)?
};
if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
let max = chrono::NaiveDate::parse_from_str(&max_date, "%Y-%m-%d")?;
let mut curr = min;
while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string();
{
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?;
}
if curr == max {
break;
}
if let Some(next) = curr.succ_opt() {
curr = next;
} else {
break;
}
}
}
Ok(())
}
+81
View File
@@ -0,0 +1,81 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use crate::config::Config;
use super::{log_job_start, log_job_end};
pub async fn run_backup_scheduler(db: Db, config: Config) {
if !config.backup_enabled {
info!("Background backup scheduler is disabled.");
return;
}
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins);
loop {
// Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
info!("Running background database backup...");
let job_id = log_job_start(&db.system, "database_backup");
match perform_backup(&db, &config).await {
Ok(path) => {
info!("Backup created successfully at {}", path);
log_job_end(&db.system, &job_id, "success", None);
}
Err(e) => {
let err_str = e.to_string();
error!("Error performing backup: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> {
use std::fs::File;
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
use chrono::Utc;
use rusqlite::params;
use uuid::Uuid;
let out_dir = config.backup_dir.clone();
if !out_dir.exists() {
std::fs::create_dir_all(&out_dir)?;
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name);
let file = File::create(&tar_path)?;
let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let db_file = config.data_dir.join(f);
if db_file.exists() {
tar.append_path_with_name(&db_file, f)?;
}
}
tar.into_inner()?.finish()?;
let size_bytes = std::fs::metadata(&tar_path)?.len();
let path_str = tar_path.to_string_lossy().to_string();
// Log to system.db.backup_history
{
let conn = db.system.lock().unwrap();
let backup_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"INSERT INTO backup_history (id, backup_path, status, created_at, size_bytes, error_message)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![backup_id, path_str, "success", now, size_bytes as i64, None::<String>],
);
}
Ok(path_str)
}
+94
View File
@@ -0,0 +1,94 @@
use reqwest::Client;
use std::time::Duration;
use tracing::{info, error};
use uuid::Uuid;
use chrono::Utc;
use rusqlite::params;
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_link_checker(db: Db, interval_mins: u64) {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1")
.build()
.unwrap_or_default();
loop {
// Sleep first to give server time to start up
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background link health check...");
let job_id = log_job_start(&db.system, "link_checker");
match perform_link_check(&db, &client).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
Err(e) => {
let err_str = e.to_string();
error!("Error performing link health check: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> {
let urls = {
let conn = db.content.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)?
};
for (id, dest) in urls {
let (status, status_code, err_msg) = check_url_health(client, &dest).await;
{
let conn = db.content.lock().unwrap();
crate::db::content::update_url_health(&conn, &id, &status)?;
}
// Log to system.db.health_checks
{
let conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
}
Ok(())
}
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) {
let res = client.get(url)
.timeout(Duration::from_secs(5))
.send()
.await;
match res {
Ok(response) => {
let status = response.status();
let code = status.as_u16();
if status.is_success() || status.is_redirection() {
("healthy".to_string(), Some(code), None)
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
("dead".to_string(), Some(code), Some(format!("HTTP {}", code)))
} else {
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code)))
}
}
Err(err) => {
let err_str = err.to_string();
if err.is_timeout() || err.is_connect() {
("suspect".to_string(), None, Some(err_str))
} else {
("dead".to_string(), None, Some(err_str))
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
use std::sync::Mutex;
use rusqlite::{Connection, params};
use uuid::Uuid;
use chrono::Utc;
pub mod healthcheck;
pub mod retention;
pub mod aggregate;
pub mod backup;
pub use healthcheck::{run_link_checker, perform_link_check};
pub use retention::run_retention_cleaner;
pub use aggregate::{run_aggregator, perform_aggregation};
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
if let Ok(c) = conn.lock() {
let _ = c.execute(
"INSERT INTO job_history (id, job_name, status, started_at) VALUES (?1, ?2, ?3, ?4);",
params![id, job_name, "running", now],
);
}
id
}
pub fn log_job_end(conn: &Mutex<Connection>, id: &str, status: &str, err_msg: Option<&str>) {
let now = Utc::now().to_rfc3339();
if let Ok(c) = conn.lock() {
let _ = c.execute(
"UPDATE job_history SET status = ?1, finished_at = ?2, error_message = ?3 WHERE id = ?4;",
params![status, now, err_msg, id],
);
}
}
+32
View File
@@ -0,0 +1,32 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
let retention_days = match retention_days_opt {
Some(days) => days,
None => return,
};
loop {
// Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup...");
let job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap();
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
Ok(count) => {
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", None);
}
Err(e) => {
let err_str = e.to_string();
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}