mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 18:41:52 +08:00
fix(server): storage prefix url (#15477)
#### PR Dependency Tree * **PR #15477** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for custom signed download URLs with configurable CDN origins, object paths, expiration times, and security signatures. * Custom download signing is available when both the URL prefix and signing key are configured. * **Bug Fixes** * Preserved custom URL and signing settings when generating download links. * Added validation for invalid signing configurations. * Standard S3 storage continues using its existing presigned URL behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use rusty_s3::{Bucket, Credentials, UrlStyle};
|
||||
use serde::Deserialize;
|
||||
use sha2::Sha256;
|
||||
use url::Url;
|
||||
|
||||
use super::{
|
||||
client::ObjectStorageClient,
|
||||
error::{ObjectStorageError, ObjectStorageResult},
|
||||
types::StorageProviderConfig,
|
||||
types::{ObjectKey, PresignedObjectRequest, StorageProviderConfig},
|
||||
};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ObjectStorageConfig {
|
||||
pub(crate) provider: String,
|
||||
@@ -24,6 +31,8 @@ pub(crate) struct ObjectStorageConfig {
|
||||
pub(crate) presign_sign_content_type_for_put: Option<bool>,
|
||||
pub(crate) use_presigned_url: bool,
|
||||
pub(crate) proxy_upload: bool,
|
||||
pub(crate) custom_get_url_prefix: Option<String>,
|
||||
pub(crate) custom_get_sign_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -124,6 +133,8 @@ impl ObjectStorageConfig {
|
||||
presign_sign_content_type_for_put: config.presign.as_ref().and_then(|v| v.sign_content_type_for_put),
|
||||
use_presigned_url: config.use_presigned_url.map(|v| v.enabled).unwrap_or(false),
|
||||
proxy_upload: false,
|
||||
custom_get_url_prefix: None,
|
||||
custom_get_sign_key: None,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -135,17 +146,25 @@ impl ObjectStorageConfig {
|
||||
Some(R2Jurisdiction::Default) | None => config.account_id,
|
||||
};
|
||||
let credentials = config.credentials.unwrap_or_default();
|
||||
let (use_presigned_url, proxy_upload) = config
|
||||
let (use_presigned_url, proxy_upload, custom_get_url_prefix, custom_get_sign_key) = config
|
||||
.use_presigned_url
|
||||
.map(|value| {
|
||||
let url_prefix = value.url_prefix.filter(|prefix| !prefix.is_empty());
|
||||
let sign_key = value.sign_key.filter(|key| !key.is_empty());
|
||||
let custom_get_enabled = value.enabled && url_prefix.is_some() && sign_key.is_some();
|
||||
let (custom_get_url_prefix, custom_get_sign_key) = if custom_get_enabled {
|
||||
(url_prefix, sign_key)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
(
|
||||
value.enabled,
|
||||
value.enabled
|
||||
&& value.url_prefix.as_ref().is_some_and(|prefix| !prefix.is_empty())
|
||||
&& value.sign_key.as_ref().is_some_and(|key| !key.is_empty()),
|
||||
custom_get_enabled,
|
||||
custom_get_url_prefix,
|
||||
custom_get_sign_key,
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
.unwrap_or((false, false, None, None));
|
||||
|
||||
Ok(Some(Self {
|
||||
provider: storage.provider,
|
||||
@@ -162,6 +181,57 @@ impl ObjectStorageConfig {
|
||||
presign_sign_content_type_for_put: config.presign.as_ref().and_then(|v| v.sign_content_type_for_put),
|
||||
use_presigned_url,
|
||||
proxy_upload,
|
||||
custom_get_url_prefix,
|
||||
custom_get_sign_key,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn custom_presign_get(&self, key: &ObjectKey) -> ObjectStorageResult<Option<PresignedObjectRequest>> {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|err| ObjectStorageError::Config(format!("system time before unix epoch: {err}")))?
|
||||
.as_secs();
|
||||
self.custom_presign_get_at(key, timestamp)
|
||||
}
|
||||
|
||||
pub(super) fn custom_presign_get_at(
|
||||
&self,
|
||||
key: &ObjectKey,
|
||||
timestamp: u64,
|
||||
) -> ObjectStorageResult<Option<PresignedObjectRequest>> {
|
||||
let (Some(prefix), Some(sign_key)) = (
|
||||
self.custom_get_url_prefix.as_deref(),
|
||||
self.custom_get_sign_key.as_deref(),
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut url = Url::parse(prefix)
|
||||
.map_err(|err| ObjectStorageError::Config(format!("invalid object storage URL prefix: {err}")))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.query().is_some() || url.fragment().is_some() {
|
||||
return Err(ObjectStorageError::Config(
|
||||
"object storage URL prefix must be an HTTP(S) URL without query or fragment".to_string(),
|
||||
));
|
||||
}
|
||||
url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| ObjectStorageError::Config("object storage URL prefix cannot be a base URL".to_string()))?
|
||||
.pop_if_empty()
|
||||
.extend(key.as_str().split('/'));
|
||||
let payload = format!("{}{timestamp}", url.path());
|
||||
let mut mac = HmacSha256::new_from_slice(sign_key.as_bytes())
|
||||
.map_err(|err| ObjectStorageError::Config(format!("invalid object storage signing key: {err}")))?;
|
||||
mac.update(payload.as_bytes());
|
||||
let signature = STANDARD.encode(mac.finalize().into_bytes());
|
||||
url
|
||||
.query_pairs_mut()
|
||||
.append_pair("sign", &format!("{timestamp}-{signature}"));
|
||||
|
||||
Ok(Some(PresignedObjectRequest {
|
||||
url: url.to_string(),
|
||||
headers: Default::default(),
|
||||
expires_at_ms: i64::try_from(timestamp.saturating_add(self.presign_expires_in_seconds.unwrap_or(60)))
|
||||
.unwrap_or(i64::MAX)
|
||||
.saturating_mul(1000),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -182,12 +182,17 @@ impl ObjectStorageService {
|
||||
) -> RuntimeResult<Option<PresignedObjectRequest>> {
|
||||
match self.backend_for_scope(locator.scope)? {
|
||||
StorageBackendConfig::Fs(_) | StorageBackendConfig::Assetpack(_) => Ok(None),
|
||||
StorageBackendConfig::S3(config) => config
|
||||
.build_client()?
|
||||
.presign_get(&locator.key)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(Into::into),
|
||||
StorageBackendConfig::S3(config) => {
|
||||
if let Some(request) = config.custom_presign_get(&locator.key)? {
|
||||
return Ok(Some(request));
|
||||
}
|
||||
config
|
||||
.build_client()?
|
||||
.presign_get(&locator.key)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +261,7 @@ fn resolves_r2_proxy_upload_capability_from_config_json_shape() {
|
||||
},
|
||||
"usePresignedURL": {
|
||||
"enabled": true,
|
||||
"urlPrefix": "https://cdn.example.com",
|
||||
"urlPrefix": "https://cdn.example.com/storage/",
|
||||
"signKey": "secret"
|
||||
}
|
||||
}),
|
||||
@@ -271,6 +271,50 @@ fn resolves_r2_proxy_upload_capability_from_config_json_shape() {
|
||||
|
||||
assert!(config.use_presigned_url);
|
||||
assert!(config.proxy_upload);
|
||||
let request = config
|
||||
.custom_presign_get_at(&ObjectKey::new("workspace/blob.m4a").unwrap(), 1_700_000_000)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let url = url::Url::parse(&request.url).unwrap();
|
||||
assert_eq!(url.origin().ascii_serialization(), "https://cdn.example.com");
|
||||
assert_eq!(url.path(), "/storage/workspace/blob.m4a");
|
||||
assert_eq!(
|
||||
url.query_pairs().find(|(key, _)| key == "sign").unwrap().1,
|
||||
"1700000000-01IngHvoE2trslxVyYUzfWkhgdlYdpcRXcpSYqZ9gkc="
|
||||
);
|
||||
|
||||
for use_presigned_url in [
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"urlPrefix": "https://cdn.example.com"
|
||||
}),
|
||||
serde_json::json!({
|
||||
"enabled": true,
|
||||
"urlPrefix": "https://cdn.example.com",
|
||||
"signKey": ""
|
||||
}),
|
||||
] {
|
||||
let storage = StorageProviderConfig {
|
||||
provider: "cloudflare-r2".to_string(),
|
||||
bucket: "workspace-blobs".to_string(),
|
||||
config: serde_json::json!({
|
||||
"accountId": "account",
|
||||
"credentials": {
|
||||
"accessKeyId": "key",
|
||||
"secretAccessKey": "secret"
|
||||
},
|
||||
"usePresignedURL": use_presigned_url
|
||||
}),
|
||||
};
|
||||
let config = ObjectStorageConfig::from_r2_config(storage).unwrap().unwrap();
|
||||
assert!(!config.proxy_upload);
|
||||
assert!(
|
||||
config
|
||||
.custom_presign_get_at(&ObjectKey::new("workspace/blob.m4a").unwrap(), 1_700_000_000)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -96,6 +96,8 @@ mod tests {
|
||||
presign_sign_content_type_for_put: Some(true),
|
||||
use_presigned_url: true,
|
||||
proxy_upload: false,
|
||||
custom_get_url_prefix: None,
|
||||
custom_get_sign_key: None,
|
||||
}));
|
||||
|
||||
assert!(capabilities.presign_put);
|
||||
@@ -121,6 +123,8 @@ mod tests {
|
||||
presign_sign_content_type_for_put: Some(true),
|
||||
use_presigned_url: true,
|
||||
proxy_upload: true,
|
||||
custom_get_url_prefix: None,
|
||||
custom_get_sign_key: None,
|
||||
}));
|
||||
|
||||
assert!(capabilities.proxy_upload);
|
||||
|
||||
Reference in New Issue
Block a user