mod attachment; pub mod response; pub mod source; mod template; use anyhow::{Result, bail}; use attachment::Attachment; use chrono::{DateTime, Utc}; use response::{Clean, Sync, change::Change}; use source::Source; use std::{ collections::{HashMap, HashSet}, fs::{self, Permissions}, path::{MAIN_SEPARATOR, PathBuf}, str::FromStr, }; use template::Template; pub struct Nex { attachment: Attachment, file_permissions: Option, filename: String, is_cleanup: bool, is_debug: bool, is_filesystem_sync: bool, template: Template, users: HashMap, } impl Nex { pub fn init(config: &crate::Config) -> Result { // validate filename if config .format_content .trim_matches(MAIN_SEPARATOR) .contains(MAIN_SEPARATOR) { bail!("File name should not contain subdir `{MAIN_SEPARATOR}` separator!") } // init data export location let target = PathBuf::from_str(&config.target)?.canonicalize()?; if !target.is_dir() { bail!("Target location is not directory!") } // init locations for each user let mut users = HashMap::with_capacity(config.user.len()); for u in &config.user { let mut p = PathBuf::from(&target); p.push(u); fs::create_dir_all(&p)?; users.insert(u.clone(), p); } // init document template formatter let template = Template::init(config); Ok(Self { attachment: Attachment::init(config.attachment.as_ref())?, file_permissions: { #[cfg(any(target_os = "linux", target_os = "macos"))] { use std::{fs::Permissions, os::unix::fs::PermissionsExt}; Some(Permissions::from_mode( config.filesystem_file_permissions.unwrap_or(0o644), )) } #[cfg(not(any(target_os = "linux", target_os = "macos",)))] { None // @TODO } }, filename: config.format_filename.clone(), is_cleanup: !config.keep, is_debug: !config.daemon, is_filesystem_sync: config.filesystem_sync, template, users, }) } // Sync Snac post with Nex entry pub fn sync( &self, name: &str, content: String, link: String, attachments: Option>, tags: Option>, (published, updated): (DateTime, Option>), ) -> Result { // collect existing entries to ignore on cleanup let mut index = if self.is_cleanup { Some(Vec::with_capacity(10)) // includes est. attachments len } else { None }; // prepare destination let root = PathBuf::from(self.users.get(name).unwrap()); let mut p = PathBuf::from(&root); if let Some(ref mut i) = index { i.push(root) } p.push(published.format("%Y").to_string()); if let Some(ref mut i) = index { i.push(p.clone()) } p.push(published.format("%m").to_string()); if let Some(ref mut i) = index { i.push(p.clone()) } p.push(published.format("%d").to_string()); if let Some(ref mut i) = index { i.push(p.clone()) } fs::create_dir_all(&p)?; // init shared post ID once let id = published .format(self.filename.trim_matches('/')) .to_string(); // init post filepath let mut f = PathBuf::from(&p); f.push(&id); // init storage directory for the post state file and attachments // * by the current implementation, its name starts with dot // as usually hidden but accessible (in servers like Nexy) let mut d = PathBuf::from(&p); d.push(format!(".{id}")); fs::create_dir_all(&d)?; // create system meta file to track post time updated // if this meta file exists and has timestamp changed, also cleanup attachment files to update let mut s = PathBuf::from(&d); s.push(".state"); let timestamp = updated.unwrap_or(published); let state = timestamp.to_string(); if !fs::read_to_string(&s).is_ok_and(|this| this == state) { fs::remove_dir_all(&d)?; fs::create_dir_all(&d)?; self.persist(&s, ×tamp, state.as_bytes())?; } else { if let Some(ref mut i) = index { if let Some(ref a) = attachments { for (n, (_, _, source)) in a.iter().enumerate() { match source { Source::File(f) => i.push(attachment::filepath(&d, f, n)), _ => continue, } } } i.extend([d, f, p, s]) } return Ok(Sync { change: Change::Ignored, keep: index, }); } // save post file, set its change status let change = self.persist( &f, ×tamp, self.template .build( updated, content, link, tags, attachments.map(|a| { let mut b = Vec::with_capacity(a.len()); for (n, (name, media_type, source)) in a.into_iter().enumerate() { b.push(( match source { Source::Url(url) => url, Source::File(from) => { let to = attachment::filepath(&d, &from, n); let uri = format!( "{}/{}", d.file_name().unwrap().to_string_lossy(), to.file_name().unwrap().to_string_lossy() ); self.attachment .sync( &from, &to, if self.is_filesystem_sync { Some(timestamp.into()) } else { None }, self.file_permissions.clone(), ) .unwrap(); if let Some(ref mut i) = index { i.push(to); } uri } }, { let mut alt = Vec::with_capacity(2); if !name.is_empty() { alt.push(name) } if !media_type.is_empty() { alt.push(format!("({media_type})")) } if alt.is_empty() { None } else { Some(alt.join(" ")) } }, )) } b }), ) .as_bytes(), )?; // move all paths processed to cleanup ignore if let Some(ref mut i) = index { i.extend([d, f, p, s]); } Ok(Sync { change, keep: index, }) } pub fn clean(&self, name: &str, ignore: HashSet) -> Result { if self.is_debug { println!("\t\tcleanup..."); } let mut r = Clean::default(); for entry in walkdir::WalkDir::new(PathBuf::from(self.users.get(name).unwrap())).follow_links(false) { let e = entry?; let p = e.path(); let s = p.to_string_lossy(); if self.is_debug { println!("\t\tcheck `{s}`..."); } if ignore.contains(p) { continue; } let m = fs::metadata(p)?; if m.is_file() { fs::remove_file(p)?; r.files += 1; if self.is_debug { println!("\t\t\tdelete file `{s}`"); } } else if m.is_dir() { fs::remove_dir_all(p)?; r.directories += 1; if self.is_debug { println!("\t\t\tdelete directory `{s}`"); } } else { panic!() } } Ok(r) } pub fn is_attachments_disabled(&self) -> bool { matches!(self.attachment, Attachment::Disabled) } pub fn is_cleanup(&self) -> bool { self.is_cleanup } /// Wrapper function to change time updated for new file, according to the Snac time. fn persist(&self, path: &PathBuf, modified: &DateTime, data: &[u8]) -> Result { use std::io::Write; let status = if fs::exists(path)? { Change::Updated } else { Change::Created }; let mut f = fs::File::create(path)?; f.write_all(data)?; if self.is_filesystem_sync { f.set_modified((*modified).into())?; // it's important to call after write } if let Some(ref p) = self.file_permissions { f.set_permissions(p.clone())? } Ok(status) } }