1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
Dustin a58a04c350 Send notifications with Matrix
I don't pay enough attention to e-mail notifications any more, since I
no longer get alerts for them on my phone.  I do get Matrix
notifications, though, through Element, and that seems to work well as a
reminder for me.

Matrix is of course a lot more complex than e-mail.  It is a stateful
protocol that requires (at least) keeping authentication and/or session
information on the client.  Technically, clients all have a "device ID,"
which they should use any time they communicate with the server.  I
decided it makes the most sense to keep this value in the same cache
file as the filesystem UUIDs and timestamps.  I prefer reading usernames
and passwords from a configuration file over passing them as
command-line arguments, so I added that ability as well.
2021-08-23 11:53:21 -05:00
Dustin 53bc0644a6 Update to Rust 2018 edition 2021-08-22 10:32:50 -05:00
6 changed files with 2196 additions and 208 deletions

2031
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,10 @@
[package] [package]
name = "mizule" name = "mizule"
version = "0.1.0" version = "0.2.0"
authors = [ authors = [
"Dustin C. Hatch <dustin@hatch.name>", "Dustin C. Hatch <dustin@hatch.name>",
] ]
edition = "2018"
[dependencies] [dependencies]
clap = "2" clap = "2"
@ -14,6 +15,15 @@ serde_json = "1.0"
version = "0.4" version = "0.4"
features = ["serde"] features = ["serde"]
[dependencies.matrix-sdk]
version = "^0.3.0"
default-features = false
features = ["markdown", "native-tls"]
[dependencies.serde] [dependencies.serde]
version = "1.0" version = "1.0"
features = ["derive"] features = ["derive"]
[dependencies.tokio]
version = "^1.10.0"
features = ["rt", "macros"]

View File

@ -1,12 +1,14 @@
use chrono::Duration;
use chrono; use chrono;
use chrono::Duration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json; use serde_json;
use std::collections::HashMap; use std::collections::HashMap;
use std::env;
use std::fmt; use std::fmt;
use std::fs; use std::fs;
use std::io; use std::io;
use std::io::prelude::*; use std::io::prelude::*;
use std::path::PathBuf;
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct CacheEntry { pub struct CacheEntry {
@ -32,6 +34,7 @@ impl CacheEntry {
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct Cache { pub struct Cache {
paths: HashMap<String, CacheEntry>, paths: HashMap<String, CacheEntry>,
device_id: Option<String>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -71,6 +74,7 @@ impl Cache {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
paths: HashMap::new(), paths: HashMap::new(),
device_id: None,
} }
} }
@ -104,9 +108,41 @@ impl Cache {
} }
pub fn save(&self, path: &str) -> Result<(), Error> { pub fn save(&self, path: &str) -> Result<(), Error> {
let path = PathBuf::from(path);
match path.parent() {
Some(p) => {
if !p.is_dir() {
fs::create_dir_all(p)?;
}
},
None => {},
};
let mut file = fs::File::create(path)?; let mut file = fs::File::create(path)?;
let contents = serde_json::to_string(&self)?; let contents = serde_json::to_string(&self)?;
file.write_all(&contents.as_bytes())?; file.write_all(&contents.as_bytes())?;
Ok(()) Ok(())
} }
pub fn device_id(&self) -> Option<&str> {
match &self.device_id {
Some(p) => Some(p),
None => None,
}
}
pub fn set_device_id(&mut self, device_id: &str) {
self.device_id = Some(device_id.into());
}
}
pub fn get_cache_dir() -> PathBuf {
let mut dir = match env::var("XDG_CACHE_HOME") {
Ok(v) => PathBuf::from(&v),
Err(_) => match env::var("HOME") {
Ok(v) => [v, ".cache".into()].iter().collect(),
Err(_) => ".".into(),
},
};
dir.push(env!("CARGO_PKG_NAME"));
dir
} }

95
src/config.rs Normal file
View File

@ -0,0 +1,95 @@
use serde::{Deserialize, Serialize};
use std::env;
use std::fmt;
use std::fs;
use std::io;
use std::io::prelude::*;
use std::path::PathBuf;
#[derive(Debug)]
pub enum ConfigError {
Io(io::Error),
Json(serde_json::Error),
}
impl ConfigError {
pub fn message(&self) -> String {
match *self {
Self::Io(ref e) => format!("{}", e),
Self::Json(ref e) => format!("{}", e),
}
}
}
impl From<io::Error> for ConfigError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for ConfigError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&self.message())
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct Config {
pub ttl: i64,
pub homeserver: String,
pub username: String,
pub password: String,
pub room: String,
}
impl Config {
pub fn new() -> Self {
Self {
ttl: 30,
homeserver: "".into(),
username: "".into(),
password: "".into(),
room: "".into(),
}
}
pub fn load(path: Option<&str>) -> Result<Self, ConfigError> {
let path = match path {
Some(p) => PathBuf::from(p),
None => {
let mut p = get_config_dir();
p.push("config.json");
p
}
};
if let Ok(mut file) = fs::File::open(path) {
let mut contents = String::new();
file.read_to_string(&mut contents)?;
let config = serde_json::from_str(&contents);
match config {
Ok(c) => Ok(c),
Err(e) => Err(e.into()),
}
} else {
Ok(Self::new())
}
}
}
pub fn get_config_dir() -> PathBuf {
let mut dir = match env::var("XDG_CONFIG_HOME") {
Ok(v) => PathBuf::from(&v),
Err(_) => match env::var("HOME") {
Ok(v) => [v, ".config".into()].iter().collect(),
Err(_) => ".".into(),
},
};
dir.push(env!("CARGO_PKG_NAME"));
dir
}

View File

@ -1,35 +1,27 @@
extern crate chrono;
extern crate clap;
extern crate libudev;
extern crate serde;
extern crate serde_json;
use chrono::Duration; use chrono::Duration;
use clap::App; use clap::App;
use clap::Arg; use clap::Arg;
use matrix_sdk::events::room::message::{
MessageEventContent, MessageType, TextMessageEventContent,
};
use matrix_sdk::events::AnyMessageEventContent;
use matrix_sdk::identifiers::RoomIdOrAliasId;
use matrix_sdk::reqwest::Url;
use matrix_sdk::{Client, ClientConfig, SyncSettings};
use std::convert::TryFrom;
use std::env; use std::env;
use std::io::prelude::*;
use std::num::ParseIntError;
use std::path::PathBuf;
use std::process; use std::process;
use tokio;
mod cache; mod cache;
mod config;
mod error; mod error;
mod mountinfo; mod mountinfo;
use mountinfo::get_fs_uuid; use mountinfo::get_fs_uuid;
const CACHE_FILENAME: &'static str = "mizule.json"; #[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), matrix_sdk::Error> {
fn validate_int(v: String) -> Result<(), String> {
let i: Result<i64, ParseIntError> = v.parse();
match i {
Ok(_) => Ok(()),
Err(e) => Err(format!("Invalid number: {}", e)),
}
}
fn main() {
let matches = App::new("Mizule") let matches = App::new("Mizule")
.version(env!("CARGO_PKG_VERSION")) .version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS")) .author(env!("CARGO_PKG_AUTHORS"))
@ -39,93 +31,115 @@ fn main() {
.help("Path to the mounted filesystem to check") .help("Path to the mounted filesystem to check")
.required(true), .required(true),
) )
.arg(
Arg::with_name("ttl")
.short("t")
.long("ttl")
.takes_value(true)
.validator(validate_int)
.default_value("30")
.help("Number of days before warning"),
)
.arg(
Arg::with_name("mailto")
.short("m")
.long("mailto")
.alias("mail-to")
.takes_value(true)
.help("Send warning to this email address"),
)
.get_matches(); .get_matches();
let mountpoint = matches.value_of("mountpoint").unwrap(); let config = match config::Config::load(None) {
let ttl: i64 = matches.value_of("ttl").unwrap().parse().unwrap(); Ok(c) => c,
let ttl = Duration::days(ttl);
let mailto = matches.value_of("mailto");
match get_fs_uuid(mountpoint) {
Ok(uuid) => {
check_and_notify(mountpoint, &uuid, ttl, mailto);
}
Err(e) => { Err(e) => {
eprintln!("Error getting filesystem UUID: {}", e); eprintln!("Error loading configuration: {}", e);
process::exit(1); process::exit(1);
} }
}
}
fn check_and_notify(
mountpoint: &str,
uuid: &str,
ttl: Duration,
mailto: Option<&str>,
) {
let mut cache_path = match env::var("XDG_CACHE_HOME") {
Ok(v) => PathBuf::from(&v),
Err(_) => match env::var("HOME") {
Ok(v) => [v, ".cache".into()].iter().collect(),
Err(_) => ".".into(),
},
}; };
cache_path.push(CACHE_FILENAME);
let cache_path = cache_path.to_str().unwrap();
let cache = cache::Cache::load(cache_path); let mut cache_path = cache::get_cache_dir();
match cache { cache_path.push("cache.json");
Ok(mut cache) => { let cache_path = cache_path.to_str().unwrap();
if let Some(entry) = cache.get(&mountpoint) { let mut cache = match cache::Cache::load(cache_path) {
if entry.uuid() != uuid { Ok(c) => c,
cache.update(&mountpoint, &uuid);
if let Err(e) = cache.save(&cache_path) {
eprintln!("Failed to save cache: {}", e);
}
} else if entry.expired(ttl) {
notify(mountpoint, uuid, entry.changed(), mailto);
}
} else {
cache.update(&mountpoint, &uuid);
if let Err(e) = cache.save(&cache_path) {
eprintln!("Failed to save cache: {}", e);
}
}
}
Err(e) => { Err(e) => {
eprintln!("Error loading cache: {}", e); eprintln!("Error loading cache: {}", e);
process::exit(1); process::exit(1);
} }
};
let mountpoint = matches.value_of("mountpoint").unwrap();
let fsuuid = match get_fs_uuid(mountpoint) {
Ok(uuid) => uuid,
Err(e) => {
eprintln!("Error getting filesystem UUID: {}", e);
process::exit(1);
} }
};
if check_and_notify(mountpoint, &fsuuid, &config, &mut cache).await {
if let Err(e) = cache.save(&cache_path) {
eprintln!("Failed to save cache: {}", e);
}
}
Ok(())
} }
fn notify( async fn check_and_notify(
mountpoint: &str,
uuid: &str,
config: &config::Config,
cache: &mut cache::Cache,
) -> bool {
let mut changed = false;
let ttl = Duration::days(config.ttl);
if let Some(entry) = cache.get(&mountpoint) {
if entry.uuid() != uuid {
cache.update(&mountpoint, &uuid);
changed = true;
} else if entry.expired(ttl) {
changed =
notify(mountpoint, uuid, entry.changed(), config, cache).await;
}
} else {
cache.update(&mountpoint, &uuid);
changed = true;
}
return changed;
}
async fn notify(
mountpoint: &str, mountpoint: &str,
uuid: &str, uuid: &str,
changed: chrono::DateTime<chrono::Utc>, changed: chrono::DateTime<chrono::Utc>,
mailto: Option<&str>, config: &config::Config,
) { cache: &mut cache::Cache,
) -> bool {
let mut cache_changed = false;
let room_id = RoomIdOrAliasId::try_from(config.room.clone()).unwrap();
let server_name = room_id.server_name().to_owned();
let server_names = [server_name];
let clientconfig = ClientConfig::new().store_path(cache::get_cache_dir());
let client = Client::new_with_config(
Url::parse(&config.homeserver).expect("Invalid homeserver URL"),
clientconfig,
)
.unwrap();
let res = client
.login(
&config.username,
&config.password,
cache.device_id(),
Some(env!("CARGO_PKG_NAME")),
)
.await
.expect("Login failed");
if cache.device_id().is_none() {
cache.set_device_id(res.device_id.as_str());
cache_changed = true;
}
let res = client
.join_room_by_id_or_alias(&room_id, &server_names)
.await
.expect("Failed to join room");
client
.sync_once(SyncSettings::default())
.await
.expect("Failed to sync with Matrix server");
let room = client.get_joined_room(&res.room_id).unwrap();
let now = chrono::Utc::now(); let now = chrono::Utc::now();
let delta = now - changed; let delta = now - changed;
let message = format!( let message = format!(
concat!( concat!(
"The filesystem mounted at {} (UUID {}) ", "The filesystem mounted at `{}` (UUID {}) ",
"was last changed on {} ({} days ago)", "was last changed on {} ({} days ago)",
), ),
mountpoint, mountpoint,
@ -134,37 +148,13 @@ fn notify(
delta.num_days() delta.num_days()
); );
println!("{}", message); println!("{}", message);
if let Some(mailto) = mailto {
println!("Sending notification to {}", mailto);
let subject = format!(
"{}: {} needs to be changed!",
env!("CARGO_PKG_NAME"),
mountpoint
);
if let Err(e) = sendmail(mailto, &subject, &message) {
eprintln!("Failed to send email: {}", e);
}
}
}
fn sendmail( let content =
mailto: &str, AnyMessageEventContent::RoomMessage(MessageEventContent::new(
subject: &str, MessageType::Text(TextMessageEventContent::markdown(message)),
message: &str, ));
) -> Result<(), std::io::Error> { room.send(content, None)
let mut cmd = process::Command::new("sendmail") .await
.arg("-t") .expect("Failed to send Matrix notification");
.stdin(process::Stdio::piped()) return cache_changed;
.spawn()?;
{
let mut stdin = cmd.stdin.take().unwrap();
stdin.write_all(format!("To: {}\n", mailto).as_bytes())?;
stdin.write_all(format!("Subject: {}\n", subject).as_bytes())?;
stdin.write_all("\n".as_bytes())?;
stdin.write_all(message.as_bytes())?;
stdin.write_all("\n".as_bytes())?;
stdin.flush()?;
}
cmd.wait()?;
Ok(())
} }

View File

@ -3,7 +3,7 @@ use std::io;
use std::io::prelude::*; use std::io::prelude::*;
use std::path; use std::path;
use error::Error; use crate::error::Error;
pub struct MountInfo { pub struct MountInfo {
pub mount_id: u32, pub mount_id: u32,