use configparser::ini::Ini; use glob::{glob_with, MatchOptions}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::SystemTime; static SIDECAR_FILENAME: &str = ".meta"; /// A struct to store a string of metadata for each file retrieved from /// sidecar files called `.lang`. /// /// These sidecar file's lines should have the format /// ```text /// : /// ``` /// where `` is only a filename (not a path) of a file that resides /// in the same directory and `` is the metadata to be stored. /// Lines that start with optional whitespace and `#` are ignored, as are lines /// that do not fit the basic format. /// Both parts are stripped of any leading and/or trailing whitespace. pub(crate) struct FileOptions { /// Stores the paths of the side files and when they were last read. /// By comparing this to the last write time, we can know if the file /// has changed. databases_read: BTreeMap, /// Stores the metadata for each file file_meta: BTreeMap, /// The default value to return default: PresetMeta, } /// A struct to store the different alternatives that a line in the sidecar /// file can have. #[derive(Clone, Debug)] pub(crate) enum PresetMeta { /// A line that starts with a semicolon in the sidecar file, or an /// empty line (to overwrite the default language command line flag). /// ```text /// index.gmi: ;lang=en-GB /// ``` /// The content is interpreted as MIME parameters and are appended to what /// agate guesses as the MIME type if the respective file can be found. Parameters(String), /// A line that is neither a `Parameters` line nor a `FullHeader` line. /// ```text /// strange.file: text/plain; lang=ee /// ``` /// Agate will send the complete line as the MIME type of the request if /// the respective file can be found (i.e. a `20` status code). FullMime(String), /// A line that starts with a digit between 1 and 6 inclusive followed by /// another digit and a space (U+0020). In the categories defined by the /// Gemini specification you can pick a defined or non-defined status code. /// ```text /// gone.gmi: 52 This file is no longer available. /// ``` /// Agate will send this header line, CR, LF, and nothing else. Agate will /// not try to access the requested file. FullHeader(u8, String), } impl FileOptions { pub(crate) fn new(default: PresetMeta) -> Self { Self { databases_read: BTreeMap::new(), file_meta: BTreeMap::new(), default, } } /// Checks wether the database for the directory of the specified file is /// still up to date and re-reads it if outdated or not yet read. fn update(&mut self, file: &Path) { let mut db = if super::ARGS.central_config { super::ARGS.content_dir.clone() } else { file.parent().expect("no parent directory").to_path_buf() }; db.push(SIDECAR_FILENAME); let should_read = if let Ok(metadata) = db.metadata() { if !metadata.is_file() { // it exists, but it is a directory false } else if let (Ok(modified), Some(last_read)) = (metadata.modified(), self.databases_read.get(&db)) { // check that it was last modified before the read // if the times are the same, we might have read the old file &modified >= last_read } else { // either the filesystem does not support last modified // metadata, so we have to read it again every time; or the // file exists but was not read before, so we have to read it true } } else { // the file probably does not exist false }; if should_read { self.read_database(&db); } } /// (Re)reads a specified sidecar file. /// This function will allways try to read the file, even if it is current. fn read_database(&mut self, db: &PathBuf) { log::trace!("reading database {:?}", db); let mut ini = Ini::new_cs(); ini.set_default_section("mime"); ini.set_comment_symbols(&['#']); let map = ini .load(db.to_str().expect("config path not UTF-8")) .and_then(|mut sections| { sections .remove("mime") .ok_or_else(|| "no \"mime\" or default section".to_string()) }); self.databases_read .insert(db.to_path_buf(), SystemTime::now()); let files = match map { Ok(section) => section, Err(err) => { log::error!("invalid config file {:?}: {}", db, err); return; } }; for (rel_path, header) in files { // treat unassigned keys as if they had an empty value let header = header.unwrap_or_default(); // generate workspace-relative path let mut path = db.clone(); path.pop(); path.push(rel_path); // parse the preset let preset = if header.is_empty() || header.starts_with(';') { PresetMeta::Parameters(header.to_string()) } else if matches!(header.chars().next(), Some('1'..='6')) { if header.len() < 3 || !header.chars().nth(1).unwrap().is_ascii_digit() || !header.chars().nth(2).unwrap().is_whitespace() { log::error!("Line for {:?} starts like a full header line, but it is incorrect; ignoring it.", path); return; } let separator = header.chars().nth(2).unwrap(); if separator != ' ' { // the Gemini specification says that the third // character has to be a space, so correct any // other whitespace to it (e.g. tabs) log::warn!("Full Header line for {:?} has an invalid character, treating {:?} as a space.", path, separator); } let status = header .chars() .take(2) .collect::() .parse::() // unwrap since we alread checked it's a number .unwrap(); // not taking a slice here because the separator // might be a whitespace wider than a byte let meta = header.chars().skip(3).collect::(); PresetMeta::FullHeader(status, meta) } else { // must be a MIME type, but without status code PresetMeta::FullMime(header.to_string()) }; let glob_options = MatchOptions { case_sensitive: true, // so there is a difference between "*" and "**". require_literal_separator: true, // security measure because entries for .hidden files // would result in them being exposed. require_literal_leading_dot: !crate::ARGS.serve_secret, }; // process filename as glob let paths = if let Some(path) = path.to_str() { match glob_with(path, glob_options) { Ok(paths) => paths.collect::>(), Err(err) => { log::error!("incorrect glob pattern in {:?}: {}", path, err); continue; } } } else { log::error!("path is not UTF-8: {:?}", path); continue; }; if paths.is_empty() { // probably an entry for a nonexistent file, glob only works for existing files self.file_meta.insert(path, preset); } else { for glob_result in paths { match glob_result { Ok(path) if path.is_dir() => { /* ignore */ } Ok(path) => { self.file_meta.insert(path, preset.clone()); } Err(err) => { log::warn!("could not process glob path: {}", err); continue; } }; } } } } /// Get the metadata for the specified file. This might need to (re)load a /// single sidecar file. /// The file path should consistenly be either absolute or relative to the /// working/content directory. If inconsisten file paths are used, this can /// lead to loading and storing sidecar files multiple times. pub fn get(&mut self, file: &Path) -> PresetMeta { self.update(file); self.file_meta.get(file).unwrap_or(&self.default).clone() } /// Returns true if a configuration exists in a configuration file. /// Returns false if no or only the default value exists. pub fn exists(&mut self, file: &Path) -> bool { self.update(file); self.file_meta.contains_key(file) } }