Skip to main content

wekan_cli/commands/admin/
attachment_settings.rs

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    /// Return the complete mapped settings document.
26    Get,
27    /// Apply native settings updates, with no read-before-write merging.
28    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    /// Default backend: fs, gridfs, s3, azure, or gcs.
147    #[arg(long = "default-storage")]
148    default_storage: Option<StorageBackend>,
149    /// Set filesystem enabled. Explicit true or false.
150    #[arg(long = "filesystem-enabled")]
151    filesystem_enabled: Option<bool>,
152    /// Set filesystem read. Explicit true or false.
153    #[arg(long = "filesystem-read")]
154    filesystem_read: Option<bool>,
155    /// Set filesystem write. Explicit true or false.
156    #[arg(long = "filesystem-write")]
157    filesystem_write: Option<bool>,
158    /// Set filesystem path.
159    #[arg(long = "filesystem-path")]
160    filesystem_path: Option<String>,
161    /// Set gridfs enabled. Explicit true or false.
162    #[arg(long = "gridfs-enabled")]
163    gridfs_enabled: Option<bool>,
164    /// Set gridfs read. Explicit true or false.
165    #[arg(long = "gridfs-read")]
166    gridfs_read: Option<bool>,
167    /// Set gridfs write. Explicit true or false.
168    #[arg(long = "gridfs-write")]
169    gridfs_write: Option<bool>,
170    /// Set s3 enabled. Explicit true or false.
171    #[arg(long = "s3-enabled")]
172    s3_enabled: Option<bool>,
173    /// Set s3 read. Explicit true or false.
174    #[arg(long = "s3-read")]
175    s3_read: Option<bool>,
176    /// Set s3 write. Explicit true or false.
177    #[arg(long = "s3-write")]
178    s3_write: Option<bool>,
179    /// Set s3 endpoint.
180    #[arg(long = "s3-endpoint")]
181    s3_endpoint: Option<String>,
182    /// Set s3 region.
183    #[arg(long = "s3-region")]
184    s3_region: Option<String>,
185    /// Set s3 bucket.
186    #[arg(long = "s3-bucket")]
187    s3_bucket: Option<String>,
188    /// Set s3 access key id.
189    #[arg(long = "s3-access-key-id")]
190    s3_access_key_id: Option<String>,
191    /// Set s3 force path style. Explicit true or false.
192    #[arg(long = "s3-force-path-style")]
193    s3_force_path_style: Option<bool>,
194    /// Set azure enabled. Explicit true or false.
195    #[arg(long = "azure-enabled")]
196    azure_enabled: Option<bool>,
197    /// Set azure read. Explicit true or false.
198    #[arg(long = "azure-read")]
199    azure_read: Option<bool>,
200    /// Set azure write. Explicit true or false.
201    #[arg(long = "azure-write")]
202    azure_write: Option<bool>,
203    /// Set azure account name.
204    #[arg(long = "azure-account-name")]
205    azure_account_name: Option<String>,
206    /// Set azure bucket.
207    #[arg(long = "azure-bucket")]
208    azure_bucket: Option<String>,
209    /// Set gcs enabled. Explicit true or false.
210    #[arg(long = "gcs-enabled")]
211    gcs_enabled: Option<bool>,
212    /// Set gcs read. Explicit true or false.
213    #[arg(long = "gcs-read")]
214    gcs_read: Option<bool>,
215    /// Set gcs write. Explicit true or false.
216    #[arg(long = "gcs-write")]
217    gcs_write: Option<bool>,
218    /// Set gcs project id.
219    #[arg(long = "gcs-project-id")]
220    gcs_project_id: Option<String>,
221    /// Set gcs key filename.
222    #[arg(long = "gcs-key-filename")]
223    gcs_key_filename: Option<String>,
224    /// Set gcs bucket.
225    #[arg(long = "gcs-bucket")]
226    gcs_bucket: Option<String>,
227    /// Set upload max file size.
228    #[arg(long = "upload-max-file-size", allow_hyphen_values = true)]
229    upload_max_file_size: Option<SettingNumber>,
230    /// Replace allowed MIME types; repeat for each entry.
231    #[arg(
232        long = "upload-allowed-mime-type",
233        conflicts_with = "clear_upload_allowed_mime_types"
234    )]
235    upload_allowed_mime_types: Vec<String>,
236    /// Set attachments upload max bytes.
237    #[arg(long = "attachments-upload-max-bytes", allow_hyphen_values = true)]
238    attachments_upload_max_bytes: Option<NonNegativeNumber>,
239    /// Set attachments upload blocked. Explicit true or false.
240    #[arg(long = "attachments-upload-blocked")]
241    attachments_upload_blocked: Option<bool>,
242    /// Set avatars upload blocked. Explicit true or false.
243    #[arg(long = "avatars-upload-blocked")]
244    avatars_upload_blocked: Option<bool>,
245    /// Set attachments download max bytes.
246    #[arg(long = "attachments-download-max-bytes", allow_hyphen_values = true)]
247    attachments_download_max_bytes: Option<NonNegativeNumber>,
248    /// Set attachments download blocked. Explicit true or false.
249    #[arg(long = "attachments-download-blocked")]
250    attachments_download_blocked: Option<bool>,
251    /// Set api upload max bytes.
252    #[arg(long = "api-upload-max-bytes", allow_hyphen_values = true)]
253    api_upload_max_bytes: Option<NonNegativeNumber>,
254    /// Set api upload blocked. Explicit true or false.
255    #[arg(long = "api-upload-blocked")]
256    api_upload_blocked: Option<bool>,
257    /// Set api download max bytes.
258    #[arg(long = "api-download-max-bytes", allow_hyphen_values = true)]
259    api_download_max_bytes: Option<NonNegativeNumber>,
260    /// Set api download blocked. Explicit true or false.
261    #[arg(long = "api-download-blocked")]
262    api_download_blocked: Option<bool>,
263    /// Set migration auto migrate. Explicit true or false.
264    #[arg(long = "migration-auto-migrate")]
265    migration_auto_migrate: Option<bool>,
266    /// Set migration batch size.
267    #[arg(long = "migration-batch-size", allow_hyphen_values = true)]
268    migration_batch_size: Option<BatchSize>,
269    /// Set migration delay ms.
270    #[arg(long = "migration-delay-ms", allow_hyphen_values = true)]
271    migration_delay_ms: Option<DelayMs>,
272    /// Set migration cpu threshold.
273    #[arg(long = "migration-cpu-threshold", allow_hyphen_values = true)]
274    migration_cpu_threshold: Option<CpuThreshold>,
275    /// Read a storage credential from a regular UTF-8 file; blank preserves the stored secret.
276    #[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    /// Read a storage credential from UTF-8 stdin; blank preserves the stored secret.
283    #[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    /// Read a storage credential from a regular UTF-8 file; blank preserves the stored secret.
289    #[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    /// Read a storage credential from UTF-8 stdin; blank preserves the stored secret.
296    #[arg(
297        long = "azure-account-key-stdin",
298        conflicts_with = "azure_account_key_file"
299    )]
300    azure_account_key_stdin: bool,
301    /// Read a storage credential from a regular UTF-8 file; blank preserves the stored secret.
302    #[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    /// Read a storage credential from UTF-8 stdin; blank preserves the stored secret.
309    #[arg(
310        long = "azure-connection-string-stdin",
311        conflicts_with = "azure_connection_string_file"
312    )]
313    azure_connection_string_stdin: bool,
314    /// Read a storage credential from a regular UTF-8 file; blank preserves the stored secret.
315    #[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    /// Read a storage credential from UTF-8 stdin; blank preserves the stored secret.
322    #[arg(
323        long = "gcs-credentials-stdin",
324        conflicts_with = "gcs_credentials_file"
325    )]
326    gcs_credentials_stdin: bool,
327    /// Replace the allowed MIME type list with an empty array.
328    #[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}