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