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_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 "webdav_password_stdin",
37]).multiple(false)), group(ArgGroup::new("setting_flags").args([
38 "webdav_enabled",
39 "webdav_read",
40 "webdav_write",
41 "webdav_endpoint",
42 "webdav_username",
43 "webdav_bucket",
44 "webdav_password_file",
45 "webdav_password_stdin",
46 "default_storage",
47 "filesystem_enabled",
48 "filesystem_read",
49 "filesystem_write",
50 "filesystem_path",
51 "gridfs_enabled",
52 "gridfs_read",
53 "gridfs_write",
54 "s3_enabled",
55 "s3_read",
56 "s3_write",
57 "s3_endpoint",
58 "s3_region",
59 "s3_bucket",
60 "s3_access_key_id",
61 "s3_force_path_style",
62 "azure_enabled",
63 "azure_read",
64 "azure_write",
65 "azure_account_name",
66 "azure_bucket",
67 "gcs_enabled",
68 "gcs_read",
69 "gcs_write",
70 "gcs_project_id",
71 "gcs_key_filename",
72 "gcs_bucket",
73 "upload_max_file_size",
74 "upload_allowed_mime_types",
75 "attachments_upload_max_bytes",
76 "attachments_upload_blocked",
77 "avatars_upload_blocked",
78 "attachments_download_max_bytes",
79 "attachments_download_blocked",
80 "api_upload_max_bytes",
81 "api_upload_blocked",
82 "api_download_max_bytes",
83 "api_download_blocked",
84 "migration_auto_migrate",
85 "migration_batch_size",
86 "migration_delay_ms",
87 "migration_cpu_threshold",
88 "s3_secret_access_key_file",
89 "s3_secret_access_key_stdin",
90 "azure_account_key_file",
91 "azure_account_key_stdin",
92 "azure_connection_string_file",
93 "azure_connection_string_stdin",
94 "gcs_credentials_file",
95 "gcs_credentials_stdin",
96 "clear_upload_allowed_mime_types",
97]).multiple(true).conflicts_with("settings_source")), group(ArgGroup::new("update_input").args([
98 "settings_file",
99 "settings_stdin",
100 "webdav_enabled",
101 "webdav_read",
102 "webdav_write",
103 "webdav_endpoint",
104 "webdav_username",
105 "webdav_bucket",
106 "webdav_password_file",
107 "webdav_password_stdin",
108 "default_storage",
109 "filesystem_enabled",
110 "filesystem_read",
111 "filesystem_write",
112 "filesystem_path",
113 "gridfs_enabled",
114 "gridfs_read",
115 "gridfs_write",
116 "s3_enabled",
117 "s3_read",
118 "s3_write",
119 "s3_endpoint",
120 "s3_region",
121 "s3_bucket",
122 "s3_access_key_id",
123 "s3_force_path_style",
124 "azure_enabled",
125 "azure_read",
126 "azure_write",
127 "azure_account_name",
128 "azure_bucket",
129 "gcs_enabled",
130 "gcs_read",
131 "gcs_write",
132 "gcs_project_id",
133 "gcs_key_filename",
134 "gcs_bucket",
135 "upload_max_file_size",
136 "upload_allowed_mime_types",
137 "attachments_upload_max_bytes",
138 "attachments_upload_blocked",
139 "avatars_upload_blocked",
140 "attachments_download_max_bytes",
141 "attachments_download_blocked",
142 "api_upload_max_bytes",
143 "api_upload_blocked",
144 "api_download_max_bytes",
145 "api_download_blocked",
146 "migration_auto_migrate",
147 "migration_batch_size",
148 "migration_delay_ms",
149 "migration_cpu_threshold",
150 "s3_secret_access_key_file",
151 "s3_secret_access_key_stdin",
152 "azure_account_key_file",
153 "azure_account_key_stdin",
154 "azure_connection_string_file",
155 "azure_connection_string_stdin",
156 "gcs_credentials_file",
157 "gcs_credentials_stdin",
158 "clear_upload_allowed_mime_types",
159]).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.")]
160pub struct UpdateArgs {
161 #[command(flatten)]
162 source: input::SourceArgs,
163 #[arg(long)]
165 webdav_enabled: Option<bool>,
166 #[arg(long)]
168 webdav_read: Option<bool>,
169 #[arg(long)]
171 webdav_write: Option<bool>,
172 #[arg(long)]
174 webdav_endpoint: Option<String>,
175 #[arg(long)]
177 webdav_username: Option<String>,
178 #[arg(long)]
180 webdav_bucket: Option<String>,
181 #[arg(long, conflicts_with = "webdav_password_stdin")]
183 webdav_password_file: Option<std::path::PathBuf>,
184 #[arg(long, conflicts_with = "webdav_password_file")]
186 webdav_password_stdin: bool,
187
188 #[arg(long = "default-storage")]
190 default_storage: Option<StorageBackend>,
191 #[arg(long = "filesystem-enabled")]
193 filesystem_enabled: Option<bool>,
194 #[arg(long = "filesystem-read")]
196 filesystem_read: Option<bool>,
197 #[arg(long = "filesystem-write")]
199 filesystem_write: Option<bool>,
200 #[arg(long = "filesystem-path")]
202 filesystem_path: Option<String>,
203 #[arg(long = "gridfs-enabled")]
205 gridfs_enabled: Option<bool>,
206 #[arg(long = "gridfs-read")]
208 gridfs_read: Option<bool>,
209 #[arg(long = "gridfs-write")]
211 gridfs_write: Option<bool>,
212 #[arg(long = "s3-enabled")]
214 s3_enabled: Option<bool>,
215 #[arg(long = "s3-read")]
217 s3_read: Option<bool>,
218 #[arg(long = "s3-write")]
220 s3_write: Option<bool>,
221 #[arg(long = "s3-endpoint")]
223 s3_endpoint: Option<String>,
224 #[arg(long = "s3-region")]
226 s3_region: Option<String>,
227 #[arg(long = "s3-bucket")]
229 s3_bucket: Option<String>,
230 #[arg(long = "s3-access-key-id")]
232 s3_access_key_id: Option<String>,
233 #[arg(long = "s3-force-path-style")]
235 s3_force_path_style: Option<bool>,
236 #[arg(long = "azure-enabled")]
238 azure_enabled: Option<bool>,
239 #[arg(long = "azure-read")]
241 azure_read: Option<bool>,
242 #[arg(long = "azure-write")]
244 azure_write: Option<bool>,
245 #[arg(long = "azure-account-name")]
247 azure_account_name: Option<String>,
248 #[arg(long = "azure-bucket")]
250 azure_bucket: Option<String>,
251 #[arg(long = "gcs-enabled")]
253 gcs_enabled: Option<bool>,
254 #[arg(long = "gcs-read")]
256 gcs_read: Option<bool>,
257 #[arg(long = "gcs-write")]
259 gcs_write: Option<bool>,
260 #[arg(long = "gcs-project-id")]
262 gcs_project_id: Option<String>,
263 #[arg(long = "gcs-key-filename")]
265 gcs_key_filename: Option<String>,
266 #[arg(long = "gcs-bucket")]
268 gcs_bucket: Option<String>,
269 #[arg(long = "upload-max-file-size", allow_hyphen_values = true)]
271 upload_max_file_size: Option<SettingNumber>,
272 #[arg(
274 long = "upload-allowed-mime-type",
275 conflicts_with = "clear_upload_allowed_mime_types"
276 )]
277 upload_allowed_mime_types: Vec<String>,
278 #[arg(long = "attachments-upload-max-bytes", allow_hyphen_values = true)]
280 attachments_upload_max_bytes: Option<NonNegativeNumber>,
281 #[arg(long = "attachments-upload-blocked")]
283 attachments_upload_blocked: Option<bool>,
284 #[arg(long = "avatars-upload-blocked")]
286 avatars_upload_blocked: Option<bool>,
287 #[arg(long = "attachments-download-max-bytes", allow_hyphen_values = true)]
289 attachments_download_max_bytes: Option<NonNegativeNumber>,
290 #[arg(long = "attachments-download-blocked")]
292 attachments_download_blocked: Option<bool>,
293 #[arg(long = "api-upload-max-bytes", allow_hyphen_values = true)]
295 api_upload_max_bytes: Option<NonNegativeNumber>,
296 #[arg(long = "api-upload-blocked")]
298 api_upload_blocked: Option<bool>,
299 #[arg(long = "api-download-max-bytes", allow_hyphen_values = true)]
301 api_download_max_bytes: Option<NonNegativeNumber>,
302 #[arg(long = "api-download-blocked")]
304 api_download_blocked: Option<bool>,
305 #[arg(long = "migration-auto-migrate")]
307 migration_auto_migrate: Option<bool>,
308 #[arg(long = "migration-batch-size", allow_hyphen_values = true)]
310 migration_batch_size: Option<BatchSize>,
311 #[arg(long = "migration-delay-ms", allow_hyphen_values = true)]
313 migration_delay_ms: Option<DelayMs>,
314 #[arg(long = "migration-cpu-threshold", allow_hyphen_values = true)]
316 migration_cpu_threshold: Option<CpuThreshold>,
317 #[arg(
319 long = "s3-secret-access-key-file",
320 conflicts_with = "s3_secret_access_key_stdin",
321 value_name = "PATH"
322 )]
323 s3_secret_access_key_file: Option<std::path::PathBuf>,
324 #[arg(
326 long = "s3-secret-access-key-stdin",
327 conflicts_with = "s3_secret_access_key_file"
328 )]
329 s3_secret_access_key_stdin: bool,
330 #[arg(
332 long = "azure-account-key-file",
333 conflicts_with = "azure_account_key_stdin",
334 value_name = "PATH"
335 )]
336 azure_account_key_file: Option<std::path::PathBuf>,
337 #[arg(
339 long = "azure-account-key-stdin",
340 conflicts_with = "azure_account_key_file"
341 )]
342 azure_account_key_stdin: bool,
343 #[arg(
345 long = "azure-connection-string-file",
346 conflicts_with = "azure_connection_string_stdin",
347 value_name = "PATH"
348 )]
349 azure_connection_string_file: Option<std::path::PathBuf>,
350 #[arg(
352 long = "azure-connection-string-stdin",
353 conflicts_with = "azure_connection_string_file"
354 )]
355 azure_connection_string_stdin: bool,
356 #[arg(
358 long = "gcs-credentials-file",
359 conflicts_with = "gcs_credentials_stdin",
360 value_name = "PATH"
361 )]
362 gcs_credentials_file: Option<std::path::PathBuf>,
363 #[arg(
365 long = "gcs-credentials-stdin",
366 conflicts_with = "gcs_credentials_file"
367 )]
368 gcs_credentials_stdin: bool,
369 #[arg(
371 long = "clear-upload-allowed-mime-types",
372 conflicts_with = "upload_allowed_mime_types"
373 )]
374 clear_upload_allowed_mime_types: bool,
375}
376impl UpdateArgs {
377 async fn request(
378 self,
379 secrets: &dyn SecretInputProvider,
380 ) -> Result<UpdateAttachmentSettings, AppError> {
381 if let Some(request) = self.source.read().await? {
382 return Ok(request);
383 }
384 let s3_secret_access_key = input::secret(
385 self.s3_secret_access_key_file,
386 self.s3_secret_access_key_stdin,
387 secrets,
388 )
389 .await?
390 .map(StorageSecret);
391 let azure_account_key = input::secret(
392 self.azure_account_key_file,
393 self.azure_account_key_stdin,
394 secrets,
395 )
396 .await?
397 .map(StorageSecret);
398 let azure_connection_string = input::secret(
399 self.azure_connection_string_file,
400 self.azure_connection_string_stdin,
401 secrets,
402 )
403 .await?
404 .map(StorageSecret);
405 let gcs_credentials = input::secret(
406 self.gcs_credentials_file,
407 self.gcs_credentials_stdin,
408 secrets,
409 )
410 .await?
411 .map(GcsCredentials::text);
412 let webdav_password = input::secret(
413 self.webdav_password_file,
414 self.webdav_password_stdin,
415 secrets,
416 )
417 .await?
418 .map(StorageSecret);
419 let webdav = WebdavUpdate {
420 enabled: self.webdav_enabled,
421 read: self.webdav_read,
422 write: self.webdav_write,
423 endpoint: self.webdav_endpoint,
424 username: self.webdav_username,
425 bucket: self.webdav_bucket,
426 password: webdav_password,
427 };
428 let webdav = if webdav.enabled.is_some()
429 || webdav.read.is_some()
430 || webdav.write.is_some()
431 || webdav.endpoint.is_some()
432 || webdav.username.is_some()
433 || webdav.bucket.is_some()
434 || webdav.password.is_some()
435 {
436 Some(webdav)
437 } else {
438 None
439 };
440 let filesystem = FilesystemUpdate {
441 enabled: self.filesystem_enabled,
442 read: self.filesystem_read,
443 write: self.filesystem_write,
444 path: self.filesystem_path,
445 };
446 let filesystem = if filesystem.enabled.is_some()
447 || filesystem.read.is_some()
448 || filesystem.write.is_some()
449 || filesystem.path.is_some()
450 {
451 Some(filesystem)
452 } else {
453 None
454 };
455 let gridfs = GridfsUpdate {
456 enabled: self.gridfs_enabled,
457 read: self.gridfs_read,
458 write: self.gridfs_write,
459 };
460 let gridfs = if gridfs.enabled.is_some() || gridfs.read.is_some() || gridfs.write.is_some()
461 {
462 Some(gridfs)
463 } else {
464 None
465 };
466 let s3 = S3Update {
467 enabled: self.s3_enabled,
468 read: self.s3_read,
469 write: self.s3_write,
470 endpoint: self.s3_endpoint,
471 region: self.s3_region,
472 bucket: self.s3_bucket,
473 access_key_id: self.s3_access_key_id,
474 force_path_style: self.s3_force_path_style,
475 secret_access_key: s3_secret_access_key,
476 };
477 let s3 = if s3.enabled.is_some()
478 || s3.read.is_some()
479 || s3.write.is_some()
480 || s3.endpoint.is_some()
481 || s3.region.is_some()
482 || s3.bucket.is_some()
483 || s3.access_key_id.is_some()
484 || s3.force_path_style.is_some()
485 || s3.secret_access_key.is_some()
486 {
487 Some(s3)
488 } else {
489 None
490 };
491 let azure = AzureUpdate {
492 enabled: self.azure_enabled,
493 read: self.azure_read,
494 write: self.azure_write,
495 account_name: self.azure_account_name,
496 bucket: self.azure_bucket,
497 account_key: azure_account_key,
498 connection_string: azure_connection_string,
499 };
500 let azure = if azure.enabled.is_some()
501 || azure.read.is_some()
502 || azure.write.is_some()
503 || azure.account_name.is_some()
504 || azure.bucket.is_some()
505 || azure.account_key.is_some()
506 || azure.connection_string.is_some()
507 {
508 Some(azure)
509 } else {
510 None
511 };
512 let gcs = GcsUpdate {
513 enabled: self.gcs_enabled,
514 read: self.gcs_read,
515 write: self.gcs_write,
516 project_id: self.gcs_project_id,
517 key_filename: self.gcs_key_filename,
518 bucket: self.gcs_bucket,
519 credentials: gcs_credentials,
520 };
521 let gcs = if gcs.enabled.is_some()
522 || gcs.read.is_some()
523 || gcs.write.is_some()
524 || gcs.project_id.is_some()
525 || gcs.key_filename.is_some()
526 || gcs.bucket.is_some()
527 || gcs.credentials.is_some()
528 {
529 Some(gcs)
530 } else {
531 None
532 };
533 let upload = UploadUpdate {
534 max_file_size: self.upload_max_file_size,
535 allowed_mime_types: if self.clear_upload_allowed_mime_types {
536 Some(Vec::new())
537 } else if self.upload_allowed_mime_types.is_empty() {
538 None
539 } else {
540 Some(self.upload_allowed_mime_types)
541 },
542 };
543 let upload = if upload.max_file_size.is_some() || upload.allowed_mime_types.is_some() {
544 Some(upload)
545 } else {
546 None
547 };
548 let limits = LimitsUpdate {
549 attachments_upload_max_bytes: self.attachments_upload_max_bytes,
550 attachments_upload_blocked: self.attachments_upload_blocked,
551 avatars_upload_blocked: self.avatars_upload_blocked,
552 attachments_download_max_bytes: self.attachments_download_max_bytes,
553 attachments_download_blocked: self.attachments_download_blocked,
554 api_upload_max_bytes: self.api_upload_max_bytes,
555 api_upload_blocked: self.api_upload_blocked,
556 api_download_max_bytes: self.api_download_max_bytes,
557 api_download_blocked: self.api_download_blocked,
558 };
559 let limits = if limits.attachments_upload_max_bytes.is_some()
560 || limits.attachments_upload_blocked.is_some()
561 || limits.avatars_upload_blocked.is_some()
562 || limits.attachments_download_max_bytes.is_some()
563 || limits.attachments_download_blocked.is_some()
564 || limits.api_upload_max_bytes.is_some()
565 || limits.api_upload_blocked.is_some()
566 || limits.api_download_max_bytes.is_some()
567 || limits.api_download_blocked.is_some()
568 {
569 Some(limits)
570 } else {
571 None
572 };
573 let migration = MigrationUpdate {
574 auto_migrate: self.migration_auto_migrate,
575 batch_size: self.migration_batch_size,
576 delay_ms: self.migration_delay_ms,
577 cpu_threshold: self.migration_cpu_threshold,
578 };
579 let migration = if migration.auto_migrate.is_some()
580 || migration.batch_size.is_some()
581 || migration.delay_ms.is_some()
582 || migration.cpu_threshold.is_some()
583 {
584 Some(migration)
585 } else {
586 None
587 };
588 let storage_config = StorageConfigUpdate {
589 webdav,
590 filesystem,
591 gridfs,
592 s3,
593 azure,
594 gcs,
595 };
596 let has_config = storage_config.webdav.is_some()
597 || storage_config.filesystem.is_some()
598 || storage_config.gridfs.is_some()
599 || storage_config.s3.is_some()
600 || storage_config.azure.is_some()
601 || storage_config.gcs.is_some();
602 Ok(UpdateAttachmentSettings {
603 default_storage: self.default_storage,
604 storage_config: has_config.then_some(storage_config),
605 upload_settings: upload,
606 limit_settings: limits,
607 migration_settings: migration,
608 })
609 }
610}
611pub(crate) async fn execute(
612 command: AttachmentSettingsCommand,
613 factory: &WekanClientFactory,
614 store: &dyn CredentialStore,
615 secrets: &dyn SecretInputProvider,
616) -> Result<CommandSuccess, AppError> {
617 match command {
618 AttachmentSettingsCommand::Get => {
619 let context = AuthenticatedContext::load(factory, store)?;
620 let document = context
621 .client()
622 .attachment_settings(context.record().token())
623 .await
624 .map_err(|e| {
625 map_client_error(
626 e,
627 &Redactor::with_secret(context.record().token()),
628 "admin attachment-settings get",
629 )
630 })?;
631 Ok(CommandSuccess::AttachmentSettings(Box::new(
632 AttachmentSettingsSuccess {
633 settings: document.into(),
634 },
635 )))
636 }
637 AttachmentSettingsCommand::Update(args) => {
638 let request = args.request(secrets).await?;
639 let submitted_fields = submitted_fields(&request);
640 input::validate_fields(&submitted_fields)?;
641 let prepared = PreparedSettingsUpdate::new(&request)
642 .map_err(|error| AppError::invalid_input(error.to_string()))?;
643 let context = AuthenticatedContext::load(factory, store)?;
644 let secrets = request.redaction_secrets();
645 let mut redactor = Redactor::with_secret(context.record().token());
646 for secret in &secrets {
647 redactor = redactor.and_secret(secret);
648 }
649 let document = context
650 .client()
651 .update_attachment_settings(prepared, context.record().token())
652 .await
653 .map_err(|e| {
654 map_client_error_with_not_found(
655 e,
656 &redactor,
657 "admin attachment-settings update",
658 "the Wekan settings document was not found",
659 )
660 })?;
661 Ok(CommandSuccess::AttachmentSettingsUpdated(Box::new(
662 AttachmentSettingsUpdateSuccess {
663 settings: document.into(),
664 submitted_fields,
665 },
666 )))
667 }
668 }
669}
670
671fn submitted_fields(request: &UpdateAttachmentSettings) -> Vec<AttachmentSettingField> {
672 let mut fields = Vec::new();
673 if request.default_storage.is_some() {
674 fields.push(AttachmentSettingField::DefaultStorage);
675 }
676 if request.storage_config.is_some() {
677 fields.push(AttachmentSettingField::StorageConfig);
678 }
679 if request.upload_settings.is_some() {
680 fields.push(AttachmentSettingField::UploadSettings);
681 }
682 if request.limit_settings.is_some() {
683 fields.push(AttachmentSettingField::LimitSettings);
684 }
685 if request.migration_settings.is_some() {
686 fields.push(AttachmentSettingField::MigrationSettings);
687 }
688 fields
689}