feat: init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/target
|
||||
2919
Cargo.lock
generated
Normal file
2919
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "ntfymhe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.45"
|
||||
html-escape = "0.2.14"
|
||||
reqwest = { version = "0.13.4", features = ["zstd", "deflate", "brotli", "gzip"] }
|
||||
rocket = "0.5.1"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
tokio = { version = "1.53.1", features = ["rt", "macros", "fs", "process"] }
|
||||
urlencoding = "2.1.3"
|
||||
114
src/data.rs
Normal file
114
src/data.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
use std::{
|
||||
collections::{HashMap, VecDeque},
|
||||
sync::{Arc, atomic::AtomicBool},
|
||||
};
|
||||
|
||||
use reqwest::Url;
|
||||
use tokio::{sync::Mutex, time::Instant};
|
||||
|
||||
use crate::{
|
||||
Config,
|
||||
ntfy::{Message, ntfy_task},
|
||||
};
|
||||
|
||||
pub struct Data {
|
||||
pub subscriptions: HashMap<String, Arc<Subscription>>,
|
||||
}
|
||||
pub struct Subscription {
|
||||
/// true while a task is running which is
|
||||
/// handling this subscription
|
||||
pub running: AtomicBool,
|
||||
/// true if there is currently no error
|
||||
pub connected: AtomicBool,
|
||||
/// true to request shutdown of any task
|
||||
/// handling this subscription
|
||||
pub shutdown: AtomicBool,
|
||||
|
||||
pub url: Url,
|
||||
pub user: Option<String>,
|
||||
pub pass: Option<String>,
|
||||
|
||||
pub shell: (String, Vec<String>),
|
||||
pub onmsg: Option<String>,
|
||||
|
||||
pub history: Mutex<VecDeque<(Instant, Message)>>,
|
||||
pub errors: Mutex<VecDeque<(Vec<Instant>, String)>>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
pub fn new((_config, subscriptions): Config) -> Self {
|
||||
Self {
|
||||
subscriptions: subscriptions
|
||||
.iter()
|
||||
.filter_map(|(name, options)| {
|
||||
let mut url = options.get("url")?.to_owned();
|
||||
if !url.ends_with('/') {
|
||||
url.push('/');
|
||||
}
|
||||
url.push_str("json");
|
||||
let url = Url::parse(&url).ok()?;
|
||||
|
||||
let shell = if let Some(shell) = options.get("shell") {
|
||||
(
|
||||
shell.to_owned(),
|
||||
options
|
||||
.get("args")
|
||||
.map(|v| {
|
||||
v.split(char::is_whitespace)
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.to_owned())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/usr/bin/env".to_owned(),
|
||||
vec!["sh".to_owned(), "-c".to_owned()],
|
||||
)
|
||||
};
|
||||
|
||||
let subscription = Arc::new(Subscription {
|
||||
running: AtomicBool::new(false),
|
||||
connected: AtomicBool::new(false),
|
||||
shutdown: AtomicBool::new(false),
|
||||
|
||||
url,
|
||||
user: options.get("user").cloned(),
|
||||
pass: options.get("pass").cloned(),
|
||||
|
||||
shell,
|
||||
onmsg: options.get("onmsg").cloned(),
|
||||
|
||||
history: Mutex::new(VecDeque::new()),
|
||||
errors: Mutex::new(VecDeque::new()),
|
||||
});
|
||||
ntfy_task(&subscription);
|
||||
Some((name.to_owned(), subscription))
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Subscription {
|
||||
/// Locks `self.errors`.
|
||||
pub async fn push_error(&self, e: String) {
|
||||
let mut errors = self.errors.lock().await;
|
||||
if let Some(i) = errors.iter().position(|(_, e2)| *e2 == e)
|
||||
&& let Some((mut times, _)) = errors.remove(i)
|
||||
{
|
||||
if times.len() >= 50 {
|
||||
times[49] = Instant::now();
|
||||
} else {
|
||||
times.push(Instant::now());
|
||||
}
|
||||
errors.push_back((times, e));
|
||||
} else {
|
||||
while errors.len() > 50 {
|
||||
errors.pop_front();
|
||||
}
|
||||
errors.push_back((vec![Instant::now()], e));
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/index.html
Normal file
28
src/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="10">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>ntfymhe</title>
|
||||
<style>
|
||||
.subs {
|
||||
display: grid;
|
||||
grid-template-columns: 3fr 2fr;
|
||||
}
|
||||
.stopped {
|
||||
background: transparent;
|
||||
}
|
||||
.connected {
|
||||
background: #0F03;
|
||||
}
|
||||
.disconnected {
|
||||
background: #F003;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ntfy subscriptions</h1>
|
||||
<div class="subs">&SUBS;</div>
|
||||
</body>
|
||||
</html>
|
||||
259
src/ini.rs
Normal file
259
src/ini.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use std::{collections::HashMap, hash::Hash};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub trait Ini<'a>: Sized {
|
||||
fn parse_ini(mut src: &'a str) -> Result<Self, String> {
|
||||
let copy = src;
|
||||
Self::deserialize(&mut src).map_err(|e| {
|
||||
let i = copy.len() - src.len();
|
||||
let line = copy[..i].lines().count();
|
||||
format!("line {line}: {e}")
|
||||
})
|
||||
}
|
||||
fn write_ini(&self) -> String {
|
||||
let mut out = String::new();
|
||||
self.serialize(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn serialize(&self, out: &mut String);
|
||||
fn deserialize(src: &mut &'a str) -> Result<Self, String>;
|
||||
}
|
||||
pub trait IniValue<'a>: Sized {
|
||||
fn serialize(&self, out: &mut String, terminators: &[char]);
|
||||
fn deserialize(src: &mut &'a str, terminators: &[char]) -> Result<Self, String>;
|
||||
}
|
||||
|
||||
impl<'a> IniValue<'a> for &'a str {
|
||||
fn serialize(&self, out: &mut String, terminators: &[char]) {
|
||||
let should_quote = out.contains(terminators)
|
||||
|| out.contains(['[', ']'])
|
||||
|| out.starts_with(char::is_whitespace)
|
||||
|| out.ends_with(char::is_whitespace);
|
||||
let specials = out.contains(['\0', '\r', '\t']);
|
||||
let single_quotes = out.contains('\'');
|
||||
if should_quote || specials || single_quotes || out.contains('"') {
|
||||
if specials || single_quotes {
|
||||
// either contains weird characters or ',
|
||||
// so we use " and escape relevant characters.
|
||||
out.push('"');
|
||||
out.push_str(
|
||||
&self
|
||||
.replace('\\', "\\\\")
|
||||
.replace('\0', "\\0")
|
||||
.replace('\r', "\\r")
|
||||
.replace('\t', "\\t")
|
||||
.replace('"', "\\\""),
|
||||
);
|
||||
out.push('"');
|
||||
} else {
|
||||
// no weird characters or ',
|
||||
// use ' without escaping anything.
|
||||
out.push('\'');
|
||||
out.push_str(&self);
|
||||
out.push('\'');
|
||||
}
|
||||
} else {
|
||||
out.push_str(self);
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize(src: &mut &'a str, terminators: &[char]) -> Result<Self, String> {
|
||||
*src = src.trim_start();
|
||||
if src.starts_with('\'') {
|
||||
*src = &src[1..];
|
||||
if let Some(i) = src.find('\'') {
|
||||
let out = &src[0..i];
|
||||
*src = &src[i + 1..];
|
||||
Ok(out)
|
||||
} else {
|
||||
Err("missing a closing single quote")?
|
||||
}
|
||||
} else if src.starts_with('"') {
|
||||
*src = &src[1..];
|
||||
if let Some(i) = src.find('"') {
|
||||
let out = &src[0..i];
|
||||
if out.contains('\\') {
|
||||
Err(
|
||||
"backslashes, even if double-quoted, are not supported by the deserializer (caller should deserialize to String instead of &str)",
|
||||
)?;
|
||||
}
|
||||
*src = &src[i + 1..];
|
||||
Ok(out)
|
||||
} else {
|
||||
Err("missing a closing double quote")?
|
||||
}
|
||||
} else {
|
||||
Ok(if let Some(i) = src.find(terminators) {
|
||||
let out = src[0..i].trim_end();
|
||||
*src = &src[i..];
|
||||
out
|
||||
} else {
|
||||
let out = src.trim_end();
|
||||
*src = "";
|
||||
out
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> IniValue<'a> for String {
|
||||
fn serialize(&self, out: &mut String, terminators: &[char]) {
|
||||
self.as_str().serialize(out, terminators)
|
||||
}
|
||||
|
||||
fn deserialize(src: &mut &'a str, terminators: &[char]) -> Result<Self, String> {
|
||||
*src = src.trim_start();
|
||||
if src.starts_with('\'') {
|
||||
*src = &src[1..];
|
||||
if let Some(i) = src.find('\'') {
|
||||
let out = &src[0..i];
|
||||
*src = &src[i + 1..];
|
||||
Ok(out.to_owned())
|
||||
} else {
|
||||
Err("missing a closing single quote")?
|
||||
}
|
||||
} else if src.starts_with('"') {
|
||||
*src = &src[1..];
|
||||
let mut out = String::new();
|
||||
loop {
|
||||
if let Some(ch) = src.chars().next() {
|
||||
*src = &src[ch.len_utf8()..];
|
||||
if ch == '"' {
|
||||
break;
|
||||
} else if ch != '\\' {
|
||||
out.push(ch);
|
||||
} else {
|
||||
let ch = src
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or("EOF after backslash in double-quoted string")?;
|
||||
*src = &src[ch.len_utf8()..];
|
||||
out.push(match ch {
|
||||
'0' => '\0',
|
||||
'r' => '\r',
|
||||
'n' => '\n',
|
||||
't' => '\t',
|
||||
ch => ch,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Err("missing a closing double quote")?
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
} else {
|
||||
Ok(if let Some(i) = src.find(terminators) {
|
||||
let out = src[0..i].trim_end();
|
||||
*src = &src[i..];
|
||||
out.to_owned()
|
||||
} else {
|
||||
let out = src.trim_end();
|
||||
*src = "";
|
||||
out.to_owned()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait IniMap<'a>: Sized {
|
||||
type K;
|
||||
type V;
|
||||
fn new() -> Self;
|
||||
fn insert(&mut self, k: Self::K, v: Self::V);
|
||||
fn iter<'b>(&'b self) -> impl Iterator<Item = (&'b Self::K, &'b Self::V)>
|
||||
where
|
||||
Self::K: 'b,
|
||||
Self::V: 'b;
|
||||
}
|
||||
impl<'a, K: Eq + Hash, V> IniMap<'a> for HashMap<K, V> {
|
||||
type K = K;
|
||||
type V = V;
|
||||
fn new() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
fn insert(&mut self, k: K, v: V) {
|
||||
self.insert(k, v);
|
||||
}
|
||||
fn iter<'b>(&'b self) -> impl Iterator<Item = (&'b K, &'b V)>
|
||||
where
|
||||
K: 'b,
|
||||
V: 'b,
|
||||
{
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, I, M> Ini<'a> for (I, M)
|
||||
where
|
||||
I: Ini<'a>,
|
||||
M: IniMap<'a>,
|
||||
M::K: IniValue<'a>,
|
||||
M::V: Ini<'a>,
|
||||
{
|
||||
fn serialize(&self, out: &mut String) {
|
||||
self.0.serialize(out);
|
||||
for (name, section) in self.1.iter() {
|
||||
out.push_str("\n[");
|
||||
name.serialize(out, &['\n', ']']);
|
||||
out.push_str("]\n");
|
||||
section.serialize(out);
|
||||
}
|
||||
}
|
||||
fn deserialize(src: &mut &'a str) -> Result<Self, String> {
|
||||
let head = I::deserialize(src)?;
|
||||
let mut body = M::new();
|
||||
loop {
|
||||
*src = src.trim_start();
|
||||
if src.is_empty() {
|
||||
break;
|
||||
} else if src.starts_with('[') {
|
||||
*src = &src[1..];
|
||||
let name = M::K::deserialize(src, &['\n', ']'])?;
|
||||
*src = src.trim_start();
|
||||
if !src.starts_with(']') {
|
||||
Err("found `[` but no corresponding `]`")?;
|
||||
}
|
||||
*src = &src[1..];
|
||||
body.insert(name, M::V::deserialize(src)?);
|
||||
} else {
|
||||
Err("expected a new section, but found no `[`")?;
|
||||
}
|
||||
}
|
||||
Ok((head, body))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, M> Ini<'a> for M
|
||||
where
|
||||
M: IniMap<'a>,
|
||||
M::K: IniValue<'a>,
|
||||
M::V: IniValue<'a>,
|
||||
{
|
||||
fn serialize(&self, out: &mut String) {
|
||||
for (k, v) in self.iter() {
|
||||
k.serialize(out, &['\n', '=']);
|
||||
out.push_str(" = ");
|
||||
v.serialize(out, &['\n']);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
fn deserialize(src: &mut &'a str) -> Result<Self, String> {
|
||||
let mut out = M::new();
|
||||
loop {
|
||||
*src = src.trim_start();
|
||||
if src.is_empty() || src.starts_with('[') {
|
||||
break;
|
||||
}
|
||||
let k = M::K::deserialize(src, &['\n', '='])?;
|
||||
*src = src.trim_start();
|
||||
if !src.starts_with('=') {
|
||||
Err("missing `=` after key")?;
|
||||
}
|
||||
*src = &src[1..];
|
||||
let v = M::V::deserialize(src, &['\n'])?;
|
||||
out.insert(k, v);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
278
src/main.rs
Normal file
278
src/main.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
mod data;
|
||||
mod ini;
|
||||
mod ntfy;
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
use std::{collections::HashMap, error::Error, sync::Arc};
|
||||
|
||||
use chrono::{Datelike, Local, Timelike};
|
||||
use html_escape::{encode_safe, encode_safe_to_string};
|
||||
use rocket::config::LogLevel;
|
||||
use rocket::form::Form;
|
||||
use rocket::http::Status;
|
||||
use rocket::{FromForm, post};
|
||||
use rocket::{get, response::content::RawHtml, routes};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::data::Data;
|
||||
use crate::ini::Ini;
|
||||
use crate::ntfy::ntfy_task;
|
||||
|
||||
type State = rocket::State<Arc<Mutex<Data>>>;
|
||||
type Config = (
|
||||
HashMap<String, String>,
|
||||
HashMap<String, HashMap<String, String>>,
|
||||
);
|
||||
|
||||
/// ntfy.sh client which reconnects without notification spam.
|
||||
///
|
||||
/// reads config from `$XDG_CONFIG_HOME/ntfymhe/config.ini`:
|
||||
///
|
||||
/// ```ini
|
||||
/// addr = 127.0.0.1
|
||||
/// port = 8000
|
||||
/// log = 2
|
||||
///
|
||||
/// [my subscription]
|
||||
/// url = https://example.com/topic
|
||||
/// user = my name
|
||||
/// pass = "abcd"
|
||||
/// onmsg = 'notify-send "$title: $message"'
|
||||
///
|
||||
/// [other subscription]
|
||||
/// ...
|
||||
/// ```
|
||||
///
|
||||
/// All fields except `url` are optional.
|
||||
/// The `onmsg` script is passed to a shell, by default as `/usr/bin/env sh -c "$onmsg"`.
|
||||
/// You can change the shell by setting e.g. `shell = /bin/bash` and `args = -c`.
|
||||
#[tokio::main(flavor = "local")]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let config_home =
|
||||
std::env::var("XDG_CONFIG_HOME").expect("env var XDG_CONFIG_HOME is required");
|
||||
let (cfg, subs) = Config::parse_ini(
|
||||
&tokio::fs::read_to_string(format!("{config_home}/ntfymhe/config.ini"))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut rocket = rocket::Config::release_default();
|
||||
rocket.log_level = [
|
||||
LogLevel::Off,
|
||||
LogLevel::Critical,
|
||||
LogLevel::Normal,
|
||||
LogLevel::Debug,
|
||||
]
|
||||
.get(cfg.get("log").and_then(|v| v.parse().ok()).unwrap_or(0))
|
||||
.copied()
|
||||
.unwrap_or(LogLevel::Off);
|
||||
if let Some(v) = cfg.get("addr")
|
||||
&& let Ok(v) = v.parse()
|
||||
{
|
||||
rocket.address = v;
|
||||
}
|
||||
if let Some(v) = cfg.get("port")
|
||||
&& let Ok(v) = v.parse()
|
||||
{
|
||||
rocket.port = v;
|
||||
}
|
||||
rocket::build()
|
||||
.configure(rocket)
|
||||
.manage(Arc::new(Mutex::new(Data::new((cfg, subs)))))
|
||||
.mount("/", routes![index, subscription, subscription_post])
|
||||
.launch()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/")]
|
||||
async fn index(state: &State) -> RawHtml<String> {
|
||||
let state = state.lock().await;
|
||||
let subs = if state.subscriptions.is_empty() {
|
||||
"<div><em>you have no subscriptions</em></div><div></div>".to_owned()
|
||||
} else {
|
||||
let mut subs = String::new();
|
||||
for (url, subscription) in state.subscriptions.iter() {
|
||||
subs.push_str(r#"<div><a href=""#);
|
||||
subs.push_str(&urlencoding::encode(url.as_str()));
|
||||
subs.push_str(r#"">"#);
|
||||
encode_safe_to_string(url.as_str(), &mut subs);
|
||||
subs.push_str(r#"</a></div><div class=""#);
|
||||
let running = subscription.running.load(Ordering::Relaxed);
|
||||
let connected = subscription.connected.load(Ordering::Relaxed);
|
||||
if !running {
|
||||
subs.push_str("stopped");
|
||||
} else if connected {
|
||||
subs.push_str("connected");
|
||||
} else {
|
||||
subs.push_str("disconnected");
|
||||
}
|
||||
subs.push_str(r#"">"#);
|
||||
if !running {
|
||||
subs.push_str("stopped");
|
||||
} else if connected {
|
||||
subs.push_str("connected");
|
||||
} else {
|
||||
subs.push_str("disconnected");
|
||||
}
|
||||
subs.push_str("</div>");
|
||||
}
|
||||
subs
|
||||
};
|
||||
RawHtml(include_str!("index.html").replace("&SUBS;", &subs))
|
||||
}
|
||||
|
||||
#[derive(FromForm)]
|
||||
struct Post {
|
||||
run: Option<u8>,
|
||||
}
|
||||
#[post("/<sub>", data = "<post>")]
|
||||
async fn subscription_post(sub: &str, post: Form<Post>, state: &State) -> Status {
|
||||
let Post { run } = post.into_inner();
|
||||
let state = state.lock().await;
|
||||
if let Some(subscription) = state.subscriptions.get(sub) {
|
||||
match run {
|
||||
Some(0) => subscription.shutdown.store(true, Ordering::Relaxed),
|
||||
Some(1) => {
|
||||
subscription.shutdown.store(false, Ordering::Relaxed);
|
||||
ntfy_task(subscription);
|
||||
}
|
||||
_ => return Status::BadRequest,
|
||||
}
|
||||
Status::Ok
|
||||
} else {
|
||||
Status::NotFound
|
||||
}
|
||||
}
|
||||
|
||||
#[get("/<sub>?<errs>")]
|
||||
async fn subscription(sub: &str, errs: bool, state: &State) -> Result<RawHtml<String>, Status> {
|
||||
let state = state.lock().await;
|
||||
if let Some(subscription) = state.subscriptions.get(sub) {
|
||||
let running = subscription.running.load(Ordering::Relaxed);
|
||||
let connected = subscription.connected.load(Ordering::Relaxed);
|
||||
let shutdown = subscription.shutdown.load(Ordering::Relaxed);
|
||||
let form = |skip: &[&str], html: &str| {
|
||||
let mut form = r#"<form action="" method="get">"#.to_owned();
|
||||
if errs && !skip.contains(&"errs") {
|
||||
form.push_str(r#"<input type="hidden" name="errs" value="">"#);
|
||||
}
|
||||
form.push_str(html);
|
||||
form.push_str("</form>");
|
||||
form
|
||||
};
|
||||
|
||||
let now = Instant::now();
|
||||
let mut messages = String::new();
|
||||
let mut errors = if running {
|
||||
r#"<a href=".">back</a> | <form action="" method="post" target="formframe"><button type="submit" name="run" value="0">stop</button></form> "#.to_owned()
|
||||
} else {
|
||||
r#"<a href=".">back</a> | <form action="" method="post" target="formframe"><button type="submit" name="run" value="1">start</button></form> "#.to_owned()
|
||||
};
|
||||
if errs {
|
||||
errors.push_str(&form(
|
||||
&["errs"],
|
||||
r#"<button type="submit">hide errors</button>"#,
|
||||
));
|
||||
errors.push_str(r#"<div class="errs">"#);
|
||||
for (recv, error) in subscription.errors.lock().await.iter().rev() {
|
||||
let ago = now.saturating_duration_since(*recv.last().unwrap());
|
||||
errors.push_str("<div><i>");
|
||||
fmt_ago(ago, &mut errors);
|
||||
errors.push_str("</i><div>");
|
||||
encode_safe_to_string(error, &mut errors);
|
||||
errors.push_str("</div></div>");
|
||||
}
|
||||
errors.push_str("</div>");
|
||||
} else {
|
||||
errors.push_str(&form(
|
||||
&["errs"],
|
||||
r#"<button type="submit" name="errs" value="">show errors</button>"#,
|
||||
));
|
||||
}
|
||||
for (recv, message) in subscription.history.lock().await.iter().rev() {
|
||||
let ago = now.saturating_duration_since(*recv);
|
||||
messages.push_str(r#"<div class="msg"><i>"#);
|
||||
if let Some(time) = message.time
|
||||
&& let Some(time) = chrono::DateTime::from_timestamp_secs(time)
|
||||
{
|
||||
let time = time.with_timezone(&Local).naive_local();
|
||||
messages.push_str(&format!(
|
||||
"{:0>2}:{:0>2}:{:0>2} <small>{}-{}-{}, ",
|
||||
time.hour(),
|
||||
time.minute(),
|
||||
time.second(),
|
||||
time.year(),
|
||||
time.month(),
|
||||
time.day(),
|
||||
));
|
||||
} else {
|
||||
messages.push_str("<small>");
|
||||
}
|
||||
messages.push_str("recv ");
|
||||
fmt_ago(ago, &mut messages);
|
||||
messages.push_str("</small></i><br>");
|
||||
match (&message.title, &message.message) {
|
||||
(None, None) => {}
|
||||
(Some(msg), None) | (None, Some(msg)) => {
|
||||
messages.push_str(r#"<b>"#);
|
||||
encode_safe_to_string(msg, &mut messages);
|
||||
messages.push_str("</b>");
|
||||
}
|
||||
(Some(title), Some(content)) => {
|
||||
messages.push_str(r#"<b>"#);
|
||||
encode_safe_to_string(title, &mut messages);
|
||||
messages.push_str("</b><br>");
|
||||
encode_safe_to_string(content, &mut messages);
|
||||
}
|
||||
}
|
||||
messages.push_str("</div>");
|
||||
}
|
||||
Ok(RawHtml(
|
||||
include_str!("subscription.html")
|
||||
.replace("&NAME;", &encode_safe(sub))
|
||||
.replace(
|
||||
"&STATE;",
|
||||
if !running {
|
||||
"stopped"
|
||||
} else if shutdown {
|
||||
"stopping"
|
||||
} else if connected {
|
||||
"connected"
|
||||
} else {
|
||||
"disconnected"
|
||||
},
|
||||
)
|
||||
.replace("&MSGS;", &messages)
|
||||
.replace("&ERRS;", &errors),
|
||||
))
|
||||
} else {
|
||||
Err(Status::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_ago(ago: Duration, html: &mut String) {
|
||||
let secs = ago.as_secs();
|
||||
fn s(n: u64) -> &'static str {
|
||||
if n == 1 { "" } else { "s" }
|
||||
}
|
||||
if secs < 60 {
|
||||
html.push_str(&format!("{} second{} ago", secs, s(secs)));
|
||||
} else {
|
||||
let mins = secs / 60;
|
||||
if mins < 60 {
|
||||
html.push_str(&format!("{} minute{} ago", mins, s(mins)));
|
||||
} else {
|
||||
let hours = mins / 60;
|
||||
if hours < 48 {
|
||||
html.push_str(&format!("{} hour{} ago", hours, s(hours)));
|
||||
} else {
|
||||
let days = hours / 24;
|
||||
html.push_str(&format!("{days} days ago"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
185
src/ntfy.rs
Normal file
185
src/ntfy.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use std::{
|
||||
sync::{Arc, atomic::Ordering},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tokio::{process::Command, task::JoinHandle, time::Instant};
|
||||
|
||||
use crate::data::Subscription;
|
||||
|
||||
pub fn ntfy_task(subscription: &Arc<Subscription>) -> Option<JoinHandle<()>> {
|
||||
if subscription
|
||||
.running
|
||||
.compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed)
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let subscription = Arc::clone(subscription);
|
||||
Some(tokio::task::spawn(async move {
|
||||
let mut backoff = 1;
|
||||
loop {
|
||||
if subscription.shutdown.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if let Err(e) = handle(&subscription).await {
|
||||
subscription.connected.store(false, Ordering::Relaxed);
|
||||
subscription.push_error(e).await;
|
||||
backoff = (backoff * 2).min(10 * 60);
|
||||
} else {
|
||||
backoff = 1;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(backoff)).await;
|
||||
}
|
||||
subscription.running.store(false, Ordering::Relaxed);
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle(subscription: &Subscription) -> Result<(), String> {
|
||||
let mut url = subscription.url.clone();
|
||||
if let Some(id) = subscription
|
||||
.history
|
||||
.lock()
|
||||
.await
|
||||
.back()
|
||||
.and_then(|(_, msg)| msg.id.as_ref())
|
||||
{
|
||||
url.set_query(Some(&format!("since={}", urlencoding::encode(id))));
|
||||
} else {
|
||||
url.set_query(Some("since=all"));
|
||||
}
|
||||
let mut request = reqwest::Client::new().get(url);
|
||||
if let Some(user) = &subscription.user {
|
||||
request = request.basic_auth(user, subscription.pass.as_ref());
|
||||
}
|
||||
let mut response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("couldn't connect: {e}"))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("non-ok response: {e}"))?;
|
||||
subscription.connected.store(true, Ordering::Relaxed);
|
||||
|
||||
let dur = Duration::from_secs(5 * 61);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
if subscription.shutdown.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
if let Some(chunk) = tokio::time::timeout(dur, response.chunk())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.ok())
|
||||
.flatten()
|
||||
{
|
||||
if let (Some(mut i), Some(j)) = (
|
||||
chunk[..].iter().position(|v| *v == b'\n'),
|
||||
chunk[..].iter().rposition(|v| *v == b'\n'),
|
||||
) {
|
||||
buf.extend(&chunk[0..i]);
|
||||
if i < j {
|
||||
i += 1;
|
||||
}
|
||||
for line in std::iter::once(&buf[..]).chain(chunk[i..j].split(|v| *v == b'\n')) {
|
||||
if let Ok(str) = str::from_utf8(line)
|
||||
&& let str = str.trim()
|
||||
&& !str.is_empty()
|
||||
{
|
||||
for_line(str, subscription).await?;
|
||||
}
|
||||
}
|
||||
buf = chunk[j + 1..].to_vec();
|
||||
} else {
|
||||
buf.extend(chunk);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[serde(tag = "event")]
|
||||
enum Event {
|
||||
#[serde(rename = "open")]
|
||||
Open,
|
||||
#[serde(rename = "keepalive")]
|
||||
Keepalive,
|
||||
#[serde(rename = "message")]
|
||||
Message(Message),
|
||||
#[serde(rename = "message_delete")]
|
||||
MessageDelete,
|
||||
#[serde(rename = "message_clear")]
|
||||
MessageClear,
|
||||
#[serde(rename = "poll_request")]
|
||||
PollRequest,
|
||||
}
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Message {
|
||||
pub id: Option<String>,
|
||||
pub time: Option<i64>,
|
||||
pub topic: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub message: Option<String>,
|
||||
#[serde(default, skip)]
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
async fn for_line(line: &str, subscription: &Subscription) -> Result<(), String> {
|
||||
let event =
|
||||
serde_json::from_str::<Event>(line).map_err(|e| format!("unparsable json message: {e}"))?;
|
||||
match event {
|
||||
Event::Message(mut message) => {
|
||||
message.raw = line.to_owned();
|
||||
let mut history = subscription.history.lock().await;
|
||||
let run_onmsg = message.id.as_ref().is_none_or(|id| {
|
||||
history
|
||||
.iter()
|
||||
.all(|(_, msg)| msg.id.as_ref().is_none_or(|old| id != old))
|
||||
});
|
||||
fn forenv(v: &Option<String>) -> &str {
|
||||
v.as_ref().map(|v| v.as_str()).unwrap_or("")
|
||||
}
|
||||
if run_onmsg
|
||||
&& let Some(cmd) = &subscription.onmsg
|
||||
&& let time = message.time.map(|v| v.to_string())
|
||||
&& let Err(e) = Command::new(&subscription.shell.0)
|
||||
.args(&subscription.shell.1)
|
||||
.arg(cmd)
|
||||
.envs([
|
||||
("NTFY_ID", forenv(&message.id)),
|
||||
("id", forenv(&message.id)),
|
||||
("NTFY_TIME", forenv(&time)),
|
||||
("time", forenv(&time)),
|
||||
("NTFY_TOPIC", forenv(&message.topic)),
|
||||
("topic", forenv(&message.topic)),
|
||||
("NTFY_MESSAGE", forenv(&message.message)),
|
||||
("message", forenv(&message.message)),
|
||||
("m", forenv(&message.message)),
|
||||
("NTFY_TITLE", forenv(&message.title)),
|
||||
("title", forenv(&message.title)),
|
||||
("t", forenv(&message.title)),
|
||||
("NTFY_RAW", message.raw.as_str()),
|
||||
("raw", message.raw.as_str()),
|
||||
])
|
||||
.spawn()
|
||||
{
|
||||
subscription
|
||||
.push_error(format!("error running onmsg command: {e}"))
|
||||
.await;
|
||||
}
|
||||
while history.len() > 200 {
|
||||
history.pop_front();
|
||||
}
|
||||
history.push_back((Instant::now(), message));
|
||||
}
|
||||
Event::Open
|
||||
| Event::Keepalive
|
||||
| Event::MessageDelete
|
||||
| Event::MessageClear
|
||||
| Event::PollRequest => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
51
src/subscription.html
Normal file
51
src/subscription.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="refresh" content="5">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>&NAME;</title>
|
||||
<style>
|
||||
form {
|
||||
display: inline;
|
||||
}
|
||||
.stopped {
|
||||
background: transparent;
|
||||
}
|
||||
.stopping {
|
||||
background: #00F2;
|
||||
}
|
||||
.connected {
|
||||
background: #0F03;
|
||||
}
|
||||
.disconnected {
|
||||
background: #F003;
|
||||
}
|
||||
.msgs {
|
||||
margin-top: 2em;
|
||||
}
|
||||
.msg {
|
||||
border-bottom: 1pt solid #FFF6;
|
||||
margin: 0;
|
||||
}
|
||||
.msg i {
|
||||
font-size: smaller;
|
||||
color: #DDD;
|
||||
}
|
||||
.errs i {
|
||||
font-size: smaller;
|
||||
color: #FCC;
|
||||
}
|
||||
#formframe {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2 class="&STATE;">&NAME;</h2>
|
||||
&ERRS;
|
||||
<div class="msgs">&MSGS;</div>
|
||||
<iframe id="formframe" name="formframe"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user