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}