1pub mod profiles;
2
3use std::env;
4
5use crate::{
6 client::{ServerUrl, WekanClientFactory},
7 error::{AppError, ErrorCode, ErrorDetails},
8 exit_code::StableExitCode,
9};
10
11use self::profiles::{
12 Profile, ProfileMutation, ProfileSnapshot, ProfileStore, canonical_server,
13 validate_profile_name,
14};
15
16const DEFAULT_PROFILE: &str = "default";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub(crate) enum MissingProfileResolution {
20 Reject,
21 Initialize,
22 LocalCredentialCleanup,
23}
24
25impl MissingProfileResolution {
26 pub(crate) const fn allows_initialization(self) -> bool {
27 matches!(self, Self::Initialize)
28 }
29}
30
31#[derive(Clone, Debug, Default)]
32pub struct ServerSelection {
33 pub explicit_server: Option<String>,
34 pub explicit_profile: Option<String>,
35 pub allow_insecure_http: bool,
36}
37
38impl ServerSelection {
39 pub fn new(
40 explicit_server: Option<String>,
41 explicit_profile: Option<String>,
42 allow_insecure_http: bool,
43 ) -> Self {
44 Self {
45 explicit_server,
46 explicit_profile,
47 allow_insecure_http,
48 }
49 }
50}
51
52#[derive(Clone, Debug, Default)]
53struct ServerEnvironment {
54 server: Option<String>,
55 profile: Option<String>,
56}
57
58impl ServerEnvironment {
59 fn read(selection: &ServerSelection) -> Result<Self, AppError> {
60 Ok(Self {
61 server: if selection.explicit_server.is_some() {
62 None
63 } else {
64 read_unicode_environment("WEKAN_URL")?
65 },
66 profile: if selection.explicit_profile.is_some() {
67 None
68 } else {
69 read_unicode_environment("WEKAN_PROFILE")?
70 },
71 })
72 }
73}
74
75fn read_unicode_environment(name: &str) -> Result<Option<String>, AppError> {
76 match env::var(name) {
77 Ok(value) => Ok(Some(value)),
78 Err(env::VarError::NotPresent) => Ok(None),
79 Err(env::VarError::NotUnicode(_)) => Err(AppError::configuration(format!(
80 "{name} must contain valid Unicode"
81 ))),
82 }
83}
84
85#[derive(Clone, Debug)]
86pub struct TargetResolver {
87 selection: ServerSelection,
88}
89
90impl TargetResolver {
91 pub const fn new(selection: ServerSelection) -> Self {
92 Self { selection }
93 }
94
95 pub fn has_explicit_target(&self) -> bool {
96 self.selection.explicit_server.is_some() || self.selection.explicit_profile.is_some()
97 }
98
99 pub(crate) fn resolve<'a>(
100 &self,
101 profile_store: &'a dyn ProfileStore,
102 missing_profile_resolution: MissingProfileResolution,
103 ) -> Result<ResolvedTarget<'a>, AppError> {
104 self.resolve_with_environment(
105 profile_store,
106 ServerEnvironment::read(&self.selection)?,
107 missing_profile_resolution,
108 )
109 }
110
111 fn resolve_with_environment<'a>(
112 &self,
113 profile_store: &'a dyn ProfileStore,
114 environment: ServerEnvironment,
115 missing_profile_resolution: MissingProfileResolution,
116 ) -> Result<ResolvedTarget<'a>, AppError> {
117 let selected_name = self.selected_name_before_active(&environment)?;
118 let snapshot = profile_store
119 .read()
120 .map_err(AppError::from)
121 .map_err(|error| with_optional_target_context(error, selected_name.clone()))?;
122 let name = selected_name.unwrap_or_else(|| {
123 snapshot
124 .document()
125 .active_profile()
126 .unwrap_or(DEFAULT_PROFILE)
127 .to_owned()
128 });
129 let supplied_server = self
130 .selection
131 .explicit_server
132 .as_ref()
133 .or(environment.server.as_ref())
134 .map(|raw| canonical_server(raw))
135 .transpose()
136 .map_err(AppError::from)
137 .map_err(|error| with_target_context(error, &name))?;
138 let credential_namespace = profile_store
139 .credential_namespace()
140 .map_err(AppError::from)
141 .map_err(|error| with_target_context(error, &name))?;
142
143 if let Some(profile) = snapshot.document().profile(&name) {
144 let server = verified_server(&name, profile.server(), supplied_server.as_deref())?;
145 let active = snapshot.document().active_profile() == Some(name.as_str());
146 return Ok(ResolvedTarget::existing(
147 server,
148 name,
149 credential_namespace,
150 active,
151 self.selection.allow_insecure_http,
152 snapshot,
153 ));
154 }
155
156 let Some(server) = supplied_server else {
157 return Err(profile_not_found(&name));
158 };
159
160 match missing_profile_resolution {
161 MissingProfileResolution::Reject => return Err(profile_not_found(&name)),
162 MissingProfileResolution::LocalCredentialCleanup => {
163 let server = ServerUrl::parse(&server)
164 .map_err(AppError::from)
165 .map_err(|error| with_target_context(error, &name))?;
166 return Ok(ResolvedTarget::missing_profile_cleanup(
167 server,
168 name,
169 credential_namespace,
170 self.selection.allow_insecure_http,
171 snapshot,
172 ));
173 }
174 MissingProfileResolution::Initialize => {}
175 }
176
177 drop(snapshot);
178 let mutation = profile_store
179 .lock_mutation()
180 .map_err(AppError::from)
181 .map_err(|error| with_target_context(error, &name))?;
182 if let Some(profile) = mutation.document().profile(&name) {
183 let server = verified_server(&name, profile.server(), Some(&server))?;
184 let active = mutation.document().active_profile() == Some(name.as_str());
185 return Ok(ResolvedTarget::under_mutation(
186 server,
187 name,
188 credential_namespace,
189 active,
190 false,
191 self.selection.allow_insecure_http,
192 mutation,
193 ));
194 }
195
196 let server = ServerUrl::parse(&server)
197 .map_err(AppError::from)
198 .map_err(|error| with_target_context(error, &name))?;
199 Ok(ResolvedTarget::under_mutation(
200 server,
201 name,
202 credential_namespace,
203 false,
204 true,
205 self.selection.allow_insecure_http,
206 mutation,
207 ))
208 }
209
210 fn selected_name_before_active(
211 &self,
212 environment: &ServerEnvironment,
213 ) -> Result<Option<String>, AppError> {
214 if let Some(name) = &self.selection.explicit_profile {
215 validate_profile_name(name)
216 .map_err(AppError::invalid_input)
217 .map_err(|error| with_target_context(error, name))?;
218 return Ok(Some(name.clone()));
219 }
220 if let Some(name) = &environment.profile {
221 validate_profile_name(name)
222 .map_err(|message| {
223 AppError::configuration(format!("WEKAN_PROFILE is invalid: {message}"))
224 })
225 .map_err(|error| with_target_context(error, name))?;
226 return Ok(Some(name.clone()));
227 }
228 Ok(None)
229 }
230}
231
232fn verified_server(
233 name: &str,
234 stored_server: &str,
235 supplied_server: Option<&str>,
236) -> Result<ServerUrl, AppError> {
237 if supplied_server.is_some_and(|supplied| supplied != stored_server) {
238 return Err(AppError::new(
239 ErrorCode::ProfileServerMismatch,
240 format!(
241 "the supplied server URL does not match profile `{name}`; the stored profile was not changed"
242 ),
243 StableExitCode::Configuration,
244 )
245 .with_details(ErrorDetails {
246 profile: Some(name.to_owned()),
247 ..ErrorDetails::default()
248 }));
249 }
250 ServerUrl::parse(stored_server)
251 .map_err(AppError::from)
252 .map_err(|error| with_target_context(error, name))
253}
254
255fn profile_not_found(name: &str) -> AppError {
256 AppError::new(
257 ErrorCode::ProfileNotFound,
258 format!(
259 "profile `{name}` does not exist; add it with `wekan profile add`, supply --server to auth login or auth register, or run `wekan --server <URL> --profile {name} auth logout --local-only --yes` to clear an orphaned local credential"
260 ),
261 StableExitCode::Configuration,
262 )
263 .with_details(ErrorDetails {
264 profile: Some(name.to_owned()),
265 ..ErrorDetails::default()
266 })
267}
268
269fn with_target_context(error: AppError, profile: &str) -> AppError {
270 if error.details().profile.is_some() {
271 error
272 } else {
273 error.with_profile_context(profile.to_owned())
274 }
275}
276
277fn with_optional_target_context(error: AppError, profile: Option<String>) -> AppError {
278 match profile {
279 Some(profile) => with_target_context(error, &profile),
280 None => error,
281 }
282}
283
284enum ProfileGuard<'a> {
285 Existing {
286 _snapshot: ProfileSnapshot,
287 },
288 Mutation {
289 mutation: Box<dyn ProfileMutation + Send + 'a>,
290 needs_creation: bool,
291 },
292}
293
294pub struct ResolvedTarget<'a> {
295 client_factory: WekanClientFactory,
296 profile: String,
297 credential_namespace: String,
298 profile_created: bool,
299 profile_active: bool,
300 guard: ProfileGuard<'a>,
301}
302
303impl<'a> ResolvedTarget<'a> {
304 fn existing(
305 server: ServerUrl,
306 profile: String,
307 credential_namespace: String,
308 active: bool,
309 allow_insecure_http: bool,
310 snapshot: ProfileSnapshot,
311 ) -> Self {
312 Self::with_snapshot(
313 server,
314 profile,
315 credential_namespace,
316 active,
317 allow_insecure_http,
318 snapshot,
319 )
320 }
321
322 fn missing_profile_cleanup(
323 server: ServerUrl,
324 profile: String,
325 credential_namespace: String,
326 allow_insecure_http: bool,
327 snapshot: ProfileSnapshot,
328 ) -> Self {
329 Self::with_snapshot(
330 server,
331 profile,
332 credential_namespace,
333 false,
334 allow_insecure_http,
335 snapshot,
336 )
337 }
338
339 fn with_snapshot(
340 server: ServerUrl,
341 profile: String,
342 credential_namespace: String,
343 active: bool,
344 allow_insecure_http: bool,
345 snapshot: ProfileSnapshot,
346 ) -> Self {
347 Self {
348 client_factory: WekanClientFactory::for_resolved_profile(
349 server,
350 profile.clone(),
351 credential_namespace.clone(),
352 allow_insecure_http,
353 ),
354 profile,
355 credential_namespace,
356 profile_created: false,
357 profile_active: active,
358 guard: ProfileGuard::Existing {
359 _snapshot: snapshot,
360 },
361 }
362 }
363
364 #[allow(clippy::too_many_arguments)]
365 fn under_mutation(
366 server: ServerUrl,
367 profile: String,
368 credential_namespace: String,
369 active: bool,
370 needs_creation: bool,
371 allow_insecure_http: bool,
372 mutation: Box<dyn ProfileMutation + Send + 'a>,
373 ) -> Self {
374 Self {
375 client_factory: WekanClientFactory::for_resolved_profile(
376 server,
377 profile.clone(),
378 credential_namespace.clone(),
379 allow_insecure_http,
380 ),
381 profile,
382 credential_namespace,
383 profile_created: false,
384 profile_active: active,
385 guard: ProfileGuard::Mutation {
386 mutation,
387 needs_creation,
388 },
389 }
390 }
391
392 pub const fn client_factory(&self) -> &WekanClientFactory {
393 &self.client_factory
394 }
395
396 pub fn profile(&self) -> &str {
397 &self.profile
398 }
399
400 pub fn credential_namespace(&self) -> &str {
401 &self.credential_namespace
402 }
403
404 pub const fn profile_created(&self) -> bool {
405 self.profile_created
406 }
407
408 pub const fn profile_active(&self) -> bool {
409 self.profile_active
410 }
411
412 pub fn initialize_profile(&mut self) -> Result<(), AppError> {
413 let profile = self.profile.clone();
414 let ProfileGuard::Mutation {
415 mutation,
416 needs_creation,
417 } = &mut self.guard
418 else {
419 return Ok(());
420 };
421 if !*needs_creation {
422 return Ok(());
423 }
424
425 let mut document = mutation.document().clone();
426 let active = document.is_empty();
427 document.insert(
428 self.profile.clone(),
429 Profile::new(self.client_factory.server_identity().as_str().to_owned()),
430 );
431 if active {
432 document.set_active_profile(Some(self.profile.clone()));
433 }
434 if let Err(error) = mutation.save(document) {
435 if error.profile_was_installed() {
436 *needs_creation = false;
437 self.profile_created = true;
438 self.profile_active = active;
439 }
440 return Err(with_target_context(AppError::from(error), &profile)
441 .with_profile_state(self.profile_created, self.profile_active));
442 }
443 *needs_creation = false;
444 self.profile_created = true;
445 self.profile_active = active;
446 Ok(())
447 }
448}
449
450#[cfg(test)]
451impl ResolvedTarget<'static> {
452 pub(crate) fn for_test(client_factory: &WekanClientFactory) -> Self {
453 Self {
454 client_factory: WekanClientFactory::for_resolved_profile(
455 client_factory.server_identity().clone(),
456 client_factory
457 .profile()
458 .unwrap_or(DEFAULT_PROFILE)
459 .to_owned(),
460 client_factory
461 .profile_store_namespace()
462 .unwrap_or("test-store")
463 .to_owned(),
464 false,
465 ),
466 profile: client_factory
467 .profile()
468 .unwrap_or(DEFAULT_PROFILE)
469 .to_owned(),
470 credential_namespace: client_factory
471 .profile_store_namespace()
472 .unwrap_or("test-store")
473 .to_owned(),
474 profile_created: false,
475 profile_active: false,
476 guard: ProfileGuard::Existing {
477 _snapshot: ProfileSnapshot::new_unlocked(Default::default()),
478 },
479 }
480 }
481}
482
483#[cfg(test)]
484mod tests;