Skip to main content

paperless_cli/
config.rs

1use std::fmt;
2use std::fs::{self, OpenOptions};
3use std::io::Write;
4use std::path::{Path, PathBuf};
5
6use clap::ValueEnum;
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use crate::error::AppError;
11
12#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, ValueEnum)]
13#[serde(rename_all = "lowercase")]
14pub enum OutputMode {
15    Json,
16    #[default]
17    Markdown,
18    Tui,
19}
20
21#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
22pub struct AppConfig {
23    pub base_url: String,
24    pub token: String,
25    #[serde(default)]
26    pub preferred_output: OutputMode,
27}
28
29impl fmt::Debug for AppConfig {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        formatter
32            .debug_struct("AppConfig")
33            .field("base_url", &self.base_url)
34            .field("token", &self.masked_token())
35            .field("preferred_output", &self.preferred_output)
36            .finish()
37    }
38}
39
40impl AppConfig {
41    pub fn new(
42        base_url: impl Into<String>,
43        token: impl Into<String>,
44        preferred_output: OutputMode,
45    ) -> Result<Self, AppError> {
46        let base_url = normalize_url(&base_url.into())?;
47        let token = token.into().trim().to_string();
48        if token.is_empty() {
49            return Err(AppError::MissingCredentials);
50        }
51
52        Ok(Self {
53            base_url,
54            token,
55            preferred_output,
56        })
57    }
58
59    pub fn api_url(&self, path: &str) -> String {
60        let suffix = path.trim_start_matches('/');
61        format!("{}/api/{}", self.base_url.trim_end_matches('/'), suffix)
62    }
63
64    pub fn masked_token(&self) -> String {
65        let total_chars = self.token.chars().count();
66        if total_chars <= 8 {
67            return "***".to_string();
68        }
69
70        let prefix = self.token.chars().take(8).collect::<String>();
71        format!("{prefix}...")
72    }
73}
74
75#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
76pub struct SessionState {
77    #[serde(default)]
78    pub last_query: String,
79    #[serde(default)]
80    pub selected_docs: Vec<u64>,
81    #[serde(default)]
82    pub history: Vec<String>,
83}
84
85impl SessionState {
86    pub fn push_history(&mut self, command: impl Into<String>) {
87        self.history.push(command.into());
88        if self.history.len() > 500 {
89            let drain = self.history.len() - 500;
90            self.history.drain(0..drain);
91        }
92    }
93}
94
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct AppPaths {
97    pub config_path: PathBuf,
98    pub session_path: PathBuf,
99}
100
101impl Default for AppPaths {
102    fn default() -> Self {
103        let config_root = std::env::var_os("PAPERLESS_CONFIG_PATH")
104            .or_else(|| std::env::var_os("PAPERLESS_CLI_CONFIG_PATH"))
105            .map(PathBuf::from)
106            .unwrap_or_else(|| {
107                dirs::config_dir()
108                    .unwrap_or_else(std::env::temp_dir)
109                    .join("paperless-cli")
110                    .join("config.toml")
111            });
112
113        let session_root = std::env::var_os("PAPERLESS_SESSION_PATH")
114            .or_else(|| std::env::var_os("PAPERLESS_CLI_SESSION_PATH"))
115            .map(PathBuf::from)
116            .unwrap_or_else(|| {
117                dirs::state_dir()
118                    .unwrap_or_else(std::env::temp_dir)
119                    .join("paperless-cli")
120                    .join("session.toml")
121            });
122
123        Self {
124            config_path: config_root,
125            session_path: session_root,
126        }
127    }
128}
129
130impl AppPaths {
131    pub fn new(config_path: impl Into<PathBuf>, session_path: impl Into<PathBuf>) -> Self {
132        Self {
133            config_path: config_path.into(),
134            session_path: session_path.into(),
135        }
136    }
137
138    pub fn config_permissions_restricted(&self) -> bool {
139        restricted_permissions(&self.config_path)
140    }
141}
142
143pub fn normalize_url(raw: &str) -> Result<String, AppError> {
144    let trimmed = raw.trim().trim_end_matches('/');
145    let parsed = Url::parse(trimmed).map_err(|_| AppError::InvalidUrl(raw.to_string()))?;
146
147    match parsed.scheme() {
148        "https" => Ok(trimmed.to_string()),
149        "http" if is_loopback_host(parsed.host_str()) => Ok(trimmed.to_string()),
150        "http" => Err(AppError::InsecureRemoteUrl(trimmed.to_string())),
151        _ => Err(AppError::InvalidUrl(raw.to_string())),
152    }
153}
154
155pub fn load_config(paths: &AppPaths) -> Result<AppConfig, AppError> {
156    let path = &paths.config_path;
157    let persisted = if path.exists() {
158        let raw = fs::read_to_string(path)?;
159        Some(
160            toml::from_str::<AppConfig>(&raw)
161                .or_else(|_| serde_json::from_str::<LegacyConfig>(&raw).map(AppConfig::from))
162                .map_err(|error| AppError::ConfigMalformed {
163                    path: path.clone(),
164                    reason: error.to_string(),
165                })?,
166        )
167    } else {
168        None
169    };
170
171    let base_url = env_value("PAPERLESS_URL")
172        .or_else(|| persisted.as_ref().map(|config| config.base_url.clone()));
173    let token = env_value("PAPERLESS_TOKEN")
174        .or_else(|| persisted.as_ref().map(|config| config.token.clone()));
175    let preferred_output = persisted
176        .as_ref()
177        .map(|config| config.preferred_output)
178        .unwrap_or_default();
179
180    match (base_url, token) {
181        (Some(base_url), Some(token)) => AppConfig::new(base_url, token, preferred_output),
182        (None, None) => Err(AppError::ConfigMissing),
183        _ => Err(AppError::Message(
184            "Missing Paperless configuration. Set both `PAPERLESS_URL` and `PAPERLESS_TOKEN`, or run `paperless login`.".to_string(),
185        )),
186    }
187}
188
189pub fn save_config(paths: &AppPaths, config: &AppConfig) -> Result<(), AppError> {
190    if let Some(parent) = paths.config_path.parent() {
191        fs::create_dir_all(parent)?;
192    }
193
194    write_private_atomic(&paths.config_path, &toml::to_string_pretty(config)?)?;
195    Ok(())
196}
197
198pub fn load_session(paths: &AppPaths) -> SessionState {
199    let path = &paths.session_path;
200    let Some(raw) = fs::read_to_string(path).ok() else {
201        return SessionState::default();
202    };
203
204    toml::from_str::<SessionState>(&raw).unwrap_or_default()
205}
206
207pub fn save_session(paths: &AppPaths, session: &SessionState) -> Result<(), AppError> {
208    if let Some(parent) = paths.session_path.parent() {
209        fs::create_dir_all(parent)?;
210    }
211
212    write_private_atomic(&paths.session_path, &toml::to_string_pretty(session)?)?;
213    Ok(())
214}
215
216fn write_private_atomic(path: &Path, contents: &str) -> Result<(), AppError> {
217    let temp_path = path.with_extension("tmp");
218    let mut options = OpenOptions::new();
219    options.write(true).create(true).truncate(true);
220
221    #[cfg(unix)]
222    {
223        use std::os::unix::fs::OpenOptionsExt;
224        options.mode(0o600);
225    }
226
227    let mut file = options.open(&temp_path)?;
228    file.write_all(contents.as_bytes())?;
229    file.flush()?;
230    drop(file);
231
232    restrict_permissions(&temp_path)?;
233    fs::rename(&temp_path, path)?;
234    restrict_permissions(path)?;
235    Ok(())
236}
237
238fn restrict_permissions(path: &Path) -> Result<(), AppError> {
239    #[cfg(unix)]
240    {
241        use std::os::unix::fs::PermissionsExt;
242
243        let permissions = fs::Permissions::from_mode(0o600);
244        fs::set_permissions(path, permissions)?;
245    }
246
247    Ok(())
248}
249
250fn is_loopback_host(host: Option<&str>) -> bool {
251    matches!(host, Some("localhost") | Some("127.0.0.1") | Some("::1"))
252}
253
254fn restricted_permissions(path: &Path) -> bool {
255    #[cfg(unix)]
256    {
257        use std::os::unix::fs::PermissionsExt;
258
259        fs::metadata(path)
260            .map(|metadata| metadata.permissions().mode() & 0o077 == 0)
261            .unwrap_or(false)
262    }
263
264    #[cfg(not(unix))]
265    {
266        path.exists()
267    }
268}
269
270fn env_value(name: &str) -> Option<String> {
271    std::env::var(name)
272        .ok()
273        .map(|value| value.trim().to_string())
274        .filter(|value| !value.is_empty())
275}
276
277#[derive(Clone, Debug, Deserialize)]
278struct LegacyConfig {
279    url: String,
280    token: String,
281}
282
283impl From<LegacyConfig> for AppConfig {
284    fn from(value: LegacyConfig) -> Self {
285        Self {
286            base_url: value.url.trim_end_matches('/').to_string(),
287            token: value.token,
288            preferred_output: OutputMode::Markdown,
289        }
290    }
291}