main
Colby Knox Publish 0.1.0 fb6e12f 11d ago
//! Local settings: API keys and sender defaults.
//!
//! Stored in `<data dir>/settings.json`, readable only by the owner. Keys are
//! never returned to the browser (only a masked form) and are only ever sent
//! to their own provider's API. An environment variable, when set, wins over
//! the stored value so the studio can also be configured non-interactively.

use std::path::{Path, PathBuf};
use std::sync::RwLock;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize, Default, Clone)]
struct Stored {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    resend_api_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    sendgrid_api_key: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    default_from: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    default_reply_to: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    default_test_to: Option<String>,
}

/// A partial update from the settings form. `Some("")` clears a field.
#[derive(Debug, Deserialize, Default)]
pub struct Patch {
    pub resend_api_key: Option<String>,
    pub sendgrid_api_key: Option<String>,
    pub default_from: Option<String>,
    pub default_reply_to: Option<String>,
    pub default_test_to: Option<String>,
}

pub struct Settings {
    file: PathBuf,
    stored: RwLock<Stored>,
}

/// Where the studio keeps its database and settings: `RESEND_STUDIO_HOME`, else
/// the platform data directory.
pub fn data_dir() -> PathBuf {
    if let Some(dir) = env_nonempty("RESEND_STUDIO_HOME") {
        return PathBuf::from(dir);
    }
    let base = env_nonempty("XDG_DATA_HOME")
        .map(PathBuf::from)
        .or_else(|| env_nonempty("APPDATA").map(PathBuf::from))
        .or_else(|| env_nonempty("HOME").map(|h| Path::new(&h).join(".local").join("share")))
        .unwrap_or_else(|| PathBuf::from("."));
    base.join("adom-resend-studio")
}

pub fn env_nonempty(k: &str) -> Option<String> {
    std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}

/// `re_AbC...wxyz`: enough to recognize which key it is, not enough to use it.
pub fn mask(key: &str) -> String {
    let chars: Vec<char> = key.chars().collect();
    if chars.len() <= 10 {
        return "*".repeat(chars.len());
    }
    let head: String = chars[..5].iter().collect();
    let tail: String = chars[chars.len() - 4..].iter().collect();
    format!("{head}...{tail}")
}

/// Where a value came from, for the settings panel.
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Source {
    Env,
    Saved,
    None,
}

impl Settings {
    pub fn load(dir: &Path) -> Result<Self> {
        std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
        let file = dir.join("settings.json");
        let stored = match std::fs::read_to_string(&file) {
            Ok(s) => serde_json::from_str(&s).with_context(|| format!("parsing {}", file.display()))?,
            Err(_) => Stored::default(),
        };
        Ok(Self { file, stored: RwLock::new(stored) })
    }

    pub fn path(&self) -> &Path {
        &self.file
    }

    fn pick(&self, env: &str, get: impl Fn(&Stored) -> Option<String>) -> (Option<String>, Source) {
        if let Some(v) = env_nonempty(env) {
            return (Some(v), Source::Env);
        }
        match get(&self.stored.read().unwrap()).filter(|v| !v.trim().is_empty()) {
            Some(v) => (Some(v), Source::Saved),
            None => (None, Source::None),
        }
    }

    pub fn resend_key(&self) -> (Option<String>, Source) {
        self.pick("RESEND_API_KEY", |s| s.resend_api_key.clone())
    }

    pub fn sendgrid_key(&self) -> (Option<String>, Source) {
        self.pick("SENDGRID_API_KEY", |s| s.sendgrid_api_key.clone())
    }

    pub fn default_from(&self) -> String {
        self.pick("RESEND_STUDIO_FROM", |s| s.default_from.clone()).0.unwrap_or_default()
    }

    pub fn default_reply_to(&self) -> String {
        self.pick("RESEND_STUDIO_REPLY_TO", |s| s.default_reply_to.clone()).0.unwrap_or_default()
    }

    pub fn default_test_to(&self) -> String {
        self.pick("RESEND_STUDIO_TEST_TO", |s| s.default_test_to.clone()).0.unwrap_or_default()
    }

    pub fn apply(&self, patch: Patch) -> Result<()> {
        let mut next = self.stored.read().unwrap().clone();
        let set = |field: &mut Option<String>, value: Option<String>| {
            if let Some(v) = value {
                let v = v.trim().to_string();
                *field = if v.is_empty() { None } else { Some(v) };
            }
        };
        set(&mut next.resend_api_key, patch.resend_api_key);
        set(&mut next.sendgrid_api_key, patch.sendgrid_api_key);
        set(&mut next.default_from, patch.default_from);
        set(&mut next.default_reply_to, patch.default_reply_to);
        set(&mut next.default_test_to, patch.default_test_to);
        write_private(&self.file, &serde_json::to_string_pretty(&next)?)?;
        *self.stored.write().unwrap() = next;
        Ok(())
    }
}

/// Writes via a temp file + rename, created owner-only on unix.
fn write_private(path: &Path, contents: &str) -> Result<()> {
    use std::io::Write;
    let tmp = path.with_extension("json.tmp");
    let mut opts = std::fs::OpenOptions::new();
    opts.write(true).create(true).truncate(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        opts.mode(0o600);
    }
    let mut f = opts.open(&tmp).with_context(|| format!("writing {}", tmp.display()))?;
    f.write_all(contents.as_bytes())?;
    f.sync_all()?;
    drop(f);
    std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn masks_keys() {
        assert_eq!(mask("re_abcdefghijklmnop"), "re_ab...mnop");
        assert_eq!(mask("short"), "*****");
    }

    #[test]
    fn saves_and_clears() {
        let dir = std::env::temp_dir().join(format!("resend-studio-test-{}", std::process::id()));
        let s = Settings::load(&dir).unwrap();
        s.apply(Patch { default_from: Some("A <[email protected]>".into()), ..Default::default() }).unwrap();
        let again = Settings::load(&dir).unwrap();
        assert_eq!(again.stored.read().unwrap().default_from.as_deref(), Some("A <[email protected]>"));
        again.apply(Patch { default_from: Some(" ".into()), ..Default::default() }).unwrap();
        assert!(again.stored.read().unwrap().default_from.is_none());
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(again.path()).unwrap().permissions().mode();
            assert_eq!(mode & 0o777, 0o600);
        }
        let _ = std::fs::remove_dir_all(&dir);
    }
}