1use crate::{
13 CreateBucketOutcome, FileStream, StorageLayerError, StorageLayerImpl, UploadFileOptions,
14 UploadFileTag,
15};
16use aws_config::SdkConfig;
17use aws_sdk_s3::{
18 config::Credentials,
19 error::SdkError,
20 operation::{
21 create_bucket::CreateBucketError, delete_bucket::DeleteBucketError,
22 delete_object::DeleteObjectError,
23 get_bucket_lifecycle_configuration::GetBucketLifecycleConfigurationError,
24 get_object::GetObjectError, head_bucket::HeadBucketError,
25 put_bucket_cors::PutBucketCorsError,
26 put_bucket_lifecycle_configuration::PutBucketLifecycleConfigurationError,
27 put_bucket_notification_configuration::PutBucketNotificationConfigurationError,
28 put_object::PutObjectError,
29 },
30 presigning::{PresignedRequest, PresigningConfig},
31 primitives::ByteStream,
32 types::{
33 BucketLifecycleConfiguration, BucketLocationConstraint, CorsConfiguration, CorsRule,
34 CreateBucketConfiguration, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter,
35 NotificationConfiguration, QueueConfiguration, Tag,
36 },
37};
38use bytes::Bytes;
39use chrono::{DateTime, TimeDelta, Utc};
40use futures::Stream;
41use serde::{Deserialize, Serialize};
42use std::{error::Error, fmt::Debug, time::Duration};
43use thiserror::Error;
44
45type S3Client = aws_sdk_s3::Client;
46
47#[derive(Debug, Default, Clone, Deserialize, Serialize)]
49#[serde(default)]
50pub struct S3StorageLayerFactoryConfig {
51 pub endpoint: S3Endpoint,
53}
54
55#[derive(Debug, Error)]
57pub enum S3StorageLayerFactoryConfigError {
58 #[error("cannot use DOCBOX_S3_ENDPOINT without specifying DOCBOX_S3_ACCESS_KEY_ID")]
60 MissingAccessKeyId,
61
62 #[error("cannot use DOCBOX_S3_ENDPOINT without specifying DOCBOX_S3_ACCESS_KEY_SECRET")]
64 MissingAccessKeySecret,
65}
66
67impl S3StorageLayerFactoryConfig {
68 pub fn from_env() -> Result<Self, S3StorageLayerFactoryConfigError> {
70 let endpoint = S3Endpoint::from_env()?;
71
72 Ok(Self { endpoint })
73 }
74}
75
76#[derive(Default, Clone, Deserialize, Serialize)]
78#[serde(tag = "type", rename_all = "snake_case")]
79pub enum S3Endpoint {
80 #[default]
82 Aws,
83 Custom {
85 endpoint: String,
87 external_endpoint: Option<String>,
89 access_key_id: String,
91 access_key_secret: String,
93 },
94}
95
96impl Debug for S3Endpoint {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 match self {
99 Self::Aws => write!(f, "Aws"),
100 Self::Custom { endpoint, .. } => f
101 .debug_struct("Custom")
102 .field("endpoint", endpoint)
103 .finish(),
104 }
105 }
106}
107
108impl S3Endpoint {
109 pub fn from_env() -> Result<Self, S3StorageLayerFactoryConfigError> {
111 match std::env::var("DOCBOX_S3_ENDPOINT") {
112 Ok(endpoint_url) => {
114 let access_key_id = std::env::var("DOCBOX_S3_ACCESS_KEY_ID")
115 .map_err(|_| S3StorageLayerFactoryConfigError::MissingAccessKeyId)?;
116 let access_key_secret = std::env::var("DOCBOX_S3_ACCESS_KEY_SECRET")
117 .map_err(|_| S3StorageLayerFactoryConfigError::MissingAccessKeySecret)?;
118
119 let external_endpoint = std::env::var("DOCBOX_S3_EXTERNAL_ENDPOINT").ok();
120
121 Ok(S3Endpoint::Custom {
122 endpoint: endpoint_url,
123 external_endpoint,
124 access_key_id,
125 access_key_secret,
126 })
127 }
128 Err(_) => Ok(S3Endpoint::Aws),
129 }
130 }
131}
132
133#[derive(Clone)]
135pub struct S3StorageLayerFactory {
136 client: S3Client,
138 external_client: Option<S3Client>,
140}
141
142impl S3StorageLayerFactory {
143 pub fn from_config(aws_config: &SdkConfig, config: S3StorageLayerFactoryConfig) -> Self {
145 let (client, external_client) = match config.endpoint {
146 S3Endpoint::Aws => {
147 tracing::debug!("using aws s3 storage layer");
148 (S3Client::new(aws_config), None)
149 }
150 S3Endpoint::Custom {
151 endpoint,
152 external_endpoint,
153 access_key_id,
154 access_key_secret,
155 } => {
156 tracing::debug!("using custom s3 storage layer");
157 let credentials = Credentials::new(
158 access_key_id,
159 access_key_secret,
160 None,
161 None,
162 "docbox_key_provider",
163 );
164
165 let config_builder = aws_sdk_s3::config::Builder::from(aws_config)
167 .force_path_style(true)
168 .endpoint_url(endpoint)
169 .credentials_provider(credentials);
170
171 let external_client = match external_endpoint {
173 Some(external_endpoint) => {
174 let config = config_builder
175 .clone()
176 .endpoint_url(external_endpoint)
177 .build();
178 let client = S3Client::from_conf(config);
179 Some(client)
180 }
181 None => None,
182 };
183
184 let config = config_builder.build();
185 let client = S3Client::from_conf(config);
186
187 (client, external_client)
188 }
189 };
190
191 Self {
192 client,
193 external_client,
194 }
195 }
196
197 pub fn create_storage_layer(&self, bucket_name: String) -> S3StorageLayer {
199 S3StorageLayer::new(
200 self.client.clone(),
201 self.external_client.clone(),
202 bucket_name,
203 )
204 }
205}
206
207#[derive(Clone)]
209pub struct S3StorageLayer {
210 bucket_name: String,
212
213 client: S3Client,
215
216 external_client: Option<S3Client>,
218}
219
220impl S3StorageLayer {
221 fn new(client: S3Client, external_client: Option<S3Client>, bucket_name: String) -> Self {
223 Self {
224 bucket_name,
225 client,
226 external_client,
227 }
228 }
229
230 async fn m1_storage_lifecycle_rules(&self) -> Result<(), StorageLayerError> {
233 let existing_lifecycle_configuration_rules = match self
234 .client
235 .get_bucket_lifecycle_configuration()
236 .bucket(&self.bucket_name)
237 .send()
238 .await
239 .inspect_err(|error| {
240 tracing::error!(
241 ?error,
242 "failed to get existing bucket lifecycle configuration"
243 )
244 }) {
245 Ok(value) => value.rules,
246 Err(error) => match error.as_service_error() {
247 Some(error)
249 if error
250 .meta()
251 .code()
252 .is_some_and(|code| code == "NoSuchLifecycleConfiguration") =>
253 {
254 None
255 }
256
257 _ => return Err(S3StorageError::GetBucketLifecycleConfiguration(error).into()),
258 },
259 };
260
261 self.client
262 .put_bucket_lifecycle_configuration()
263 .bucket(&self.bucket_name)
264 .lifecycle_configuration(
265 BucketLifecycleConfiguration::builder()
266 .set_rules(existing_lifecycle_configuration_rules)
268 .rules(
270 LifecycleRule::builder()
271 .id("expire-1d")
272 .status(aws_sdk_s3::types::ExpirationStatus::Enabled)
273 .filter(
274 LifecycleRuleFilter::builder()
275 .tag(
276 Tag::builder()
277 .key("expire")
278 .value("1d")
279 .build()
280 .expect("invalid tag"),
281 )
282 .build(),
283 )
284 .expiration(LifecycleExpiration::builder().days(1).build())
285 .build()
286 .expect("invalid lifecycle rule configuration"),
287 )
288 .rules(
290 LifecycleRule::builder()
291 .id("expire-30d")
292 .status(aws_sdk_s3::types::ExpirationStatus::Enabled)
293 .filter(
294 LifecycleRuleFilter::builder()
295 .tag(
296 Tag::builder()
297 .key("expire")
298 .value("30d")
299 .build()
300 .expect("invalid tag"),
301 )
302 .build(),
303 )
304 .expiration(LifecycleExpiration::builder().days(30).build())
305 .build()
306 .expect("invalid lifecycle rule configuration"),
307 )
308 .build()
309 .expect("invalid lifecycle configuration"),
310 )
311 .send()
312 .await
313 .inspect_err(|error| {
314 tracing::error!(?error, "failed to put bucket lifecycle configuration")
315 })
316 .map_err(S3StorageError::PutBucketLifecycleConfiguration)?;
317
318 Ok(())
319 }
320}
321
322#[derive(Debug, Error)]
327pub enum S3StorageError {
328 #[error("invalid server configuration (region)")]
330 MissingRegion,
331
332 #[error("failed to create storage bucket")]
334 CreateBucket(SdkError<CreateBucketError>),
335
336 #[error("failed to delete storage bucket")]
338 DeleteBucket(SdkError<DeleteBucketError>),
339
340 #[error("failed to get storage bucket")]
342 HeadBucket(SdkError<HeadBucketError>),
343
344 #[error("failed to store file object")]
346 PutObject(SdkError<PutObjectError>),
347
348 #[error("failed to calculate expiry timestamp")]
350 UnixTimeCalculation,
351
352 #[error("failed to create presigned store file object")]
354 PutObjectPresigned(SdkError<PutObjectError>),
355
356 #[error("failed to create presigned config")]
358 PresignedConfig,
359
360 #[error("failed to get presigned store file object")]
362 GetObjectPresigned(SdkError<GetObjectError>),
363
364 #[error("failed to create bucket notification queue config")]
366 QueueConfig,
367
368 #[error("failed to add bucket notification queue: {0}")]
374 PutBucketNotification(SdkError<PutBucketNotificationConfigurationError>),
375
376 #[error("failed to create bucket cors config")]
378 CreateCorsConfig,
379
380 #[error("failed to set bucket cors rules: {0}")]
386 PutBucketCors(SdkError<PutBucketCorsError>),
387
388 #[error("failed to delete file object")]
390 DeleteObject(SdkError<DeleteObjectError>),
391
392 #[error("failed to get file storage object")]
394 GetObject(SdkError<GetObjectError>),
395
396 #[error("failed to get bucket lifecycle configuration: {0}")]
402 GetBucketLifecycleConfiguration(SdkError<GetBucketLifecycleConfigurationError>),
403
404 #[error("failed to put bucket lifecycle configuration: {0}")]
410 PutBucketLifecycleConfiguration(SdkError<PutBucketLifecycleConfigurationError>),
411}
412
413const MIGRATION_NAMES: &[&str] = &["m1_storage_lifecycle_rules"];
414
415impl StorageLayerImpl for S3StorageLayer {
416 fn bucket_name(&self) -> String {
417 self.bucket_name.clone()
418 }
419
420 async fn create_bucket(&self) -> Result<CreateBucketOutcome, StorageLayerError> {
421 let bucket_region = self
422 .client
423 .config()
424 .region()
425 .ok_or(S3StorageError::MissingRegion)?
426 .to_string();
427
428 let mut builder = self.client.create_bucket().bucket(&self.bucket_name);
429
430 if bucket_region != "us-east-1" {
435 let constraint = BucketLocationConstraint::from(bucket_region.as_str());
436 let cfg = CreateBucketConfiguration::builder()
437 .location_constraint(constraint)
438 .build();
439 builder = builder.create_bucket_configuration(cfg)
440 }
441
442 if let Err(error) = builder.send().await {
443 let already_exists = error
444 .as_service_error()
445 .is_some_and(|value| value.is_bucket_already_owned_by_you());
446
447 if already_exists {
449 tracing::debug!("bucket already exists");
450 return Ok(CreateBucketOutcome::Existing);
451 }
452
453 tracing::error!(?error, "failed to create bucket");
454 return Err(S3StorageError::CreateBucket(error).into());
455 }
456
457 Ok(CreateBucketOutcome::New)
458 }
459
460 async fn bucket_exists(&self) -> Result<bool, StorageLayerError> {
461 if let Err(error) = self
462 .client
463 .head_bucket()
464 .bucket(&self.bucket_name)
465 .send()
466 .await
467 {
468 if error
470 .as_service_error()
471 .is_some_and(|error| error.is_not_found())
472 {
473 return Ok(false);
474 }
475
476 return Err(S3StorageError::HeadBucket(error).into());
477 }
478
479 Ok(true)
480 }
481
482 async fn delete_bucket(&self) -> Result<(), StorageLayerError> {
483 if let Err(error) = self
484 .client
485 .delete_bucket()
486 .bucket(&self.bucket_name)
487 .send()
488 .await
489 {
490 if error
493 .as_service_error()
494 .and_then(|err| err.meta().code())
495 .is_some_and(|code| code == "NoSuchBucket")
496 {
497 tracing::debug!("bucket did not exist");
498 return Ok(());
499 }
500
501 tracing::error!(?error, "failed to delete bucket");
502
503 return Err(S3StorageError::DeleteBucket(error).into());
504 }
505
506 Ok(())
507 }
508
509 async fn upload_file(
510 &self,
511 key: &str,
512 body: Bytes,
513 options: UploadFileOptions,
514 ) -> Result<(), StorageLayerError> {
515 let tagging = options.tags.map(|tags| {
516 use itertools::Itertools;
517
518 tags.into_iter()
519 .map(|tag| match tag {
520 UploadFileTag::ExpireDays1 => "expire=1d",
521 UploadFileTag::ExpireDays30 => "expire=30d",
522 })
523 .join("&")
524 });
525
526 self.client
527 .put_object()
528 .bucket(&self.bucket_name)
529 .content_type(options.content_type)
530 .key(key)
531 .set_tagging(tagging)
532 .body(body.into())
533 .send()
534 .await
535 .map_err(|error| {
536 tracing::error!(?error, "failed to store file object");
537 S3StorageError::PutObject(error)
538 })?;
539
540 Ok(())
541 }
542
543 async fn create_presigned(
544 &self,
545 key: &str,
546 size: i64,
547 ) -> Result<(PresignedRequest, DateTime<Utc>), StorageLayerError> {
548 let expiry_time_minutes = 30;
549 let expires_at = Utc::now()
550 .checked_add_signed(TimeDelta::minutes(expiry_time_minutes))
551 .ok_or(S3StorageError::UnixTimeCalculation)?;
552
553 let client = match self.external_client.as_ref() {
554 Some(external_client) => external_client,
555 None => &self.client,
556 };
557
558 let result = client
559 .put_object()
560 .bucket(&self.bucket_name)
561 .key(key)
562 .content_length(size)
563 .presigned(
564 PresigningConfig::builder()
565 .expires_in(Duration::from_secs(60 * expiry_time_minutes as u64))
566 .build()
567 .map_err(|error| {
568 tracing::error!(?error, "Failed to create presigned store config");
569 S3StorageError::PresignedConfig
570 })?,
571 )
572 .await
573 .map_err(|error| {
574 tracing::error!(?error, "failed to create presigned store file object");
575 S3StorageError::PutObjectPresigned(error)
576 })?;
577
578 Ok((result, expires_at))
579 }
580
581 async fn create_presigned_download(
582 &self,
583 key: &str,
584 expires_in: Duration,
585 ) -> Result<(PresignedRequest, DateTime<Utc>), StorageLayerError> {
586 let expires_at = Utc::now()
587 .checked_add_signed(TimeDelta::seconds(expires_in.as_secs() as i64))
588 .ok_or(S3StorageError::UnixTimeCalculation)?;
589
590 let client = match self.external_client.as_ref() {
591 Some(external_client) => external_client,
592 None => &self.client,
593 };
594
595 let result = client
596 .get_object()
597 .bucket(&self.bucket_name)
598 .key(key)
599 .presigned(PresigningConfig::expires_in(expires_in).map_err(|error| {
600 tracing::error!(?error, "failed to create presigned download config");
601 S3StorageError::PresignedConfig
602 })?)
603 .await
604 .map_err(|error| {
605 tracing::error!(?error, "failed to create presigned download");
606 S3StorageError::GetObjectPresigned(error)
607 })?;
608
609 Ok((result, expires_at))
610 }
611
612 async fn add_bucket_notifications(&self, sqs_arn: &str) -> Result<(), StorageLayerError> {
613 self.client
615 .put_bucket_notification_configuration()
616 .bucket(&self.bucket_name)
617 .notification_configuration(
618 NotificationConfiguration::builder()
619 .set_queue_configurations(Some(vec![
620 QueueConfiguration::builder()
621 .queue_arn(sqs_arn)
622 .events(aws_sdk_s3::types::Event::S3ObjectCreated)
623 .build()
624 .map_err(|error| {
625 tracing::error!(
626 ?error,
627 "failed to create bucket notification queue config"
628 );
629 S3StorageError::QueueConfig
630 })?,
631 ]))
632 .build(),
633 )
634 .send()
635 .await
636 .map_err(|error| {
637 tracing::error!(?error, "failed to add bucket notification queue");
638 S3StorageError::PutBucketNotification(error)
639 })?;
640
641 Ok(())
642 }
643
644 async fn set_bucket_cors_origins(&self, origins: Vec<String>) -> Result<(), StorageLayerError> {
645 if let Err(error) = self
646 .client
647 .put_bucket_cors()
648 .bucket(&self.bucket_name)
649 .cors_configuration(
650 CorsConfiguration::builder()
651 .cors_rules(
652 CorsRule::builder()
653 .allowed_headers("*")
654 .allowed_methods("PUT")
655 .set_allowed_origins(Some(origins))
656 .set_expose_headers(Some(Vec::new()))
657 .build()
658 .map_err(|error| {
659 tracing::error!(?error, "failed to create cors rule");
660 S3StorageError::CreateCorsConfig
661 })?,
662 )
663 .build()
664 .map_err(|error| {
665 tracing::error!(?error, "failed to create cors config");
666 S3StorageError::CreateCorsConfig
667 })?,
668 )
669 .send()
670 .await
671 {
672 if error
674 .raw_response()
675 .is_some_and(|response| response.status().as_u16() == 501)
677 {
678 tracing::warn!("storage s3 backend does not support PutBucketCors.. skipping..");
679 return Ok(());
680 }
681
682 tracing::error!(?error, "failed to add bucket cors");
683 return Err(S3StorageError::PutBucketCors(error).into());
684 };
685
686 Ok(())
687 }
688
689 async fn delete_file(&self, key: &str) -> Result<(), StorageLayerError> {
690 if let Err(error) = self
691 .client
692 .delete_object()
693 .bucket(&self.bucket_name)
694 .key(key)
695 .send()
696 .await
697 {
698 if error
701 .as_service_error()
702 .and_then(|err| err.source())
703 .and_then(|source| source.downcast_ref::<aws_sdk_s3::Error>())
704 .is_some_and(|err| matches!(err, aws_sdk_s3::Error::NoSuchKey(_)))
705 {
706 return Ok(());
707 }
708
709 tracing::error!(?error, "failed to delete file object");
710 return Err(S3StorageError::DeleteObject(error).into());
711 }
712
713 Ok(())
714 }
715
716 async fn get_file(&self, key: &str) -> Result<FileStream, StorageLayerError> {
717 let object = self
718 .client
719 .get_object()
720 .bucket(&self.bucket_name)
721 .key(key)
722 .send()
723 .await
724 .map_err(|error| {
725 tracing::error!(?error, "failed to get file storage object");
726 S3StorageError::GetObject(error)
727 })?;
728
729 let stream = FileStream {
730 stream: Box::pin(AwsFileStream { inner: object.body }),
731 };
732
733 Ok(stream)
734 }
735
736 async fn get_pending_migrations(
737 &self,
738 applied_names: Vec<String>,
739 ) -> Result<Vec<String>, StorageLayerError> {
740 Ok(MIGRATION_NAMES
741 .iter()
742 .map(|name| name.to_string())
743 .filter(|name| !applied_names.contains(name))
744 .collect())
745 }
746
747 async fn apply_migration(&self, name: &str) -> Result<(), StorageLayerError> {
748 #[allow(clippy::single_match)]
749 match name {
750 "m1_storage_lifecycle_rules" => self.m1_storage_lifecycle_rules().await,
751 _ => Ok(()),
752 }
753 }
754}
755
756pub struct AwsFileStream {
758 inner: ByteStream,
759}
760
761impl AwsFileStream {
762 pub fn into_inner(self) -> ByteStream {
764 self.inner
765 }
766}
767
768impl Stream for AwsFileStream {
769 type Item = std::io::Result<Bytes>;
770
771 fn poll_next(
772 self: std::pin::Pin<&mut Self>,
773 cx: &mut std::task::Context<'_>,
774 ) -> std::task::Poll<Option<Self::Item>> {
775 let this = self.get_mut();
776 let inner = std::pin::Pin::new(&mut this.inner);
777 inner.poll_next(cx).map_err(std::io::Error::other)
778 }
779}