1use super::input;
2use crate::client::admin::{
3 BatchSize, CpuThreshold, DelayMs, GcsCredentials, NonNegativeNumber, PreparedSettingsUpdate,
4 SettingNumber, StorageSecret, attachment_settings::*,
5};
6use crate::{
7 client::WekanClientFactory,
8 command_result::*,
9 commands::{
10 authenticated::AuthenticatedContext,
11 client_error::{map_client_error, map_partial_mutation_client_error_with_not_found},
12 },
13 credentials::{CredentialStore, SecretInputProvider},
14 error::AppError,
15 redaction::Redactor,
16};
17use clap::{ArgGroup, Args, Subcommand};
18#[derive(Debug, Args)]
19pub struct AttachmentSettingsArgs {
20 #[command(subcommand)]
21 pub command: AttachmentSettingsCommand,
22}
23#[derive(Debug, Subcommand)]
24pub enum AttachmentSettingsCommand {
25 Get,
27 Update(Box<UpdateArgs>),
29}
30#[derive(Debug, Args)]
31#[command(group(ArgGroup::new("credential_stdin").args([
32 "s3_secret_access_key_stdin",
33 "azure_account_key_stdin",
34 "azure_connection_string_stdin",
35 "gcs_credentials_stdin",
36]).multiple(false)), group(ArgGroup::new("setting_flags").args([
37 "default_storage",
38 "filesystem_enabled",
39 "filesystem_read",
40 "filesystem_write",
41 "filesystem_path",
42 "gridfs_enabled",
43 "gridfs_read",
44 "gridfs_write",
45 "s3_enabled",
46 "s3_read",
47 "s3_write",
48 "s3_endpoint",
49 "s3_region",
50 "s3_bucket",
51 "s3_access_key_id",
52 "s3_force_path_style",
53 "azure_enabled",
54 "azure_read",
55 "azure_write",
56 "azure_account_name",
57 "azure_bucket",
58 "gcs_enabled",
59 "gcs_read",
60 "gcs_write",
61 "gcs_project_id",
62 "gcs_key_filename",
63 "gcs_bucket",
64 "upload_max_file_size",
65 "upload_allowed_mime_types",
66 "attachments_upload_max_bytes",
67 "attachments_upload_blocked",
68 "avatars_upload_blocked",
69 "attachments_download_max_bytes",
70 "attachments_download_blocked",
71 "api_upload_max_bytes",
72 "api_upload_blocked",
73 "api_download_max_bytes",
74 "api_download_blocked",
75 "migration_auto_migrate",
76 "migration_batch_size",
77 "migration_delay_ms",
78 "migration_cpu_threshold",
79 "s3_secret_access_key_file",
80 "s3_secret_access_key_stdin",
81 "azure_account_key_file",
82 "azure_account_key_stdin",
83 "azure_connection_string_file",
84 "azure_connection_string_stdin",
85 "gcs_credentials_file",
86 "gcs_credentials_stdin",
87 "clear_upload_allowed_mime_types",
88]).multiple(true).conflicts_with("settings_source")), group(ArgGroup::new("update_input").args([
89 "settings_file",
90 "settings_stdin",
91 "default_storage",
92 "filesystem_enabled",
93 "filesystem_read",
94 "filesystem_write",
95 "filesystem_path",
96 "gridfs_enabled",
97 "gridfs_read",
98 "gridfs_write",
99 "s3_enabled",
100 "s3_read",
101 "s3_write",
102 "s3_endpoint",
103 "s3_region",
104 "s3_bucket",
105 "s3_access_key_id",
106 "s3_force_path_style",
107 "azure_enabled",
108 "azure_read",
109 "azure_write",
110 "azure_account_name",
111 "azure_bucket",
112 "gcs_enabled",
113 "gcs_read",
114 "gcs_write",
115 "gcs_project_id",
116 "gcs_key_filename",
117 "gcs_bucket",
118 "upload_max_file_size",
119 "upload_allowed_mime_types",
120 "attachments_upload_max_bytes",
121 "attachments_upload_blocked",
122 "avatars_upload_blocked",
123 "attachments_download_max_bytes",
124 "attachments_download_blocked",
125 "api_upload_max_bytes",
126 "api_upload_blocked",
127 "api_download_max_bytes",
128 "api_download_blocked",
129 "migration_auto_migrate",
130 "migration_batch_size",
131 "migration_delay_ms",
132 "migration_cpu_threshold",
133 "s3_secret_access_key_file",
134 "s3_secret_access_key_stdin",
135 "azure_account_key_file",
136 "azure_account_key_stdin",
137 "azure_connection_string_file",
138 "azure_connection_string_stdin",
139 "gcs_credentials_file",
140 "gcs_credentials_stdin",
141 "clear_upload_allowed_mime_types",
142]).required(true).multiple(true)), after_help = "Nested sections use Wekan replacement/default semantics: omitted siblings can reset or disappear. Cloud providers merge and blank secrets preserve stored credentials. Saving does not verify connectivity. JSON sources conflict with every setting flag.")]
143pub struct UpdateArgs {
144 #[command(flatten)]
145 source: input::SourceArgs,
146 #[arg(long = "default-storage")]
148 default_storage: Option<StorageBackend>,
149 #[arg(long = "filesystem-enabled")]
151 filesystem_enabled: Option<bool>,
152 #[arg(long = "filesystem-read")]
154 filesystem_read: Option<bool>,
155 #[arg(long = "filesystem-write")]
157 filesystem_write: Option<bool>,
158 #[arg(long = "filesystem-path")]
160 filesystem_path: Option<String>,
161 #[arg(long = "gridfs-enabled")]
163 gridfs_enabled: Option<bool>,
164 #[arg(long = "gridfs-read")]
166 gridfs_read: Option<bool>,
167 #[arg(long = "gridfs-write")]
169 gridfs_write: Option<bool>,
170 #[arg(long = "s3-enabled")]
172 s3_enabled: Option<bool>,
173 #[arg(long = "s3-read")]
175 s3_read: Option<bool>,
176 #[arg(long = "s3-write")]
178 s3_write: Option<bool>,
179 #[arg(long = "s3-endpoint")]
181 s3_endpoint: Option<String>,
182 #[arg(long = "s3-region")]
184 s3_region: Option<String>,
185 #[arg(long = "s3-bucket")]
187 s3_bucket: Option<String>,
188 #[arg(long = "s3-access-key-id")]
190 s3_access_key_id: Option<String>,
191 #[arg(long = "s3-force-path-style")]
193 s3_force_path_style: Option<bool>,
194 #[arg(long = "azure-enabled")]
196 azure_enabled: Option<bool>,
197 #[arg(long = "azure-read")]
199 azure_read: Option<bool>,
200 #[arg(long = "azure-write")]
202 azure_write: Option<bool>,
203 #[arg(long = "azure-account-name")]
205 azure_account_name: Option<String>,
206 #[arg(long = "azure-bucket")]
208 azure_bucket: Option<String>,
209 #[arg(long = "gcs-enabled")]
211 gcs_enabled: Option<bool>,
212 #[arg(long = "gcs-read")]
214 gcs_read: Option<bool>,
215 #[arg(long = "gcs-write")]
217 gcs_write: Option<bool>,
218 #[arg(long = "gcs-project-id")]
220 gcs_project_id: Option<String>,
221 #[arg(long = "gcs-key-filename")]
223 gcs_key_filename: Option<String>,
224 #[arg(long = "gcs-bucket")]
226 gcs_bucket: Option<String>,
227 #[arg(long = "upload-max-file-size", allow_hyphen_values = true)]
229 upload_max_file_size: Option<SettingNumber>,
230 #[arg(
232 long = "upload-allowed-mime-type",
233 conflicts_with = "clear_upload_allowed_mime_types"
234 )]
235 upload_allowed_mime_types: Vec<String>,
236 #[arg(long = "attachments-upload-max-bytes", allow_hyphen_values = true)]
238 attachments_upload_max_bytes: Option<NonNegativeNumber>,
239 #[arg(long = "attachments-upload-blocked")]
241 attachments_upload_blocked: Option<bool>,
242 #[arg(long = "avatars-upload-blocked")]
244 avatars_upload_blocked: Option<bool>,
245 #[arg(long = "attachments-download-max-bytes", allow_hyphen_values = true)]
247 attachments_download_max_bytes: Option<NonNegativeNumber>,
248 #[arg(long = "attachments-download-blocked")]
250 attachments_download_blocked: Option<bool>,
251 #[arg(long = "api-upload-max-bytes", allow_hyphen_values = true)]
253 api_upload_max_bytes: Option<NonNegativeNumber>,
254 #[arg(long = "api-upload-blocked")]
256 api_upload_blocked: Option<bool>,
257 #[arg(long = "api-download-max-bytes", allow_hyphen_values = true)]
259 api_download_max_bytes: Option<NonNegativeNumber>,
260 #[arg(long = "api-download-blocked")]
262 api_download_blocked: Option<bool>,
263 #[arg(long = "migration-auto-migrate")]
265 migration_auto_migrate: Option<bool>,
266 #[arg(long = "migration-batch-size", allow_hyphen_values = true)]
268 migration_batch_size: Option<BatchSize>,
269 #[arg(long = "migration-delay-ms", allow_hyphen_values = true)]
271 migration_delay_ms: Option<DelayMs>,
272 #[arg(long = "migration-cpu-threshold", allow_hyphen_values = true)]
274 migration_cpu_threshold: Option<CpuThreshold>,
275 #[arg(
277 long = "s3-secret-access-key-file",
278 conflicts_with = "s3_secret_access_key_stdin",
279 value_name = "PATH"
280 )]
281 s3_secret_access_key_file: Option<std::path::PathBuf>,
282 #[arg(
284 long = "s3-secret-access-key-stdin",
285 conflicts_with = "s3_secret_access_key_file"
286 )]
287 s3_secret_access_key_stdin: bool,
288 #[arg(
290 long = "azure-account-key-file",
291 conflicts_with = "azure_account_key_stdin",
292 value_name = "PATH"
293 )]
294 azure_account_key_file: Option<std::path::PathBuf>,
295 #[arg(
297 long = "azure-account-key-stdin",
298 conflicts_with = "azure_account_key_file"
299 )]
300 azure_account_key_stdin: bool,
301 #[arg(
303 long = "azure-connection-string-file",
304 conflicts_with = "azure_connection_string_stdin",
305 value_name = "PATH"
306 )]
307 azure_connection_string_file: Option<std::path::PathBuf>,
308 #[arg(
310 long = "azure-connection-string-stdin",
311 conflicts_with = "azure_connection_string_file"
312 )]
313 azure_connection_string_stdin: bool,
314 #[arg(
316 long = "gcs-credentials-file",
317 conflicts_with = "gcs_credentials_stdin",
318 value_name = "PATH"
319 )]
320 gcs_credentials_file: Option<std::path::PathBuf>,
321 #[arg(
323 long = "gcs-credentials-stdin",
324 conflicts_with = "gcs_credentials_file"
325 )]
326 gcs_credentials_stdin: bool,
327 #[arg(
329 long = "clear-upload-allowed-mime-types",
330 conflicts_with = "upload_allowed_mime_types"
331 )]
332 clear_upload_allowed_mime_types: bool,
333}
334impl UpdateArgs {
335 async fn request(
336 self,
337 secrets: &dyn SecretInputProvider,
338 ) -> Result<UpdateAttachmentSettings, AppError> {
339 if let Some(request) = self.source.read().await? {
340 return Ok(request);
341 }
342 let s3_secret_access_key = input::secret(
343 self.s3_secret_access_key_file,
344 self.s3_secret_access_key_stdin,
345 secrets,
346 )
347 .await?
348 .map(StorageSecret);
349 let azure_account_key = input::secret(
350 self.azure_account_key_file,
351 self.azure_account_key_stdin,
352 secrets,
353 )
354 .await?
355 .map(StorageSecret);
356 let azure_connection_string = input::secret(
357 self.azure_connection_string_file,
358 self.azure_connection_string_stdin,
359 secrets,
360 )
361 .await?
362 .map(StorageSecret);
363 let gcs_credentials = input::secret(
364 self.gcs_credentials_file,
365 self.gcs_credentials_stdin,
366 secrets,
367 )
368 .await?
369 .map(GcsCredentials::text);
370 let filesystem = FilesystemUpdate {
371 enabled: self.filesystem_enabled,
372 read: self.filesystem_read,
373 write: self.filesystem_write,
374 path: self.filesystem_path,
375 };
376 let filesystem = if filesystem.enabled.is_some()
377 || filesystem.read.is_some()
378 || filesystem.write.is_some()
379 || filesystem.path.is_some()
380 {
381 Some(filesystem)
382 } else {
383 None
384 };
385 let gridfs = GridfsUpdate {
386 enabled: self.gridfs_enabled,
387 read: self.gridfs_read,
388 write: self.gridfs_write,
389 };
390 let gridfs = if gridfs.enabled.is_some() || gridfs.read.is_some() || gridfs.write.is_some()
391 {
392 Some(gridfs)
393 } else {
394 None
395 };
396 let s3 = S3Update {
397 enabled: self.s3_enabled,
398 read: self.s3_read,
399 write: self.s3_write,
400 endpoint: self.s3_endpoint,
401 region: self.s3_region,
402 bucket: self.s3_bucket,
403 access_key_id: self.s3_access_key_id,
404 force_path_style: self.s3_force_path_style,
405 secret_access_key: s3_secret_access_key,
406 };
407 let s3 = if s3.enabled.is_some()
408 || s3.read.is_some()
409 || s3.write.is_some()
410 || s3.endpoint.is_some()
411 || s3.region.is_some()
412 || s3.bucket.is_some()
413 || s3.access_key_id.is_some()
414 || s3.force_path_style.is_some()
415 || s3.secret_access_key.is_some()
416 {
417 Some(s3)
418 } else {
419 None
420 };
421 let azure = AzureUpdate {
422 enabled: self.azure_enabled,
423 read: self.azure_read,
424 write: self.azure_write,
425 account_name: self.azure_account_name,
426 bucket: self.azure_bucket,
427 account_key: azure_account_key,
428 connection_string: azure_connection_string,
429 };
430 let azure = if azure.enabled.is_some()
431 || azure.read.is_some()
432 || azure.write.is_some()
433 || azure.account_name.is_some()
434 || azure.bucket.is_some()
435 || azure.account_key.is_some()
436 || azure.connection_string.is_some()
437 {
438 Some(azure)
439 } else {
440 None
441 };
442 let gcs = GcsUpdate {
443 enabled: self.gcs_enabled,
444 read: self.gcs_read,
445 write: self.gcs_write,
446 project_id: self.gcs_project_id,
447 key_filename: self.gcs_key_filename,
448 bucket: self.gcs_bucket,
449 credentials: gcs_credentials,
450 };
451 let gcs = if gcs.enabled.is_some()
452 || gcs.read.is_some()
453 || gcs.write.is_some()
454 || gcs.project_id.is_some()
455 || gcs.key_filename.is_some()
456 || gcs.bucket.is_some()
457 || gcs.credentials.is_some()
458 {
459 Some(gcs)
460 } else {
461 None
462 };
463 let upload = UploadUpdate {
464 max_file_size: self.upload_max_file_size,
465 allowed_mime_types: if self.clear_upload_allowed_mime_types {
466 Some(Vec::new())
467 } else if self.upload_allowed_mime_types.is_empty() {
468 None
469 } else {
470 Some(self.upload_allowed_mime_types)
471 },
472 };
473 let upload = if upload.max_file_size.is_some() || upload.allowed_mime_types.is_some() {
474 Some(upload)
475 } else {
476 None
477 };
478 let limits = LimitsUpdate {
479 attachments_upload_max_bytes: self.attachments_upload_max_bytes,
480 attachments_upload_blocked: self.attachments_upload_blocked,
481 avatars_upload_blocked: self.avatars_upload_blocked,
482 attachments_download_max_bytes: self.attachments_download_max_bytes,
483 attachments_download_blocked: self.attachments_download_blocked,
484 api_upload_max_bytes: self.api_upload_max_bytes,
485 api_upload_blocked: self.api_upload_blocked,
486 api_download_max_bytes: self.api_download_max_bytes,
487 api_download_blocked: self.api_download_blocked,
488 };
489 let limits = if limits.attachments_upload_max_bytes.is_some()
490 || limits.attachments_upload_blocked.is_some()
491 || limits.avatars_upload_blocked.is_some()
492 || limits.attachments_download_max_bytes.is_some()
493 || limits.attachments_download_blocked.is_some()
494 || limits.api_upload_max_bytes.is_some()
495 || limits.api_upload_blocked.is_some()
496 || limits.api_download_max_bytes.is_some()
497 || limits.api_download_blocked.is_some()
498 {
499 Some(limits)
500 } else {
501 None
502 };
503 let migration = MigrationUpdate {
504 auto_migrate: self.migration_auto_migrate,
505 batch_size: self.migration_batch_size,
506 delay_ms: self.migration_delay_ms,
507 cpu_threshold: self.migration_cpu_threshold,
508 };
509 let migration = if migration.auto_migrate.is_some()
510 || migration.batch_size.is_some()
511 || migration.delay_ms.is_some()
512 || migration.cpu_threshold.is_some()
513 {
514 Some(migration)
515 } else {
516 None
517 };
518 let storage_config = StorageConfigUpdate {
519 filesystem,
520 gridfs,
521 s3,
522 azure,
523 gcs,
524 };
525 let has_config = storage_config.filesystem.is_some()
526 || storage_config.gridfs.is_some()
527 || storage_config.s3.is_some()
528 || storage_config.azure.is_some()
529 || storage_config.gcs.is_some();
530 Ok(UpdateAttachmentSettings {
531 default_storage: self.default_storage,
532 storage_config: has_config.then_some(storage_config),
533 upload_settings: upload,
534 limit_settings: limits,
535 migration_settings: migration,
536 })
537 }
538}
539pub(crate) async fn execute(
540 command: AttachmentSettingsCommand,
541 factory: &WekanClientFactory,
542 store: &dyn CredentialStore,
543 secrets: &dyn SecretInputProvider,
544) -> Result<CommandSuccess, AppError> {
545 match command {
546 AttachmentSettingsCommand::Get => {
547 let context = AuthenticatedContext::load(factory, store)?;
548 let document = context
549 .client()
550 .attachment_settings(context.record().token())
551 .await
552 .map_err(|e| {
553 map_client_error(
554 e,
555 &Redactor::with_secret(context.record().token()),
556 "admin attachment-settings get",
557 false,
558 )
559 })?;
560 Ok(CommandSuccess::AttachmentSettings(Box::new(
561 AttachmentSettingsSuccess {
562 settings: document.into(),
563 },
564 )))
565 }
566 AttachmentSettingsCommand::Update(args) => {
567 let request = args.request(secrets).await?;
568 let submitted_fields = submitted_fields(&request);
569 input::validate_fields(&submitted_fields)?;
570 let prepared = PreparedSettingsUpdate::new(&request)
571 .map_err(|error| AppError::invalid_input(error.to_string()))?;
572 let context = AuthenticatedContext::load(factory, store)?;
573 let secrets = request.redaction_secrets();
574 let mut redactor = Redactor::with_secret(context.record().token());
575 for secret in &secrets {
576 redactor = redactor.and_secret(secret);
577 }
578 let document = context
579 .client()
580 .update_attachment_settings(prepared, context.record().token())
581 .await
582 .map_err(|e| {
583 map_partial_mutation_client_error_with_not_found(
584 e,
585 &redactor,
586 "admin attachment-settings update",
587 "the Wekan settings document was not found",
588 )
589 })?;
590 Ok(CommandSuccess::AttachmentSettingsUpdated(Box::new(
591 AttachmentSettingsUpdateSuccess {
592 settings: document.into(),
593 submitted_fields,
594 },
595 )))
596 }
597 }
598}
599
600fn submitted_fields(request: &UpdateAttachmentSettings) -> Vec<AttachmentSettingField> {
601 let mut fields = Vec::new();
602 if request.default_storage.is_some() {
603 fields.push(AttachmentSettingField::DefaultStorage);
604 }
605 if request.storage_config.is_some() {
606 fields.push(AttachmentSettingField::StorageConfig);
607 }
608 if request.upload_settings.is_some() {
609 fields.push(AttachmentSettingField::UploadSettings);
610 }
611 if request.limit_settings.is_some() {
612 fields.push(AttachmentSettingField::LimitSettings);
613 }
614 if request.migration_settings.is_some() {
615 fields.push(AttachmentSettingField::MigrationSettings);
616 }
617 fields
618}