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:
DarkSky
2026-08-13 20:30:43 +08:00
committed by GitHub
parent 18977f2048
commit 0f349af8ee
6 changed files with 150 additions and 27 deletions
@@ -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);