Browse Source

Initial commit, written around 2020-04-{04,05}.

master
treyzania 2 years ago
commit
692716b2da
6 changed files with 2103 additions and 0 deletions
  1. 2
    0
      .gitignore
  2. 1541
    0
      Cargo.lock
  3. 22
    0
      Cargo.toml
  4. 211
    0
      src/config.rs
  5. 103
    0
      src/main.rs
  6. 224
    0
      src/spool.rs

+ 2
- 0
.gitignore View File

@@ -0,0 +1,2 @@
/target
*~

+ 1541
- 0
Cargo.lock
File diff suppressed because it is too large
View File


+ 22
- 0
Cargo.toml View File

@@ -0,0 +1,22 @@
[package]
name = "mtxspooler"
version = "0.1.0"
authors = []
edition = "2018"

[dependencies]
clap = { git = "https://github.com/clap-rs/clap", features = ["derive"] }
futures = { version = "0.3.4", features = ["compat"] }
hyper = "0.13.2"
hyper-tls = "0.4.1"
inotify = "0.3.0"
rand = "0.7.3"
ruma-api = "0.13.0"
ruma-client = "0.3.0"
ruma-client-api = "0.6.0"
ruma-events = "0.15.0"
ruma-identifiers = "0.14.1"
tokio = { version = "0.2.16", features = ["fs", "io-driver", "net", "rt-core", "sync"] }
tokio-inotify = "0.4.1"
toml = "0.5.6"
url = "2.1.1"

+ 211
- 0
src/config.rs View File

@@ -0,0 +1,211 @@
use std::collections::*;
use std::convert::TryFrom;
use std::default::Default;
use std::io;
use std::path::PathBuf;

use futures::prelude::*;

use ruma_identifiers::{self, RoomId};

use tokio::fs as tokiofs;
use tokio::prelude::*;

use toml;

// FIXME Some of these error types are only used in the main module.
#[derive(Debug)]
pub enum Error {
// TODO Reconcile these different parse errors.
ParseFile(PathBuf),
ParseToml(toml::de::Error),
ParseMalformed,
ParseIdentifier,
Io(io::Error),
}

impl From<io::Error> for Error {
fn from(f: io::Error) -> Self {
Self::Io(f)
}
}

impl From<toml::de::Error> for Error {
fn from(f: toml::de::Error) -> Self {
Self::ParseToml(f)
}
}

#[derive(Default, Debug)]
pub struct Config {
pub accounts: HashMap<String, Account>,
pub spool_dirs: Vec<SpoolDir>,
}

#[derive(Clone, Debug)]
pub struct Account {
pub homeserver: String,
pub display: Option<String>,
pub device_id: Option<String>,
pub auth: Auth,
}

#[derive(Clone, Debug)]
pub enum Auth {
UsernamePass(String, String),
}

#[derive(Debug)]
pub struct SpoolDir {
pub path: PathBuf,
pub send_delay_sec: u32,
pub sender_acct_label: String,
pub dest_room_id: RoomId,
}

pub async fn find_configs(search_dir: &PathBuf) -> Result<Vec<PathBuf>, Error> {
let items: Vec<tokiofs::DirEntry> = tokiofs::read_dir(search_dir).await?.try_collect().await?;
Ok(items
.into_iter()
.filter(|de| {
de.file_name()
.to_str()
.map(|s| s.ends_with(".toml"))
.unwrap_or(false)
})
.map(|e| e.path())
.collect())
}

pub async fn parse_configs(paths: Vec<PathBuf>) -> Result<Config, Error> {
let mut conf = Config::default();

for p in &paths {
println!("Reading config: {}", p.to_str().unwrap_or("[non-UTF-8]"));
let val = match load_toml(p).await {
Ok(t) => t,
Err(_) => {
println!("Error loading config, skipping");
continue;
}
};

match parse_toml(&val) {
Ok((accts, spools)) => {
conf.accounts.extend(accts);
conf.spool_dirs.extend(spools);
}
Err(_) => {
println!("Error processing config, skipping");
continue;
}
}
}

Ok(conf)
}

async fn load_toml(path: &PathBuf) -> Result<toml::Value, Error> {
let mut buf = Vec::new();
let mut f = tokiofs::File::open(path).await?;
let _ = f.read_to_end(&mut buf).await?;
toml::de::from_slice(buf.as_slice()).map_err(|_| Error::ParseFile(path.clone()))
}

fn parse_toml(val: &toml::Value) -> Result<(Vec<(String, Account)>, Vec<SpoolDir>), Error> {
use toml::Value::*;

let mut accts = Vec::new();
let mut spools = Vec::new();

match val {
Table(tab) => {
let acct_ents = tab.get("acct").cloned();
let watch_ents = tab.get("watch").cloned();

match acct_ents {
Some(Array(entries)) => {
for acct in &entries {
accts.push(parse_acct_entry(&acct)?);
}
}
// TODO
Some(_) => {}
_ => {}
}

match watch_ents {
Some(Array(entries)) => {
for wd in &entries {
spools.push(parse_watch_entry(&wd)?);
}
}
// TODO
Some(_) => {}
_ => {}
}
}
_ => {}
}

Ok((accts, spools))
}

fn parse_acct_entry(ent: &toml::Value) -> Result<(String, Account), Error> {
use toml::Value::*;
type StdString = ::std::string::String;

let label = ent.get("label");
let homeserver = ent.get("homeserver");
let display = ent.get("display");
let dev_id = ent.get("deviceid");
let username = ent.get("username");
let password = ent.get("password");

// This is gross and I don't like it, but ok.
match (label, homeserver, username, password) {
(Some(String(l)), Some(String(s)), Some(String(u)), Some(String(p))) => {
let auth = Auth::UsernamePass(u.clone(), p.clone());
Ok((
l.clone(),
Account {
homeserver: s.clone(),
display: display
.cloned()
.map(|v| v.try_into::<StdString>())
.transpose()?,
device_id: dev_id
.cloned()
.map(|v| v.try_into::<StdString>())
.transpose()?,
auth: auth,
},
))
}
_ => Err(Error::ParseMalformed),
}
}

fn parse_watch_entry(ent: &toml::Value) -> Result<SpoolDir, Error> {
use toml::Value::*;

let sender = ent.get("sender");
let path = ent.get("path");
let dest = ent.get("destroom");
let delay = ent.get("delay");

// Again this is gross, but whatever.
match (path, sender, dest) {
(Some(String(p)), Some(String(s)), Some(String(d))) => Ok(SpoolDir {
path: PathBuf::from(p.clone()),
send_delay_sec: delay
.cloned()
.map(|v| v.try_into::<u32>())
.transpose()?
.unwrap_or(0),
sender_acct_label: s.clone(),
dest_room_id: RoomId::try_from(d.as_str()).map_err(|_| Error::ParseIdentifier)?,
}),
_ => Err(Error::ParseMalformed),
}
}

+ 103
- 0
src/main.rs View File

@@ -0,0 +1,103 @@
//#![allow(unused)]
#![allow(incomplete_features)]
#![feature(impl_trait_in_bindings)]
#![feature(async_closure)]

mod config;
mod spool;

use std::path::PathBuf;

use clap::Clap;

use tokio::{self, runtime};

use crate::config::*;

#[derive(Clap)]
#[clap(version = "0.1")]
struct Opts {
#[clap(
name = "config",
short = "c",
help = "Read this config file by itself, parsed before -C"
)]
conf: Option<PathBuf>,

#[clap(
name = "configdir",
short = "C",
help = "Read all config files in this directory"
)]
conf_dir: Option<PathBuf>,

#[clap(
name = "triggerpath",
short = "r",
help = "Delete this file to trigger a config reload [NYI]"
)]
reload_trigger: Option<PathBuf>,
}

fn main() {
let opts = Opts::parse();
if opts.reload_trigger.is_some() {
println!("Reload trigger file specified, but this option is not supported yet. Ignoring.");
}

let mut rt = make_runtime();

// Figure out which files we want to configure.
let mut confs = Vec::new();
if let Some(main) = opts.conf {
confs.push(main.clone());
}
if let Some(dir) = opts.conf_dir {
match rt.block_on(find_configs(&dir)) {
Ok(paths) => confs.extend(paths),
Err(e) => {
println!("Error reading configuration: {:?}", e);
return;
}
}
}

// Sanity check.
if confs.len() == 0 {
println!("No configuration declared, exiting...");
return;
}

// Process configuration.
let config = match rt.block_on(parse_configs(confs)) {
Ok(c) => c,
Err(e) => {
println!("Error parsing configuration: {:?}", e);
return; // maybe
}
};

for s in &config.spool_dirs {
if s.send_delay_sec != 0 {
println!(
"Warning: send delay (as in watch on {:?}) are current not supported, ignoring",
s.path
);
}
}

// This is where the real stuff actually happens.
match rt.block_on(spool::start_spooling(config)) {
Ok(_) => {}
Err(e) => println!("fatal error: {:?}", e),
}
}

fn make_runtime() -> runtime::Runtime {
runtime::Builder::new()
.max_threads(1)
.enable_all()
.basic_scheduler()
.build()
.expect("init runtime")
}

+ 224
- 0
src/spool.rs View File

@@ -0,0 +1,224 @@
use std::collections::*;
use std::path::PathBuf;
use std::sync::Arc;

use futures::compat::Stream01CompatExt;
use futures::prelude::*;

use hyper::client::connect::dns::GaiResolver;
use hyper::client::connect::HttpConnector;
use hyper_tls::HttpsConnector;

use inotify::ffi::*;

use tokio::fs as tokiofs;
use tokio::prelude::*;

use tokio_inotify;

use url::Url;

use ruma_client::Client;
use ruma_client_api::r0::message as rumamessage;
use ruma_events::{self, room::message::*};
use ruma_identifiers::RoomId;

use crate::config::*;

type MatrixClient = Client<HttpsConnector<HttpConnector<GaiResolver>>>;
type MessageRequest = rumamessage::create_message_event::Request;

#[derive(Debug)]
pub enum Error {
FileFormatMismatch,
BadUrl,
Io(io::Error),
MtxClient(ruma_client::Error),
}

impl From<io::Error> for Error {
fn from(f: io::Error) -> Self {
Self::Io(f)
}
}

impl From<ruma_client::Error> for Error {
fn from(f: ruma_client::Error) -> Self {
Self::MtxClient(f)
}
}

#[derive(Clone)]
struct SpoolAction {
client: Arc<MatrixClient>,
room: RoomId,
delay_secs: u32,
watch_path: PathBuf,
}

pub async fn start_spooling(conf: Config) -> Result<(), Error> {
let ain = tokio_inotify::AsyncINotify::init().expect("inotify init");

let mut clients = HashMap::new();
let mut watch_map = HashMap::new();

for s in conf.spool_dirs {
let label = s.sender_acct_label;
let w = ain.add_watch(&s.path, IN_CLOSE_WRITE | IN_MOVED_TO)?;
println!("Added watch: {}", s.path.to_str().unwrap_or("[non-UTF-8]"));

// TODO Make this better.
let cli = if !clients.contains_key(&label) {
let acc = conf.accounts.get(&label).expect("missing account");
let cli = create_and_auth_client(acc.clone()).await?;
clients.insert(label, cli.clone());
cli
} else {
clients.get(&label).expect("missing account").clone()
};

let sa = SpoolAction {
client: cli,
room: s.dest_room_id,
delay_secs: s.send_delay_sec,
watch_path: s.path.clone(),
};

watch_map.insert(w, sa);
}

let watch_map = Arc::new(watch_map);

// This is horrible.
let _ = ain
.compat()
.map_err(Error::from)
.try_for_each({
|e| {
// Not sure why we have to do this clone here outside.
let wm = watch_map.clone();
async move {
// I don't like these clones but idk any better way.
let act = match wm.as_ref().get(&e.wd) {
Some(a) => a.clone(),
None => {
println!(
"got a wd that was not from a watch we added, ignoring: {:?}",
e.wd
);
return Ok(());
}
};

//let pb = e.name.clone();

// TODO This should be spawning a new task.
//tokio::spawn(async move {
// TODO Respect delay.
match process_file(&e.name, &act).await {
Ok(()) => {} // ok I guess?
Err(e) => println!("Error processing file: {:?}", e),
}
//});

Ok(())
}
}
})
.await?;

Ok(())
}

async fn process_file(p: &PathBuf, sa: &SpoolAction) -> Result<(), Error> {
let ext = match p.extension().map(|e| e.to_str()).flatten() {
Some(v) => v,
None => {
println!("Found weird file {:?}, ignoring", p);
return Ok(());
}
};

let name = p
.file_name()
.map(|e| e.to_str())
.flatten()
.unwrap_or("[non-UTF-8]");

// This makes me *mad*.
let mut real_path = sa.watch_path.clone();
real_path.push(p);

match ext {
"txt" => {
println!("Processing file for {} at {:?}", sa.room, p);

let buf = match file_as_string(&real_path).await {
Ok(v) => v,
Err(Error::FileFormatMismatch) => {
println!("File {} is not UTF-8, ignoring", name);
return Ok(());
}
Err(e) => return Err(e),
};

let mut rng = rand::thread_rng();
let req = make_text_request(sa.room.clone(), buf.as_str(), &mut rng);
match sa.client.as_ref().request(req).await {
Ok(_) => {
// Now delete it if it passed.
tokiofs::remove_file(real_path).await?;
}
Err(e) => println!("Error processing {}: {:?}", name, e),
}
}
_ => println!(
"Found file {:?}, but it has unsupported extension \"{}\"",
p, ext
),
}

Ok(())
}

async fn file_as_string(p: &PathBuf) -> Result<String, Error> {
let mut buf = Vec::new();
let mut f = tokiofs::File::open(p).await?;
f.read_to_end(&mut buf).await?;
String::from_utf8(buf).map_err(|_| Error::FileFormatMismatch)
}

async fn create_and_auth_client(acct: Account) -> Result<Arc<MatrixClient>, Error> {
let hs_url = Url::parse(&acct.homeserver).map_err(|_| Error::BadUrl)?;
let c = MatrixClient::https(hs_url, None);
match acct.auth {
Auth::UsernamePass(un, pw) => c.log_in(un, pw, acct.device_id, acct.display).await?,
};
Ok(Arc::new(c))
}

fn make_text_request<R: rand::Rng>(room_id: RoomId, msg: &str, rng: &mut R) -> MessageRequest {
let inner = TextMessageEventContent {
body: String::from(msg),
format: None,
formatted_body: None,
relates_to: None,
};
let mec = MessageEventContent::Text(inner);
MessageRequest {
room_id: room_id,
event_type: ruma_events::EventType::RoomMessage,
txn_id: make_txn_id(rng),
data: mec,
}
}

const TXN_ID_LEN: usize = 20;

fn make_txn_id<R: rand::Rng>(rng: &mut R) -> String {
let mut buf = String::with_capacity(TXN_ID_LEN);
for _ in 0..TXN_ID_LEN {
buf.push((rng.gen_range(0, 26) + ('a' as u8)) as char);
}
buf
}

Loading…
Cancel
Save