mirror of
https://github.com/samsonjs/agate.git
synced 2026-03-25 09:05:50 +00:00
240 lines
9.3 KiB
Rust
240 lines
9.3 KiB
Rust
use configparser::ini::Ini;
|
|
use glob::{MatchOptions, glob_with};
|
|
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 with the name given by `SIDECAR_FILENAME`.
|
|
///
|
|
/// These sidecar file's lines should have the format
|
|
/// ```text
|
|
/// <filename>:<metadata>
|
|
/// ```
|
|
/// where `<filename>` is only a filename (not a path) of a file that resides
|
|
/// in the same directory and `<metadata>` 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<PathBuf, SystemTime>,
|
|
/// Stores the metadata for each file
|
|
file_meta: BTreeMap<PathBuf, PresetMeta>,
|
|
/// 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: &Path) {
|
|
log::debug!("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.to_path_buf();
|
|
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 {path:?} starts like a full header line, but it is incorrect; ignoring it."
|
|
);
|
|
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 {path:?} has an invalid character, treating {separator:?} as a space."
|
|
);
|
|
}
|
|
let status = header
|
|
.chars()
|
|
.take(2)
|
|
.collect::<String>()
|
|
.parse::<u8>()
|
|
// 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::<String>();
|
|
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::<Vec<_>>(),
|
|
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 inconsistent 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)
|
|
}
|
|
}
|