feat: add configurable S3 object storage

This commit is contained in:
237899745
2026-07-25 13:23:11 +08:00
parent 61fa9cb820
commit d1f093685d
29 changed files with 3703 additions and 284 deletions

View File

@@ -40,7 +40,7 @@ pub fn router() -> Router<AppState> {
.route("/admin/config", put(update_config))
}
async fn require_admin(
pub(super) async fn require_admin(
state: &AppState,
jar: axum_extra::extract::cookie::CookieJar,
headers: &HeaderMap,

761
src/api/admin_storage.rs Normal file
View File

@@ -0,0 +1,761 @@
use crate::api::admin::require_admin;
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::error::{AppError, ErrorCode};
use crate::services::{settings, storage};
use crate::state::AppState;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::HeaderMap;
use axum::routing::{get, post, put};
use axum::{Json, Router};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, SocketAddr};
use url::Url;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
.route(
"/admin/storage/endpoints",
get(list_storage_endpoints).post(create_storage_endpoint),
)
.route(
"/admin/storage/endpoints/{endpoint_id}",
put(update_storage_endpoint).delete(delete_storage_endpoint),
)
.route(
"/admin/storage/endpoints/{endpoint_id}/test",
post(test_storage_endpoint),
)
.route(
"/admin/storage/endpoints/{endpoint_id}/activate",
post(activate_storage_endpoint),
)
}
#[derive(Debug, Serialize)]
struct StorageEndpointView {
id: Uuid,
name: String,
internal_endpoint: String,
public_endpoint: String,
bucket: String,
region: String,
access_key_hint: String,
credentials_configured: bool,
force_path_style: bool,
presign_ttl_seconds: i32,
is_active: bool,
last_test_at: Option<DateTime<Utc>>,
last_test_ok: Option<bool>,
last_test_error: Option<String>,
object_count: i64,
stored_bytes: i64,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize)]
struct StorageEndpointsResponse {
active_backend: String,
endpoints: Vec<StorageEndpointView>,
}
#[derive(Debug, Deserialize)]
struct CreateStorageEndpointRequest {
name: String,
internal_endpoint: String,
public_endpoint: String,
bucket: String,
region: Option<String>,
access_key: String,
secret_key: String,
force_path_style: Option<bool>,
presign_ttl_seconds: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct UpdateStorageEndpointRequest {
name: Option<String>,
internal_endpoint: Option<String>,
public_endpoint: Option<String>,
bucket: Option<String>,
region: Option<String>,
access_key: Option<String>,
secret_key: Option<String>,
force_path_style: Option<bool>,
presign_ttl_seconds: Option<i32>,
}
#[derive(Debug, Serialize)]
struct StorageActionResponse {
message: String,
endpoint: StorageEndpointView,
}
async fn list_storage_endpoints(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
) -> Result<Json<Envelope<StorageEndpointsResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?;
let endpoints = storage::list_endpoints(&state).await?;
let active_backend = if endpoints.iter().any(|endpoint| endpoint.is_active) {
"s3"
} else {
"local"
};
let mut views = Vec::with_capacity(endpoints.len());
for endpoint in endpoints {
views.push(endpoint_view(&state, endpoint).await?);
}
Ok(Json(Envelope {
success: true,
data: StorageEndpointsResponse {
active_backend: active_backend.to_string(),
endpoints: views,
},
}))
}
async fn create_storage_endpoint(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<CreateStorageEndpointRequest>,
) -> Result<Json<Envelope<StorageEndpointView>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let name = validate_name(&req.name)?;
ensure_name_available(&state, &name, None).await?;
let internal_endpoint = validate_endpoint_url(&req.internal_endpoint, "内部 Endpoint")?;
let public_endpoint = validate_endpoint_url(&req.public_endpoint, "公网 Endpoint")?;
let bucket = validate_bucket(&req.bucket)?;
let region = validate_region(req.region.as_deref().unwrap_or("garage"))?;
let access_key = validate_credential(&req.access_key, "Access Key")?;
let secret_key = validate_credential(&req.secret_key, "Secret Key")?;
let presign_ttl_seconds = validate_presign_ttl(req.presign_ttl_seconds.unwrap_or(300))?;
let endpoint = sqlx::query_as::<_, storage::StorageEndpoint>(
r#"
INSERT INTO storage_endpoints (
name, internal_endpoint, public_endpoint, bucket, region,
access_key_encrypted, secret_key_encrypted, access_key_hint,
force_path_style, presign_ttl_seconds, updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, name, internal_endpoint, public_endpoint, bucket, region,
access_key_encrypted, secret_key_encrypted, access_key_hint,
force_path_style, presign_ttl_seconds, is_active,
last_test_at, last_test_ok, last_test_error,
created_at, updated_at, deleted_at
"#,
)
.bind(&name)
.bind(&internal_endpoint)
.bind(&public_endpoint)
.bind(&bucket)
.bind(&region)
.bind(settings::encrypt_secret(&state, &access_key)?)
.bind(settings::encrypt_secret(&state, &secret_key)?)
.bind(access_key_hint(&access_key))
.bind(req.force_path_style.unwrap_or(true))
.bind(presign_ttl_seconds)
.bind(admin_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建存储端点失败").with_source(err))?;
audit_storage_action(
&state,
admin_id,
ip,
"storage_endpoint.create",
endpoint.id,
serde_json::json!({ "name": endpoint.name }),
)
.await;
Ok(Json(Envelope {
success: true,
data: endpoint_view(&state, endpoint).await?,
}))
}
async fn update_storage_endpoint(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(endpoint_id): Path<Uuid>,
Json(req): Json<UpdateStorageEndpointRequest>,
) -> Result<Json<Envelope<StorageEndpointView>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let existing = storage::get_endpoint(&state, endpoint_id).await?;
ensure_configurable(&existing)?;
if existing.is_active {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"活动端点不能直接编辑,请先新增并启用替代端点",
));
}
let (object_count, _) = endpoint_usage(&state, endpoint_id).await?;
if object_count > 0 {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"端点仍有关联对象,不能修改;请新增替代端点并等待旧对象过期",
));
}
let name = match req.name.as_deref() {
Some(value) => validate_name(value)?,
None => existing.name.clone(),
};
ensure_name_available(&state, &name, Some(endpoint_id)).await?;
let internal_endpoint = match req.internal_endpoint.as_deref() {
Some(value) => validate_endpoint_url(value, "内部 Endpoint")?,
None => existing.internal_endpoint.clone(),
};
let public_endpoint = match req.public_endpoint.as_deref() {
Some(value) => validate_endpoint_url(value, "公网 Endpoint")?,
None => existing.public_endpoint.clone(),
};
let bucket = match req.bucket.as_deref() {
Some(value) => validate_bucket(value)?,
None => existing.bucket.clone(),
};
let region = match req.region.as_deref() {
Some(value) => validate_region(value)?,
None => existing.region.clone(),
};
let access_key_encrypted = match req.access_key.as_deref() {
Some(value) if !value.trim().is_empty() => Some(settings::encrypt_secret(
&state,
&validate_credential(value, "Access Key")?,
)?),
_ => None,
};
let secret_key_encrypted = match req.secret_key.as_deref() {
Some(value) if !value.trim().is_empty() => Some(settings::encrypt_secret(
&state,
&validate_credential(value, "Secret Key")?,
)?),
_ => None,
};
let access_key_hint_value = req
.access_key
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(access_key_hint);
let presign_ttl_seconds = validate_presign_ttl(
req.presign_ttl_seconds
.unwrap_or(existing.presign_ttl_seconds),
)?;
let endpoint = sqlx::query_as::<_, storage::StorageEndpoint>(
r#"
UPDATE storage_endpoints
SET name = $2,
internal_endpoint = $3,
public_endpoint = $4,
bucket = $5,
region = $6,
access_key_encrypted = COALESCE($7, access_key_encrypted),
secret_key_encrypted = COALESCE($8, secret_key_encrypted),
access_key_hint = COALESCE($9, access_key_hint),
force_path_style = $10,
presign_ttl_seconds = $11,
is_active = false,
last_test_at = NULL,
last_test_ok = NULL,
last_test_error = NULL,
updated_at = NOW(),
updated_by = $12
WHERE id = $1
RETURNING id, name, internal_endpoint, public_endpoint, bucket, region,
access_key_encrypted, secret_key_encrypted, access_key_hint,
force_path_style, presign_ttl_seconds, is_active,
last_test_at, last_test_ok, last_test_error,
created_at, updated_at, deleted_at
"#,
)
.bind(endpoint_id)
.bind(&name)
.bind(&internal_endpoint)
.bind(&public_endpoint)
.bind(&bucket)
.bind(&region)
.bind(access_key_encrypted)
.bind(secret_key_encrypted)
.bind(access_key_hint_value)
.bind(req.force_path_style.unwrap_or(existing.force_path_style))
.bind(presign_ttl_seconds)
.bind(admin_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "更新存储端点失败").with_source(err))?;
audit_storage_action(
&state,
admin_id,
ip,
"storage_endpoint.update",
endpoint.id,
serde_json::json!({ "name": endpoint.name, "requires_reactivation": true }),
)
.await;
Ok(Json(Envelope {
success: true,
data: endpoint_view(&state, endpoint).await?,
}))
}
async fn test_storage_endpoint(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(endpoint_id): Path<Uuid>,
) -> Result<Json<Envelope<StorageActionResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
ensure_configurable(&endpoint)?;
if let Err(err) = storage::test_endpoint(&state, &endpoint).await {
let detail = storage_test_message(&err);
record_test_result(&state, endpoint_id, false, Some(&detail), admin_id).await?;
audit_storage_action(
&state,
admin_id,
ip,
"storage_endpoint.test_failed",
endpoint_id,
serde_json::json!({ "message": detail }),
)
.await;
return Err(AppError::new(ErrorCode::StorageUnavailable, detail));
}
record_test_result(&state, endpoint_id, true, None, admin_id).await?;
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
ensure_configurable(&endpoint)?;
audit_storage_action(
&state,
admin_id,
ip,
"storage_endpoint.test_succeeded",
endpoint_id,
serde_json::json!({}),
)
.await;
Ok(Json(Envelope {
success: true,
data: StorageActionResponse {
message: "S3 内部读写删与公网签名下载测试通过".to_string(),
endpoint: endpoint_view(&state, endpoint).await?,
},
}))
}
async fn activate_storage_endpoint(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(endpoint_id): Path<Uuid>,
) -> Result<Json<Envelope<StorageActionResponse>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
ensure_configurable(&endpoint)?;
let tested_config_updated_at = endpoint.updated_at;
if let Err(err) = storage::test_endpoint(&state, &endpoint).await {
let detail = storage_test_message(&err);
record_test_result(&state, endpoint_id, false, Some(&detail), admin_id).await?;
return Err(AppError::new(ErrorCode::StorageUnavailable, detail));
}
let mut tx = state.db.begin().await.map_err(|err| {
AppError::new(ErrorCode::Internal, "开启存储切换事务失败").with_source(err)
})?;
sqlx::query(
"UPDATE storage_endpoints SET is_active = false, updated_at = NOW() WHERE is_active = true",
)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "停用旧存储端点失败").with_source(err))?;
let activated = sqlx::query(
r#"
UPDATE storage_endpoints
SET is_active = true,
last_test_at = NOW(),
last_test_ok = true,
last_test_error = NULL,
updated_at = NOW(),
updated_by = $2
WHERE id = $1 AND updated_at = $3 AND deleted_at IS NULL
"#,
)
.bind(endpoint_id)
.bind(admin_id)
.bind(tested_config_updated_at)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "启用存储端点失败").with_source(err))?;
if activated.rows_affected() == 0 {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"端点配置在测试期间发生变化,请重新测试",
));
}
tx.commit().await.map_err(|err| {
AppError::new(ErrorCode::Internal, "提交存储切换事务失败").with_source(err)
})?;
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
audit_storage_action(
&state,
admin_id,
ip,
"storage_endpoint.activate",
endpoint_id,
serde_json::json!({ "name": endpoint.name }),
)
.await;
Ok(Json(Envelope {
success: true,
data: StorageActionResponse {
message: "S3 端点已启用,新生成文件将写入该端点".to_string(),
endpoint: endpoint_view(&state, endpoint).await?,
},
}))
}
async fn delete_storage_endpoint(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Path(endpoint_id): Path<Uuid>,
) -> Result<Json<Envelope<serde_json::Value>>, AppError> {
let ip = context::client_ip(&headers, addr.ip());
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
ensure_configurable(&endpoint)?;
if endpoint.is_active {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"活动端点不能删除,请先启用其他端点",
));
}
let (object_count, _): (i64, i64) = endpoint_usage(&state, endpoint_id).await?;
if object_count > 0 {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("端点仍关联 {object_count} 个对象,需等待对象过期清理后再删除"),
));
}
sqlx::query(
"UPDATE storage_endpoints SET is_active = false, deleted_at = NOW(), updated_at = NOW(), updated_by = $2 WHERE id = $1",
)
.bind(endpoint_id)
.bind(admin_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "归档存储端点失败").with_source(err))?;
audit_storage_action(
&state,
admin_id,
ip,
"storage_endpoint.delete",
endpoint_id,
serde_json::json!({ "name": endpoint.name }),
)
.await;
Ok(Json(Envelope {
success: true,
data: serde_json::json!({ "message": "存储端点已删除" }),
}))
}
async fn endpoint_view(
state: &AppState,
endpoint: storage::StorageEndpoint,
) -> Result<StorageEndpointView, AppError> {
let (object_count, stored_bytes) = endpoint_usage(state, endpoint.id).await?;
Ok(StorageEndpointView {
id: endpoint.id,
name: endpoint.name,
internal_endpoint: endpoint.internal_endpoint,
public_endpoint: endpoint.public_endpoint,
bucket: endpoint.bucket,
region: endpoint.region,
access_key_hint: endpoint.access_key_hint,
credentials_configured: !endpoint.access_key_encrypted.is_empty()
&& !endpoint.secret_key_encrypted.is_empty(),
force_path_style: endpoint.force_path_style,
presign_ttl_seconds: endpoint.presign_ttl_seconds,
is_active: endpoint.is_active,
last_test_at: endpoint.last_test_at,
last_test_ok: endpoint.last_test_ok,
last_test_error: endpoint.last_test_error,
object_count,
stored_bytes,
created_at: endpoint.created_at,
updated_at: endpoint.updated_at,
})
}
async fn endpoint_usage(state: &AppState, endpoint_id: Uuid) -> Result<(i64, i64), AppError> {
sqlx::query_as::<_, (i64, i64)>(
r#"
SELECT
(SELECT COUNT(*) FROM task_files WHERE storage_endpoint_id = $1)
+ (SELECT COUNT(*) FROM tasks WHERE zip_storage_endpoint_id = $1) AS object_count,
COALESCE((SELECT SUM(compressed_size)::BIGINT FROM task_files WHERE storage_endpoint_id = $1), 0::BIGINT)
+ COALESCE((SELECT SUM(zip_size)::BIGINT FROM tasks WHERE zip_storage_endpoint_id = $1), 0::BIGINT) AS stored_bytes
"#,
)
.bind(endpoint_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "统计存储使用量失败").with_source(err))
}
async fn record_test_result(
state: &AppState,
endpoint_id: Uuid,
ok: bool,
error: Option<&str>,
admin_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE storage_endpoints
SET last_test_at = NOW(),
last_test_ok = $2,
last_test_error = $3,
updated_at = NOW(),
updated_by = $4
WHERE id = $1
"#,
)
.bind(endpoint_id)
.bind(ok)
.bind(error)
.bind(admin_id)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "记录存储测试结果失败").with_source(err))?;
Ok(())
}
async fn ensure_name_available(
state: &AppState,
name: &str,
except_id: Option<Uuid>,
) -> Result<(), AppError> {
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM storage_endpoints WHERE deleted_at IS NULL AND lower(name) = lower($1) AND ($2::uuid IS NULL OR id <> $2))",
)
.bind(name)
.bind(except_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "校验存储端点名称失败").with_source(err))?;
if exists {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"存储端点名称已存在",
));
}
Ok(())
}
fn validate_name(value: &str) -> Result<String, AppError> {
let value = value.trim();
if value.is_empty() || value.chars().count() > 100 {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"端点名称长度必须为 1-100 个字符",
));
}
Ok(value.to_string())
}
fn ensure_configurable(endpoint: &storage::StorageEndpoint) -> Result<(), AppError> {
if endpoint.deleted_at.is_some() {
return Err(AppError::new(ErrorCode::NotFound, "存储端点不存在"));
}
Ok(())
}
fn validate_endpoint_url(value: &str, label: &str) -> Result<String, AppError> {
let value = value.trim().trim_end_matches('/');
let parsed = Url::parse(value).map_err(|err| {
AppError::new(ErrorCode::InvalidRequest, format!("{label} 格式错误")).with_source(err)
})?;
if !matches!(parsed.scheme(), "http" | "https")
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.host_str().is_none()
{
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("{label} 必须是无凭据、无查询参数的 HTTP(S) 地址"),
));
}
Ok(value.to_string())
}
fn validate_bucket(value: &str) -> Result<String, AppError> {
let value = value.trim();
let valid = (3..=63).contains(&value.len())
&& value
.bytes()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, b'.' | b'-'))
&& value
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphanumeric)
&& value
.as_bytes()
.last()
.is_some_and(u8::is_ascii_alphanumeric)
&& !value.contains("..")
&& !value.contains(".-")
&& !value.contains("-.");
if !valid {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"Bucket 名称不符合 S3 命名规则",
));
}
Ok(value.to_string())
}
fn validate_region(value: &str) -> Result<String, AppError> {
let value = value.trim();
if value.is_empty()
|| value.len() > 100
|| !value
.bytes()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_'))
{
return Err(AppError::new(
ErrorCode::InvalidRequest,
"Region 格式不正确",
));
}
Ok(value.to_string())
}
fn validate_credential(value: &str, label: &str) -> Result<String, AppError> {
let value = value.trim();
if value.len() < 3 || value.len() > 512 || value.chars().any(char::is_control) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
format!("{label} 长度或格式不正确"),
));
}
Ok(value.to_string())
}
fn validate_presign_ttl(value: i32) -> Result<i32, AppError> {
if !(60..=3600).contains(&value) {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"签名有效期必须为 60-3600 秒",
));
}
Ok(value)
}
fn access_key_hint(value: &str) -> String {
let chars = value.chars().collect::<Vec<_>>();
if chars.len() <= 8 {
return "********".to_string();
}
let start = chars.iter().take(4).collect::<String>();
let end = chars.iter().rev().take(4).rev().collect::<String>();
format!("{start}...{end}")
}
fn storage_test_message(error: &AppError) -> String {
let Some(source) = error.source.as_deref() else {
return error.message.clone();
};
let source = source.replace(['\r', '\n'], " ");
let mut detail = format!("{}{}", error.message, source);
if detail.len() > 500 {
let mut end = 500;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail.truncate(end);
}
detail
}
async fn audit_storage_action(
state: &AppState,
admin_id: Uuid,
ip: IpAddr,
action: &str,
endpoint_id: Uuid,
details: serde_json::Value,
) {
let _ = sqlx::query(
r#"
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
VALUES ($1, $2, 'storage_endpoint', $3, $4, $5::inet)
"#,
)
.bind(admin_id)
.bind(action)
.bind(endpoint_id)
.bind(details)
.bind(ip.to_string())
.execute(&state.db)
.await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_url_rejects_embedded_credentials() {
assert!(validate_endpoint_url("https://user:pass@example.com", "Endpoint").is_err());
assert!(validate_endpoint_url("https://files.example.com/", "Endpoint").is_ok());
}
#[test]
fn bucket_validation_matches_s3_basics() {
assert!(validate_bucket("imageforge-results").is_ok());
assert!(validate_bucket("Bad_Bucket").is_err());
assert!(validate_bucket("ab").is_err());
}
#[test]
fn credentials_are_masked() {
assert_eq!(access_key_hint("ABCD12345678WXYZ"), "ABCD...WXYZ");
assert_eq!(access_key_hint("short"), "********");
}
}

View File

@@ -7,6 +7,7 @@ use crate::services::compress;
use crate::services::compress::{CompressionLevel, ImageFmt};
use crate::services::idempotency;
use crate::services::quota;
use crate::services::storage;
use crate::state::AppState;
use axum::extract::{ConnectInfo, Multipart, State};
@@ -19,7 +20,6 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use std::net::{IpAddr, SocketAddr};
use tokio::io::AsyncWriteExt;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
@@ -300,35 +300,31 @@ async fn compress_json(
&& req.max_height.is_none();
let charge_units = !skip_charge && compressed_size < original_size;
if charge_units {
if let QuotaContext::Anonymous { session_id, ip } = &quota_ctx {
quota::consume_anonymous_units(&state, session_id, *ip, 1).await?;
}
}
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
tokio::fs::create_dir_all(&state.config.storage_path)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err)
})?;
let task_id = Uuid::new_v4();
let file_id = Uuid::new_v4();
let file_path = format!(
"{}/{}.{}",
state.config.storage_path,
file_id,
format_out.extension()
);
let retention_hours = retention.num_hours();
let object_key =
storage::result_key(retention_hours, task_id, file_id, format_out.extension());
let stored =
storage::store_bytes(&state, &object_key, compressed, format_out.content_type())
.await?;
write_file(&file_path, &compressed).await?;
if charge_units {
if let QuotaContext::Anonymous { session_id, ip } = &quota_ctx {
if let Err(err) = quota::consume_anonymous_units(&state, session_id, *ip, 1).await {
let _ = storage::delete_object(
&state,
&storage::ObjectLocator {
backend: stored.backend.clone(),
endpoint_id: stored.endpoint_id,
key: stored.key.clone(),
},
)
.await;
return Err(err);
}
}
}
let expires_at = Utc::now() + retention;
@@ -338,7 +334,7 @@ async fn compress_json(
ip,
task_id,
file_id,
&file_path,
&stored,
&req.file_name,
req.max_width,
req.max_height,
@@ -350,12 +346,21 @@ async fn compress_json(
compressed_size,
saved_percent,
expires_at,
retention_hours,
&quota_ctx,
charge_units,
)
.await
{
let _ = tokio::fs::remove_file(&file_path).await;
let _ = storage::delete_object(
&state,
&storage::ObjectLocator {
backend: stored.backend.clone(),
endpoint_id: stored.endpoint_id,
key: stored.key.clone(),
},
)
.await;
return Err(err);
}
@@ -671,27 +676,18 @@ async fn compress_direct(
&& req.max_height.is_none();
let charge_units = !skip_charge && compressed_size < original_size;
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
tokio::fs::create_dir_all(&state.config.storage_path)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err)
})?;
let task_id = Uuid::new_v4();
let file_id = Uuid::new_v4();
let file_path = format!(
"{}/{}.{}",
state.config.storage_path,
file_id,
format_out.extension()
);
write_file(&file_path, &compressed).await?;
let retention_hours = retention.num_hours();
let object_key =
storage::result_key(retention_hours, task_id, file_id, format_out.extension());
let stored = storage::store_bytes(
&state,
&object_key,
compressed.clone(),
format_out.content_type(),
)
.await?;
let expires_at = Utc::now() + retention;
@@ -701,7 +697,7 @@ async fn compress_direct(
ip,
task_id,
file_id,
&file_path,
&stored,
&req.file_name,
req.max_width,
req.max_height,
@@ -713,12 +709,21 @@ async fn compress_direct(
compressed_size,
saved_percent,
expires_at,
retention_hours,
&quota_ctx,
charge_units,
)
.await
{
let _ = tokio::fs::remove_file(&file_path).await;
let _ = storage::delete_object(
&state,
&storage::ObjectLocator {
backend: stored.backend.clone(),
endpoint_id: stored.endpoint_id,
key: stored.key.clone(),
},
)
.await;
return Err(err);
}
@@ -800,19 +805,11 @@ async fn compress_direct(
}
}
async fn write_file(path: &str, bytes: &[u8]) -> Result<(), AppError> {
let mut file = tokio::fs::File::create(path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err)
})?;
file.write_all(bytes).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "写入文件失败").with_source(err)
})?;
Ok(())
}
#[derive(Debug, FromRow)]
struct DirectReplayRow {
storage_path: Option<String>,
storage_backend: String,
storage_endpoint_id: Option<Uuid>,
storage_key: Option<String>,
output_format: String,
file_status: String,
expires_at: DateTime<Utc>,
@@ -828,7 +825,9 @@ async fn load_direct_replay_bytes(
sqlx::query_as::<_, DirectReplayRow>(
r#"
SELECT
f.storage_path,
f.storage_backend,
f.storage_endpoint_id,
COALESCE(f.storage_key, f.storage_path) AS storage_key,
f.output_format,
f.status::text AS file_status,
t.expires_at
@@ -846,7 +845,9 @@ async fn load_direct_replay_bytes(
sqlx::query_as::<_, DirectReplayRow>(
r#"
SELECT
f.storage_path,
f.storage_backend,
f.storage_endpoint_id,
COALESCE(f.storage_key, f.storage_path) AS storage_key,
f.output_format,
f.status::text AS file_status,
t.expires_at
@@ -873,13 +874,18 @@ async fn load_direct_replay_bytes(
if row.file_status != "completed" {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
}
let Some(path) = row.storage_path else {
let Some(key) = row.storage_key else {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
};
let bytes = tokio::fs::read(&path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err)
})?;
let bytes = storage::read_bytes(
state,
&storage::ObjectLocator {
backend: row.storage_backend,
endpoint_id: row.storage_endpoint_id,
key,
},
)
.await?;
let fmt = compress::parse_output_format(&row.output_format)?;
Ok((bytes, fmt))
@@ -1082,7 +1088,7 @@ async fn record_task_and_metering(
client_ip: IpAddr,
task_id: Uuid,
file_id: Uuid,
file_path: &str,
stored: &storage::StoredObject,
original_name: &str,
max_width: Option<u32>,
max_height: Option<u32>,
@@ -1094,6 +1100,7 @@ async fn record_task_and_metering(
compressed_size: u64,
saved_percent: f64,
expires_at: DateTime<Utc>,
retention_hours: i64,
quota_ctx: &QuotaContext,
charge_units: bool,
) -> Result<(), AppError> {
@@ -1123,13 +1130,13 @@ async fn record_task_and_metering(
compression_rate,
total_files, completed_files, failed_files,
total_original_size, total_compressed_size,
started_at, completed_at, expires_at
started_at, completed_at, expires_at, retention_hours
) VALUES (
$1, $2, $3, $4, $5::inet, $6::task_source, 'completed',
$7::compression_level, $8, $9, $10, $11, $12,
1, 1, 0,
$13, $14,
NOW(), NOW(), $15
NOW(), NOW(), $15, $16
)
"#,
)
@@ -1148,6 +1155,7 @@ async fn record_task_and_metering(
.bind(original_size as i64)
.bind(compressed_size as i64)
.bind(expires_at)
.bind(retention_hours as i32)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建任务失败").with_source(err))?;
@@ -1158,12 +1166,13 @@ async fn record_task_and_metering(
id, task_id,
original_name, original_format, output_format,
original_size, compressed_size, saved_percent,
storage_path, status, completed_at
storage_path, storage_backend, storage_endpoint_id, storage_key, storage_etag,
status, completed_at
) VALUES (
$1, $2,
$3, $4, $5,
$6, $7, $8,
$9, 'completed', NOW()
$9, $10, $11, $12, $13, 'completed', NOW()
)
"#,
)
@@ -1175,7 +1184,15 @@ async fn record_task_and_metering(
.bind(original_size as i64)
.bind(compressed_size as i64)
.bind(saved_percent)
.bind(file_path)
.bind(if stored.backend == "local" {
Some(stored.key.as_str())
} else {
None
})
.bind(&stored.backend)
.bind(stored.endpoint_id)
.bind(&stored.key)
.bind(&stored.etag)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建文件记录失败").with_source(err))?;

View File

@@ -1,11 +1,12 @@
use crate::api::context;
use crate::error::{AppError, ErrorCode};
use crate::services::storage;
use crate::state::AppState;
use axum::body::Body;
use axum::extract::{ConnectInfo, Path, State};
use axum::http::{header, HeaderMap};
use axum::response::{IntoResponse, Response};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::get;
use axum::Router;
use chrono::{DateTime, Utc};
@@ -25,7 +26,9 @@ pub fn router() -> Router<AppState> {
#[derive(Debug, FromRow)]
struct DownloadRow {
storage_path: Option<String>,
storage_backend: String,
storage_endpoint_id: Option<Uuid>,
storage_key: Option<String>,
output_format: String,
original_name: String,
file_status: String,
@@ -47,7 +50,9 @@ async fn download_file(
let row = sqlx::query_as::<_, DownloadRow>(
r#"
SELECT
f.storage_path,
f.storage_backend,
f.storage_endpoint_id,
COALESCE(f.storage_key, f.storage_path) AS storage_key,
f.output_format,
f.original_name,
f.status::text AS file_status,
@@ -75,32 +80,51 @@ async fn download_file(
authorize_download(&principal, &row)?;
let Some(path) = &row.storage_path else {
let Some(key) = row.storage_key else {
return Err(AppError::new(ErrorCode::NotFound, "文件不存在"));
};
let object = storage::ObjectLocator {
backend: row.storage_backend,
endpoint_id: row.storage_endpoint_id,
key,
};
respond_object(
&state,
jar,
&object,
&output_file_name(&row.original_name, &row.output_format),
content_type(&row.output_format),
)
.await
}
let file = tokio::fs::File::open(path).await.map_err(|err| {
async fn respond_object(
state: &AppState,
jar: axum_extra::extract::cookie::CookieJar,
object: &storage::ObjectLocator,
file_name: &str,
content_type_value: &str,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
if let Some(url) =
storage::presign_download(state, object, file_name, content_type_value).await?
{
return Ok((jar, Redirect::temporary(&url).into_response()));
}
let file = tokio::fs::File::open(&object.key).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取文件失败").with_source(err)
})?;
let content_length = file.metadata().await.ok().map(|metadata| metadata.len());
let body = Body::from_stream(ReaderStream::new(file));
let mut resp_headers = HeaderMap::new();
resp_headers.insert(
header::CONTENT_TYPE,
content_type(&row.output_format).parse().unwrap(),
);
resp_headers.insert(
header::CONTENT_DISPOSITION,
content_disposition(&row.original_name)?,
);
resp_headers.insert(header::CONTENT_TYPE, content_type_value.parse().unwrap());
resp_headers.insert(header::CONTENT_DISPOSITION, content_disposition(file_name)?);
if let Some(content_length) = content_length {
resp_headers.insert(
header::CONTENT_LENGTH,
content_length.to_string().parse().unwrap(),
);
}
Ok((jar, (resp_headers, body).into_response()))
}
@@ -126,10 +150,36 @@ fn content_type(format: &str) -> &'static str {
"jpeg" | "jpg" => "image/jpeg",
"webp" => "image/webp",
"avif" => "image/avif",
"gif" => "image/gif",
"bmp" => "image/bmp",
"tif" | "tiff" => "image/tiff",
"ico" => "image/x-icon",
_ => "application/octet-stream",
}
}
fn output_file_name(original_name: &str, output_format: &str) -> String {
let sanitized = sanitize_filename(original_name);
let base = sanitized
.rsplit_once('.')
.map(|(value, _)| value)
.unwrap_or(&sanitized)
.trim_end_matches('.');
let base = if base.is_empty() { "download" } else { base };
let extension = match output_format.trim().to_ascii_lowercase().as_str() {
"jpeg" | "jpg" => "jpg",
"png" => "png",
"webp" => "webp",
"avif" => "avif",
"gif" => "gif",
"bmp" => "bmp",
"tif" | "tiff" => "tiff",
"ico" => "ico",
_ => "bin",
};
format!("{base}.{extension}")
}
fn sanitize_filename(name: &str) -> String {
let mut out = name.trim().to_string();
if out.is_empty() {
@@ -177,11 +227,17 @@ struct TaskZipRow {
status: String,
completed_at: Option<DateTime<Utc>>,
expires_at: DateTime<Utc>,
retention_hours: i32,
zip_storage_backend: Option<String>,
zip_storage_endpoint_id: Option<Uuid>,
zip_storage_key: Option<String>,
}
#[derive(Debug, FromRow)]
struct TaskZipFileRow {
storage_path: Option<String>,
storage_backend: String,
storage_endpoint_id: Option<Uuid>,
storage_key: Option<String>,
original_name: String,
output_format: String,
}
@@ -203,7 +259,11 @@ async fn download_task_zip(
session_id,
status::text AS status,
completed_at,
expires_at
expires_at,
retention_hours,
zip_storage_backend,
zip_storage_endpoint_id,
zip_storage_key
FROM tasks
WHERE id = $1
"#,
@@ -235,26 +295,29 @@ async fn download_task_zip(
}
}
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
let zip_dir = format!("{}/zips", state.config.storage_path);
tokio::fs::create_dir_all(&zip_dir).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err)
})?;
let zip_path = PathBuf::from(format!("{zip_dir}/{task_id}.zip"));
if tokio::fs::try_exists(&zip_path).await.unwrap_or(false) {
return stream_zip(jar, zip_path, task_id).await;
if let (Some(backend), Some(key)) = (
task.zip_storage_backend.clone(),
task.zip_storage_key.clone(),
) {
return respond_object(
&state,
jar,
&storage::ObjectLocator {
backend,
endpoint_id: task.zip_storage_endpoint_id,
key,
},
&format!("task_{task_id}.zip"),
"application/zip",
)
.await;
}
let rows = sqlx::query_as::<_, TaskZipFileRow>(
r#"
SELECT storage_path, original_name, output_format
SELECT storage_backend, storage_endpoint_id,
COALESCE(storage_key, storage_path) AS storage_key,
original_name, output_format
FROM task_files
WHERE task_id = $1 AND status = 'completed'
ORDER BY created_at ASC
@@ -269,53 +332,92 @@ async fn download_task_zip(
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let mut used_names: HashMap<String, usize> = HashMap::new();
let mut entries: Vec<(String, String)> = Vec::new();
for row in rows {
let Some(path) = row.storage_path else {
continue;
};
let name = build_zip_entry_name(&row.original_name, &row.output_format, &mut used_names);
entries.push((name, path));
}
if entries.is_empty() {
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let zip_path_cloned = zip_path.clone();
let task_id_str = task_id.to_string();
tokio::task::spawn_blocking(move || {
generate_zip_file(&zip_path_cloned, &task_id_str, &entries)
})
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?;
stream_zip(jar, zip_path, task_id).await
}
async fn stream_zip(
jar: axum_extra::extract::cookie::CookieJar,
zip_path: PathBuf,
task_id: Uuid,
) -> Result<(axum_extra::extract::cookie::CookieJar, Response), AppError> {
let file = tokio::fs::File::open(&zip_path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取 ZIP 失败").with_source(err)
let temp_dir = PathBuf::from(format!(
"{}/tmp/zips/{task_id}-{}",
state.config.storage_path,
Uuid::new_v4()
));
tokio::fs::create_dir_all(&temp_dir).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建 ZIP 临时目录失败").with_source(err)
})?;
let zip_path = temp_dir.join(format!("task_{task_id}.zip"));
let stream = ReaderStream::new(file);
let body = Body::from_stream(stream);
let build_result: Result<storage::StoredObject, AppError> = async {
let mut used_names: HashMap<String, usize> = HashMap::new();
let mut entries: Vec<(String, String)> = Vec::new();
for (index, row) in rows.into_iter().enumerate() {
let Some(key) = row.storage_key else {
continue;
};
let path = temp_dir.join(format!("entry-{index}"));
storage::download_to_file(
&state,
&storage::ObjectLocator {
backend: row.storage_backend,
endpoint_id: row.storage_endpoint_id,
key,
},
&path,
)
.await?;
let name =
build_zip_entry_name(&row.original_name, &row.output_format, &mut used_names);
entries.push((name, path.to_string_lossy().to_string()));
}
if entries.is_empty() {
return Err(AppError::new(ErrorCode::NotFound, "没有可打包的文件"));
}
let mut resp_headers = HeaderMap::new();
resp_headers.insert(header::CONTENT_TYPE, "application/zip".parse().unwrap());
resp_headers.insert(
header::CONTENT_DISPOSITION,
format!("attachment; filename=\"task_{task_id}.zip\"")
.parse()
.unwrap(),
);
let zip_path_cloned = zip_path.clone();
let task_id_str = task_id.to_string();
tokio::task::spawn_blocking(move || {
generate_zip_file(&zip_path_cloned, &task_id_str, &entries)
})
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?
.map_err(|err| AppError::new(ErrorCode::Internal, "生成 ZIP 失败").with_source(err))?;
Ok((jar, (resp_headers, body).into_response()))
let object_key = storage::archive_key(task.retention_hours as i64, task_id);
storage::store_file(&state, &object_key, &zip_path, "application/zip").await
}
.await;
let _ = tokio::fs::remove_dir_all(&temp_dir).await;
let stored = build_result?;
sqlx::query(
r#"
UPDATE tasks
SET zip_storage_backend = $2,
zip_storage_endpoint_id = $3,
zip_storage_key = $4,
zip_storage_etag = $5,
zip_size = $6
WHERE id = $1 AND zip_storage_key IS NULL
"#,
)
.bind(task_id)
.bind(&stored.backend)
.bind(stored.endpoint_id)
.bind(&stored.key)
.bind(&stored.etag)
.bind(stored.size as i64)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "记录 ZIP 对象失败").with_source(err))?;
respond_object(
&state,
jar,
&storage::ObjectLocator {
backend: stored.backend,
endpoint_id: stored.endpoint_id,
key: stored.key,
},
&format!("task_{task_id}.zip"),
"application/zip",
)
.await
}
fn build_zip_entry_name(
@@ -333,6 +435,10 @@ fn build_zip_entry_name(
"png" => "png",
"webp" => "webp",
"avif" => "avif",
"gif" => "gif",
"bmp" => "bmp",
"tif" | "tiff" => "tiff",
"ico" => "ico",
_ => "bin",
};
@@ -411,4 +517,10 @@ mod tests {
assert_eq!(sanitized, "a".repeat(119));
assert!(sanitized.is_char_boundary(sanitized.len()));
}
#[test]
fn output_file_name_matches_converted_format() {
assert_eq!(output_file_name("photo.png", "webp"), "photo.webp");
assert_eq!(output_file_name("没有扩展名", "jpeg"), "没有扩展名.jpg");
}
}

View File

@@ -1,4 +1,5 @@
mod admin;
mod admin_storage;
mod auth;
mod billing;
mod compress;
@@ -62,5 +63,6 @@ fn v1_router() -> Router<AppState> {
.merge(webhooks::router())
.merge(user::router())
.merge(admin::router())
.merge(admin_storage::router())
.fallback(response::not_found)
}

View File

@@ -6,6 +6,7 @@ use crate::services::billing::{BillingContext, Plan};
use crate::services::compress;
use crate::services::compress::{CompressionLevel, ImageFmt};
use crate::services::idempotency;
use crate::services::storage;
use crate::state::AppState;
use axum::extract::{ConnectInfo, Multipart, Path, State};
@@ -72,13 +73,6 @@ async fn create_batch_task(
let ip = context::client_ip(&headers, addr.ip());
let (jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
if !state.config.storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"当前仅支持本地存储STORAGE_TYPE=local",
));
}
let idempotency_key = headers
.get("idempotency-key")
.and_then(|v| v.to_str().ok())
@@ -217,13 +211,8 @@ async fn create_batch_task(
}
}?;
tokio::fs::create_dir_all(&state.config.storage_path)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建存储目录失败").with_source(err)
})?;
let expires_at = Utc::now() + retention;
let retention_hours = retention.num_hours();
let (user_id, session_id, api_key_id) = match &task_owner {
TaskOwner::Anonymous { session_id } => (None, Some(session_id.clone()), None),
TaskOwner::User { user_id } => (Some(*user_id), None, None),
@@ -248,13 +237,13 @@ async fn create_batch_task(
compression_rate,
total_files, completed_files, failed_files,
total_original_size, total_compressed_size,
expires_at
expires_at, retention_hours
) VALUES (
$1, $2, $3, $4, $5::inet, $6::task_source, 'pending',
$7::compression_level, $8, $9, $10, $11, $12,
$13, 0, 0,
$14, 0,
$15
$15, $16
)
"#,
)
@@ -273,6 +262,7 @@ async fn create_batch_task(
.bind(files.len() as i32)
.bind(total_original_size)
.bind(expires_at)
.bind(retention_hours as i32)
.execute(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "创建任务失败").with_source(err))?;
@@ -284,7 +274,7 @@ async fn create_batch_task(
id, task_id,
original_name, original_format, output_format,
original_size,
storage_path, status
input_path, status
) VALUES (
$1, $2,
$3, $4, $5,
@@ -990,24 +980,61 @@ async fn delete_task(
));
}
let paths: Vec<Option<String>> =
sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1")
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?;
let files = sqlx::query_as::<_, TaskStorageRow>(
r#"
SELECT storage_backend, storage_endpoint_id,
COALESCE(storage_key, storage_path) AS storage_key,
input_path
FROM task_files
WHERE task_id = $1
"#,
)
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询文件失败").with_source(err))?;
for p in paths.into_iter().flatten() {
let _ = tokio::fs::remove_file(p).await;
for file in files {
if let Some(key) = file.storage_key {
storage::delete_object(
&state,
&storage::ObjectLocator {
backend: file.storage_backend,
endpoint_id: file.storage_endpoint_id,
key,
},
)
.await?;
}
if let Some(input_path) = file.input_path {
let _ = tokio::fs::remove_file(input_path).await;
}
}
if state.config.storage_type.eq_ignore_ascii_case("local") {
let zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
let _ = tokio::fs::remove_file(zip_path).await;
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
let _ = tokio::fs::remove_dir_all(orig_dir).await;
let zip = sqlx::query_as::<_, TaskZipStorageRow>(
"SELECT zip_storage_backend, zip_storage_endpoint_id, zip_storage_key FROM tasks WHERE id = $1",
)
.bind(task_id)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询 ZIP 文件失败").with_source(err))?;
if let (Some(backend), Some(key)) = (zip.zip_storage_backend, zip.zip_storage_key) {
storage::delete_object(
&state,
&storage::ObjectLocator {
backend,
endpoint_id: zip.zip_storage_endpoint_id,
key,
},
)
.await?;
}
let legacy_zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
let _ = tokio::fs::remove_file(legacy_zip_path).await;
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
let _ = tokio::fs::remove_dir_all(orig_dir).await;
let deleted = sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(task_id)
.execute(&state.db)
@@ -1027,6 +1054,21 @@ async fn delete_task(
))
}
#[derive(Debug, FromRow)]
struct TaskStorageRow {
storage_backend: String,
storage_endpoint_id: Option<Uuid>,
storage_key: Option<String>,
input_path: Option<String>,
}
#[derive(Debug, FromRow)]
struct TaskZipStorageRow {
zip_storage_backend: Option<String>,
zip_storage_endpoint_id: Option<Uuid>,
zip_storage_key: Option<String>,
}
fn authorize_task(
principal: &context::Principal,
user_id: Option<Uuid>,

View File

@@ -500,7 +500,7 @@ async fn list_history(
status: String,
output_format: String,
error_message: Option<String>,
storage_path: Option<String>,
has_storage: bool,
}
let now = Utc::now();
@@ -517,7 +517,7 @@ async fn list_history(
status::text AS status,
output_format,
error_message,
storage_path
COALESCE(storage_key, storage_path) IS NOT NULL AS has_storage
FROM task_files
WHERE task_id = $1
ORDER BY created_at ASC
@@ -540,7 +540,7 @@ async fn list_history(
output_format: file.output_format,
error_message: file.error_message,
download_url: if file.status == "completed"
&& file.storage_path.is_some()
&& file.has_storage
&& task.expires_at > now
{
Some(format!("/downloads/{}", file.id))

View File

@@ -25,7 +25,6 @@ pub struct Config {
pub stripe_secret_key: Option<String>,
pub stripe_webhook_secret: Option<String>,
pub storage_type: String,
pub storage_path: String,
pub allow_anonymous_upload: bool,
@@ -95,13 +94,6 @@ impl Config {
let stripe_secret_key = env_string("STRIPE_SECRET_KEY");
let stripe_webhook_secret = env_string("STRIPE_WEBHOOK_SECRET");
let storage_type = env_string("STORAGE_TYPE").unwrap_or_else(|| "local".to_string());
if !storage_type.eq_ignore_ascii_case("local") {
return Err(AppError::new(
ErrorCode::InvalidRequest,
"STORAGE_TYPE 目前仅支持 local",
));
}
let storage_path = env_string("STORAGE_PATH").unwrap_or_else(|| "./uploads".to_string());
let allow_anonymous_upload = env_bool("ALLOW_ANONYMOUS_UPLOAD").unwrap_or(true);
@@ -141,7 +133,6 @@ impl Config {
api_key_pepper,
stripe_secret_key,
stripe_webhook_secret,
storage_type,
storage_path,
allow_anonymous_upload,
anon_max_file_size_mb,

View File

@@ -5,3 +5,4 @@ pub mod idempotency;
pub mod mail;
pub mod quota;
pub mod settings;
pub mod storage;

718
src/services/storage.rs Normal file
View File

@@ -0,0 +1,718 @@
use crate::error::{AppError, ErrorCode};
use crate::services::settings;
use crate::state::AppState;
use aws_sdk_s3::config::{
BehaviorVersion, Credentials, Region, RequestChecksumCalculation, ResponseChecksumValidation,
};
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use aws_sdk_s3::Client;
use chrono::{DateTime, Datelike, Utc};
use sqlx::FromRow;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use uuid::Uuid;
const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
const MULTIPART_PART_SIZE: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone, FromRow)]
pub struct StorageEndpoint {
pub id: Uuid,
pub name: String,
pub internal_endpoint: String,
pub public_endpoint: String,
pub bucket: String,
pub region: String,
pub access_key_encrypted: String,
pub secret_key_encrypted: String,
pub access_key_hint: String,
pub force_path_style: bool,
pub presign_ttl_seconds: i32,
pub is_active: bool,
pub last_test_at: Option<DateTime<Utc>>,
pub last_test_ok: Option<bool>,
pub last_test_error: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone)]
pub struct StoredObject {
pub backend: String,
pub endpoint_id: Option<Uuid>,
pub key: String,
pub etag: Option<String>,
pub size: u64,
}
#[derive(Debug, Clone)]
pub struct ObjectLocator {
pub backend: String,
pub endpoint_id: Option<Uuid>,
pub key: String,
}
pub async fn list_endpoints(state: &AppState) -> Result<Vec<StorageEndpoint>, AppError> {
sqlx::query_as::<_, StorageEndpoint>(
r#"
SELECT id, name, internal_endpoint, public_endpoint, bucket, region,
access_key_encrypted, secret_key_encrypted, access_key_hint,
force_path_style, presign_ttl_seconds, is_active,
last_test_at, last_test_ok, last_test_error,
created_at, updated_at, deleted_at
FROM storage_endpoints
WHERE deleted_at IS NULL
ORDER BY is_active DESC, updated_at DESC
"#,
)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询存储端点失败").with_source(err))
}
pub async fn get_endpoint(
state: &AppState,
endpoint_id: Uuid,
) -> Result<StorageEndpoint, AppError> {
sqlx::query_as::<_, StorageEndpoint>(
r#"
SELECT id, name, internal_endpoint, public_endpoint, bucket, region,
access_key_encrypted, secret_key_encrypted, access_key_hint,
force_path_style, presign_ttl_seconds, is_active,
last_test_at, last_test_ok, last_test_error,
created_at, updated_at, deleted_at
FROM storage_endpoints
WHERE id = $1
"#,
)
.bind(endpoint_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询存储端点失败").with_source(err))?
.ok_or_else(|| AppError::new(ErrorCode::NotFound, "存储端点不存在"))
}
pub async fn active_endpoint(state: &AppState) -> Result<Option<StorageEndpoint>, AppError> {
sqlx::query_as::<_, StorageEndpoint>(
r#"
SELECT id, name, internal_endpoint, public_endpoint, bucket, region,
access_key_encrypted, secret_key_encrypted, access_key_hint,
force_path_style, presign_ttl_seconds, is_active,
last_test_at, last_test_ok, last_test_error,
created_at, updated_at, deleted_at
FROM storage_endpoints
WHERE is_active = true AND deleted_at IS NULL
LIMIT 1
"#,
)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询活动存储端点失败").with_source(err))
}
pub fn result_key(retention_hours: i64, task_id: Uuid, file_id: Uuid, extension: &str) -> String {
let now = Utc::now();
format!(
"results/{}/{:04}/{:02}/{task_id}/{file_id}.{}",
retention_prefix(retention_hours),
now.year(),
now.month(),
extension.trim_start_matches('.')
)
}
pub fn archive_key(retention_hours: i64, task_id: Uuid) -> String {
let now = Utc::now();
format!(
"archives/{}/{:04}/{:02}/{task_id}.zip",
retention_prefix(retention_hours),
now.year(),
now.month()
)
}
fn retention_prefix(hours: i64) -> String {
match hours {
0..=24 => "1d".to_string(),
25..=168 => "7d".to_string(),
169..=360 => "15d".to_string(),
value => format!("custom-{}h", value.max(1)),
}
}
pub async fn store_bytes(
state: &AppState,
key: &str,
bytes: Vec<u8>,
content_type: &str,
) -> Result<StoredObject, AppError> {
if let Some(endpoint) = active_endpoint(state).await? {
let client = client_for(state, &endpoint, EndpointKind::Internal)?;
let size = bytes.len() as u64;
let output = client
.put_object()
.bucket(&endpoint.bucket)
.key(key)
.content_type(content_type)
.body(ByteStream::from(bytes))
.send()
.await
.map_err(|err| storage_error("上传 S3 对象失败", err))?;
return Ok(StoredObject {
backend: "s3".to_string(),
endpoint_id: Some(endpoint.id),
key: key.to_string(),
etag: output.e_tag().map(ToOwned::to_owned),
size,
});
}
let path = local_path(state, key)?;
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建本地存储目录失败").with_source(err)
})?;
}
tokio::fs::write(&path, &bytes).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "写入本地存储失败").with_source(err)
})?;
Ok(StoredObject {
backend: "local".to_string(),
endpoint_id: None,
key: path.to_string_lossy().to_string(),
etag: None,
size: bytes.len() as u64,
})
}
pub async fn store_file(
state: &AppState,
key: &str,
path: &Path,
content_type: &str,
) -> Result<StoredObject, AppError> {
let metadata = tokio::fs::metadata(path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取待上传文件失败").with_source(err)
})?;
if let Some(endpoint) = active_endpoint(state).await? {
let client = client_for(state, &endpoint, EndpointKind::Internal)?;
let etag = if metadata.len() >= MULTIPART_THRESHOLD {
multipart_upload(&client, &endpoint.bucket, key, path, content_type).await?
} else {
let body = ByteStream::from_path(path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取待上传文件失败").with_source(err)
})?;
client
.put_object()
.bucket(&endpoint.bucket)
.key(key)
.content_type(content_type)
.body(body)
.send()
.await
.map_err(|err| storage_error("上传 S3 对象失败", err))?
.e_tag()
.map(ToOwned::to_owned)
};
return Ok(StoredObject {
backend: "s3".to_string(),
endpoint_id: Some(endpoint.id),
key: key.to_string(),
etag,
size: metadata.len(),
});
}
let destination = local_path(state, key)?;
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建本地存储目录失败").with_source(err)
})?;
}
tokio::fs::copy(path, &destination).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "保存本地文件失败").with_source(err)
})?;
Ok(StoredObject {
backend: "local".to_string(),
endpoint_id: None,
key: destination.to_string_lossy().to_string(),
etag: None,
size: metadata.len(),
})
}
pub async fn read_bytes(state: &AppState, object: &ObjectLocator) -> Result<Vec<u8>, AppError> {
if object.backend == "local" {
return tokio::fs::read(&object.key).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取本地文件失败").with_source(err)
});
}
let endpoint = endpoint_for_object(state, object).await?;
let client = client_for(state, &endpoint, EndpointKind::Internal)?;
let response = client
.get_object()
.bucket(&endpoint.bucket)
.key(&object.key)
.send()
.await
.map_err(|err| storage_error("读取 S3 对象失败", err))?;
let bytes = response
.body
.collect()
.await
.map_err(|err| storage_error("接收 S3 对象失败", err))?;
Ok(bytes.into_bytes().to_vec())
}
pub async fn download_to_file(
state: &AppState,
object: &ObjectLocator,
destination: &Path,
) -> Result<(), AppError> {
if object.backend == "local" {
tokio::fs::copy(&object.key, destination)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "复制本地文件失败").with_source(err)
})?;
return Ok(());
}
let endpoint = endpoint_for_object(state, object).await?;
let client = client_for(state, &endpoint, EndpointKind::Internal)?;
let response = client
.get_object()
.bucket(&endpoint.bucket)
.key(&object.key)
.send()
.await
.map_err(|err| storage_error("读取 S3 对象失败", err))?;
let mut source = response.body.into_async_read();
let mut output = tokio::fs::File::create(destination).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "创建临时文件失败").with_source(err)
})?;
tokio::io::copy(&mut source, &mut output)
.await
.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "保存临时文件失败").with_source(err)
})?;
output.flush().await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "刷新临时文件失败").with_source(err)
})?;
Ok(())
}
pub async fn presign_download(
state: &AppState,
object: &ObjectLocator,
file_name: &str,
content_type: &str,
) -> Result<Option<String>, AppError> {
if object.backend == "local" {
return Ok(None);
}
let endpoint = endpoint_for_object(state, object).await?;
let client = client_for(state, &endpoint, EndpointKind::Public)?;
let ttl = Duration::from_secs(endpoint.presign_ttl_seconds as u64);
let config = PresigningConfig::expires_in(ttl).map_err(|err| {
AppError::new(ErrorCode::Internal, "生成下载签名配置失败").with_source(err)
})?;
let disposition = content_disposition(file_name);
let request = client
.get_object()
.bucket(&endpoint.bucket)
.key(&object.key)
.response_content_disposition(disposition)
.response_content_type(content_type)
.presigned(config)
.await
.map_err(|err| storage_error("生成 S3 下载地址失败", err))?;
Ok(Some(request.uri().to_string()))
}
pub async fn delete_object(state: &AppState, object: &ObjectLocator) -> Result<(), AppError> {
if object.backend == "local" {
match tokio::fs::remove_file(&object.key).await {
Ok(()) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(
AppError::new(ErrorCode::StorageUnavailable, "删除本地文件失败")
.with_source(err),
)
}
}
}
let endpoint = endpoint_for_object(state, object).await?;
let client = client_for(state, &endpoint, EndpointKind::Internal)?;
client
.delete_object()
.bucket(&endpoint.bucket)
.key(&object.key)
.send()
.await
.map_err(|err| storage_error("删除 S3 对象失败", err))?;
Ok(())
}
pub async fn test_endpoint(state: &AppState, endpoint: &StorageEndpoint) -> Result<(), AppError> {
let client = client_for(state, endpoint, EndpointKind::Internal)?;
client
.head_bucket()
.bucket(&endpoint.bucket)
.send()
.await
.map_err(|err| storage_error("S3 Bucket 访问失败", err))?;
let key = format!(".imageforge-health/{}.txt", Uuid::new_v4());
let expected = b"imageforge-storage-check".to_vec();
client
.put_object()
.bucket(&endpoint.bucket)
.key(&key)
.content_type("text/plain")
.body(ByteStream::from(expected.clone()))
.send()
.await
.map_err(|err| storage_error("S3 写入测试失败", err))?;
let result = async {
let response = client
.get_object()
.bucket(&endpoint.bucket)
.key(&key)
.send()
.await
.map_err(|err| storage_error("S3 读取测试失败", err))?;
let actual = response
.body
.collect()
.await
.map_err(|err| storage_error("S3 测试对象接收失败", err))?
.into_bytes();
if actual.as_ref() != expected.as_slice() {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"S3 读写校验内容不一致",
));
}
let public_client = client_for(state, endpoint, EndpointKind::Public)?;
let signed = public_client
.get_object()
.bucket(&endpoint.bucket)
.key(&key)
.presigned(
PresigningConfig::expires_in(Duration::from_secs(60)).map_err(|err| {
AppError::new(ErrorCode::Internal, "生成公网下载测试签名失败").with_source(err)
})?,
)
.await
.map_err(|err| storage_error("生成公网下载测试地址失败", err))?;
let response = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|err| storage_error("创建公网下载测试客户端失败", err))?
.get(signed.uri().to_string())
.header(reqwest::header::USER_AGENT, "ImageForge-Storage-Check/1.0")
.send()
.await
.map_err(|err| public_test_request_error(endpoint, err))?;
let status = response.status();
if !status.is_success() {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
format!("公网 Endpoint 下载测试返回 HTTP {status}"),
));
}
let public_bytes = response.bytes().await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取公网下载测试响应失败")
.with_source(format!("response body error: {}", err.is_timeout()))
})?;
if public_bytes.as_ref() != expected.as_slice() {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"公网 Endpoint 下载内容校验不一致",
));
}
Ok(())
}
.await;
let delete_result = client
.delete_object()
.bucket(&endpoint.bucket)
.key(&key)
.send()
.await;
result?;
delete_result.map_err(|err| storage_error("S3 删除测试失败", err))?;
Ok(())
}
fn client_for(
state: &AppState,
endpoint: &StorageEndpoint,
kind: EndpointKind,
) -> Result<Client, AppError> {
let access_key =
settings::decrypt_secret(state, &endpoint.access_key_encrypted).map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "存储端点凭据不可用").with_source(err)
})?;
let secret_key =
settings::decrypt_secret(state, &endpoint.secret_key_encrypted).map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "存储端点凭据不可用").with_source(err)
})?;
let endpoint_url = match kind {
EndpointKind::Internal => &endpoint.internal_endpoint,
EndpointKind::Public => &endpoint.public_endpoint,
};
Ok(build_client(
access_key,
secret_key,
endpoint_url,
&endpoint.region,
endpoint.force_path_style,
))
}
fn build_client(
access_key: String,
secret_key: String,
endpoint_url: &str,
region: &str,
force_path_style: bool,
) -> Client {
let config = aws_sdk_s3::Config::builder()
.behavior_version(BehaviorVersion::latest())
.credentials_provider(Credentials::new(
access_key,
secret_key,
None,
None,
"imageforge-admin",
))
.region(Region::new(region.to_string()))
.endpoint_url(endpoint_url)
.force_path_style(force_path_style)
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
.response_checksum_validation(ResponseChecksumValidation::WhenRequired)
.build();
Client::from_conf(config)
}
async fn endpoint_for_object(
state: &AppState,
object: &ObjectLocator,
) -> Result<StorageEndpoint, AppError> {
let endpoint_id = object
.endpoint_id
.ok_or_else(|| AppError::new(ErrorCode::StorageUnavailable, "S3 对象缺少存储端点标识"))?;
get_endpoint(state, endpoint_id).await
}
fn local_path(state: &AppState, key: &str) -> Result<PathBuf, AppError> {
if key.is_empty()
|| key.starts_with('/')
|| key.starts_with('\\')
|| key.split('/').any(|part| part == "..")
{
return Err(AppError::new(ErrorCode::InvalidRequest, "非法存储对象键"));
}
Ok(Path::new(&state.config.storage_path).join(key))
}
fn content_disposition(file_name: &str) -> String {
let ascii = file_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') {
ch
} else {
'_'
}
})
.collect::<String>();
let ascii = if ascii.is_empty() { "download" } else { &ascii };
let encoded =
percent_encoding::utf8_percent_encode(file_name, percent_encoding::NON_ALPHANUMERIC);
format!("attachment; filename=\"{ascii}\"; filename*=UTF-8''{encoded}")
}
async fn multipart_upload(
client: &Client,
bucket: &str,
key: &str,
path: &Path,
content_type: &str,
) -> Result<Option<String>, AppError> {
let created = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.content_type(content_type)
.send()
.await
.map_err(|err| storage_error("创建 S3 分片上传失败", err))?;
let upload_id = created
.upload_id()
.ok_or_else(|| AppError::new(ErrorCode::StorageUnavailable, "S3 未返回分片上传标识"))?;
let result = async {
let mut file = tokio::fs::File::open(path).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取待上传文件失败").with_source(err)
})?;
let mut parts = Vec::new();
let mut part_number = 1;
loop {
let mut buffer = vec![0u8; MULTIPART_PART_SIZE];
let mut filled = 0;
while filled < buffer.len() {
let read = file.read(&mut buffer[filled..]).await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取上传分片失败")
.with_source(err)
})?;
if read == 0 {
break;
}
filled += read;
}
if filled == 0 {
break;
}
buffer.truncate(filled);
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(part_number)
.body(ByteStream::from(buffer))
.send()
.await
.map_err(|err| storage_error("上传 S3 分片失败", err))?;
let part = CompletedPart::builder()
.part_number(part_number)
.set_e_tag(uploaded.e_tag().map(ToOwned::to_owned))
.build();
parts.push(part);
part_number += 1;
}
let completed = CompletedMultipartUpload::builder()
.set_parts(Some(parts))
.build();
let response = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(completed)
.send()
.await
.map_err(|err| storage_error("完成 S3 分片上传失败", err))?;
Ok(response.e_tag().map(ToOwned::to_owned))
}
.await;
if result.is_err() {
let _ = client
.abort_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.send()
.await;
}
result
}
fn storage_error(message: &'static str, err: impl std::fmt::Display) -> AppError {
AppError::new(ErrorCode::StorageUnavailable, message).with_source(err)
}
fn public_test_request_error(endpoint: &StorageEndpoint, err: reqwest::Error) -> AppError {
AppError::new(ErrorCode::StorageUnavailable, "公网 Endpoint 下载测试失败").with_source(format!(
"endpoint={}, connect={}, timeout={}",
endpoint.public_endpoint,
err.is_connect(),
err.is_timeout()
))
}
#[derive(Clone, Copy)]
enum EndpointKind {
Internal,
Public,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retention_prefixes_match_product_policy() {
assert_eq!(retention_prefix(24), "1d");
assert_eq!(retention_prefix(168), "7d");
assert_eq!(retention_prefix(360), "15d");
assert_eq!(retention_prefix(720), "custom-720h");
}
#[test]
fn generated_keys_are_lifecycle_scoped() {
let task_id = Uuid::nil();
let file_id = Uuid::from_u128(1);
let key = result_key(168, task_id, file_id, "webp");
assert!(key.starts_with("results/7d/"));
assert!(key.ends_with("/00000000-0000-0000-0000-000000000001.webp"));
assert!(archive_key(360, task_id).starts_with("archives/15d/"));
}
#[test]
fn content_disposition_never_injects_headers() {
let value = content_disposition("a\r\nX-Test: yes.png");
assert!(!value.contains('\r'));
assert!(!value.contains('\n'));
assert!(value.contains("filename*=UTF-8''"));
}
#[tokio::test]
async fn presigning_uses_public_path_style_endpoint() {
let client = build_client(
"GKTESTACCESSKEY".to_string(),
"test-secret-key".to_string(),
"https://files.example.com",
"garage",
true,
);
let request = client
.get_object()
.bucket("imageforge-results")
.key("results/1d/test.webp")
.presigned(PresigningConfig::expires_in(Duration::from_secs(300)).unwrap())
.await
.unwrap();
let uri = request.uri().to_string();
assert!(
uri.starts_with("https://files.example.com/imageforge-results/results/1d/test.webp?")
);
assert!(uri.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
assert!(uri.contains("X-Amz-Expires=300"));
assert!(uri.contains("X-Amz-Signature="));
}
}

View File

@@ -2,6 +2,7 @@ use crate::error::{AppError, ErrorCode};
use crate::services::billing;
use crate::services::compress;
use crate::services::quota;
use crate::services::storage;
use crate::state::AppState;
use redis::streams::StreamReadOptions;
@@ -148,17 +149,33 @@ struct TaskProcRow {
api_key_id: Option<Uuid>,
source: String,
client_ip: Option<String>,
retention_hours: i32,
}
#[derive(Debug, FromRow)]
struct TaskFileProcRow {
id: Uuid,
storage_path: Option<String>,
input_path: Option<String>,
original_format: String,
output_format: String,
status: String,
}
#[derive(Debug, FromRow)]
struct CleanupFileRow {
storage_backend: String,
storage_endpoint_id: Option<Uuid>,
storage_key: Option<String>,
input_path: Option<String>,
}
#[derive(Debug, FromRow)]
struct CleanupZipRow {
zip_storage_backend: Option<String>,
zip_storage_endpoint_id: Option<Uuid>,
zip_storage_key: Option<String>,
}
#[derive(Clone)]
struct TaskContext {
api_key_id: Option<Uuid>,
@@ -167,6 +184,7 @@ struct TaskContext {
session_id: Option<String>,
anon_ip: Option<IpAddr>,
is_anonymous: bool,
retention_hours: i32,
}
async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
@@ -183,7 +201,8 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
session_id,
api_key_id,
source::text AS source,
host(client_ip) AS client_ip
host(client_ip) AS client_ip,
retention_hours
FROM tasks
WHERE id = $1
"#,
@@ -241,7 +260,7 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
r#"
SELECT
id,
storage_path,
input_path,
original_format,
output_format,
status::text AS status
@@ -273,6 +292,7 @@ async fn process_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
session_id: task.session_id.clone(),
anon_ip,
is_anonymous: task.user_id.is_none(),
retention_hours: task.retention_hours,
};
let concurrency = state.config.worker_concurrency.max(1) as usize;
@@ -391,7 +411,7 @@ async fn process_task_file(
return Ok(());
}
let Some(input_path) = file.storage_path.clone() else {
let Some(input_path) = file.input_path.clone() else {
mark_file_failed(&state, task_id, file.id, "原文件不存在").await?;
return Ok(());
};
@@ -462,40 +482,50 @@ async fn process_task_file(
&& max_height.is_none();
let charge_units = !skip_charge && compressed_size < original_size;
let object_key = storage::result_key(
ctx.retention_hours as i64,
task_id,
file.id,
format_out.extension(),
);
let stored = match storage::store_bytes(
&state,
&object_key,
compressed,
format_out.content_type(),
)
.await
{
Ok(value) => value,
Err(err) => {
reset_file_for_retry(&state, file.id, "对象存储暂时不可用").await?;
return Err(err);
}
};
if ctx.is_anonymous && charge_units {
let Some(session_id) = ctx.session_id.as_deref() else {
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
mark_file_failed(&state, task_id, file.id, "匿名任务缺少 session_id").await?;
let _ = tokio::fs::remove_file(&input_path).await;
return Ok(());
};
let Some(ip) = ctx.anon_ip else {
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
mark_file_failed(&state, task_id, file.id, "匿名任务缺少 client_ip").await?;
let _ = tokio::fs::remove_file(&input_path).await;
return Ok(());
};
if let Err(err) = quota::consume_anonymous_units(&state, session_id, ip, 1).await {
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
mark_file_failed(&state, task_id, file.id, &err.message).await?;
let _ = tokio::fs::remove_file(&input_path).await;
return Ok(());
}
}
let output_path = format!(
"{}/{}.{}",
state.config.storage_path,
file.id,
format_out.extension()
);
if let Err(err) = tokio::fs::write(&output_path, &compressed).await {
mark_file_failed(&state, task_id, file.id, "写入压缩文件失败").await?;
let _ = tokio::fs::remove_file(&input_path).await;
return Err(
AppError::new(ErrorCode::StorageUnavailable, "写入压缩文件失败").with_source(err),
);
}
if is_task_cancelled(&state, task_id).await? {
let _ = tokio::fs::remove_file(&output_path).await;
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
mark_file_failed(&state, task_id, file.id, "已取消").await?;
let _ = tokio::fs::remove_file(&input_path).await;
return Ok(());
@@ -508,7 +538,7 @@ async fn process_task_file(
&ctx.source,
task_id,
file.id,
&output_path,
&stored,
original_size as i64,
compressed_size as i64,
saved_percent,
@@ -518,12 +548,8 @@ async fn process_task_file(
)
.await
{
if err.code == ErrorCode::QuotaExceeded {
let _ = tokio::fs::remove_file(&output_path).await;
mark_file_failed(&state, task_id, file.id, &err.message).await?;
} else {
mark_file_failed(&state, task_id, file.id, &err.message).await?;
}
let _ = storage::delete_object(&state, &stored_locator(&stored)).await;
mark_file_failed(&state, task_id, file.id, &err.message).await?;
let _ = tokio::fs::remove_file(&input_path).await;
return Ok(());
}
@@ -540,7 +566,7 @@ async fn finalize_file(
source: &str,
task_id: Uuid,
task_file_id: Uuid,
output_path: &str,
stored: &storage::StoredObject,
bytes_in: i64,
bytes_out: i64,
saved_percent: f64,
@@ -577,15 +603,28 @@ async fn finalize_file(
r#"
UPDATE task_files
SET storage_path = $2,
compressed_size = $3,
saved_percent = $4,
storage_backend = $3,
storage_endpoint_id = $4,
storage_key = $5,
storage_etag = $6,
input_path = NULL,
compressed_size = $7,
saved_percent = $8,
status = 'completed',
completed_at = NOW()
WHERE id = $1
"#,
)
.bind(task_file_id)
.bind(output_path)
.bind(if stored.backend == "local" {
Some(stored.key.as_str())
} else {
None
})
.bind(&stored.backend)
.bind(stored.endpoint_id)
.bind(&stored.key)
.bind(&stored.etag)
.bind(bytes_out)
.bind(saved_percent)
.execute(&mut *tx)
@@ -631,6 +670,7 @@ async fn mark_file_failed(
SET status = 'failed',
error_message = $2,
storage_path = NULL,
input_path = NULL,
completed_at = NOW()
WHERE id = $1
AND status NOT IN ('completed', 'failed')
@@ -663,6 +703,22 @@ async fn mark_file_failed(
Ok(())
}
async fn reset_file_for_retry(
state: &AppState,
task_file_id: Uuid,
message: &str,
) -> Result<(), AppError> {
sqlx::query(
"UPDATE task_files SET status = 'pending', error_message = $2 WHERE id = $1 AND status = 'processing'",
)
.bind(task_file_id)
.bind(message)
.execute(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "恢复待重试文件失败").with_source(err))?;
Ok(())
}
async fn finalize_task_status(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
let row: Option<(i32, i32, i32, String)> = sqlx::query_as(
"SELECT total_files, completed_files, failed_files, status::text AS status FROM tasks WHERE id = $1",
@@ -677,7 +733,7 @@ async fn finalize_task_status(state: &AppState, task_id: Uuid) -> Result<(), App
};
if status == "cancelled" {
let paths: Vec<Option<String>> = sqlx::query_scalar(
"SELECT storage_path FROM task_files WHERE task_id = $1 AND status IN ('pending','processing')",
"SELECT input_path FROM task_files WHERE task_id = $1 AND status IN ('pending','processing')",
)
.bind(task_id)
.fetch_all(&state.db)
@@ -688,7 +744,7 @@ async fn finalize_task_status(state: &AppState, task_id: Uuid) -> Result<(), App
}
let _ = sqlx::query(
"UPDATE task_files SET status = 'failed', error_message = '已取消', storage_path = NULL, completed_at = NOW() WHERE task_id = $1 AND status IN ('pending','processing')",
"UPDATE task_files SET status = 'failed', error_message = '已取消', storage_path = NULL, input_path = NULL, completed_at = NOW() WHERE task_id = $1 AND status IN ('pending','processing')",
)
.bind(task_id)
.execute(&state.db)
@@ -810,10 +866,47 @@ async fn charge_one_unit(
async fn maintenance(state: &AppState) -> Result<(), AppError> {
cleanup_expired_tasks(state).await?;
cleanup_stale_zip_temp(state).await?;
cleanup_expired_records(state).await?;
Ok(())
}
async fn cleanup_stale_zip_temp(state: &AppState) -> Result<(), AppError> {
let root = std::path::Path::new(&state.config.storage_path).join("tmp/zips");
let mut entries = match tokio::fs::read_dir(&root).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) => {
return Err(
AppError::new(ErrorCode::StorageUnavailable, "读取 ZIP 临时目录失败")
.with_source(err),
)
}
};
let cutoff = std::time::SystemTime::now()
.checked_sub(std::time::Duration::from_secs(6 * 60 * 60))
.unwrap_or(std::time::UNIX_EPOCH);
while let Some(entry) = entries.next_entry().await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "遍历 ZIP 临时目录失败").with_source(err)
})? {
let metadata = match entry.metadata().await {
Ok(metadata) => metadata,
Err(_) => continue,
};
if !metadata.is_dir()
|| metadata
.modified()
.map(|time| time >= cutoff)
.unwrap_or(true)
{
continue;
}
let _ = tokio::fs::remove_dir_all(entry.path()).await;
}
Ok(())
}
async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> {
let _ = sqlx::query("DELETE FROM idempotency_keys WHERE expires_at < NOW()")
.execute(&state.db)
@@ -834,6 +927,17 @@ async fn cleanup_expired_records(state: &AppState) -> Result<(), AppError> {
.execute(&state.db)
.await;
let _ = sqlx::query(
r#"
DELETE FROM storage_endpoints e
WHERE e.deleted_at < NOW() - INTERVAL '30 days'
AND NOT EXISTS (SELECT 1 FROM task_files f WHERE f.storage_endpoint_id = e.id)
AND NOT EXISTS (SELECT 1 FROM tasks t WHERE t.zip_storage_endpoint_id = e.id)
"#,
)
.execute(&state.db)
.await;
Ok(())
}
@@ -848,33 +952,87 @@ async fn cleanup_expired_tasks(state: &AppState) -> Result<(), AppError> {
return Ok(());
}
if state.config.storage_type.eq_ignore_ascii_case("local") {
for task_id in &task_ids {
let paths: Vec<Option<String>> =
sqlx::query_scalar("SELECT storage_path FROM task_files WHERE task_id = $1")
.bind(task_id)
.fetch_all(&state.db)
.await
.unwrap_or_default();
for p in paths.into_iter().flatten() {
let _ = tokio::fs::remove_file(p).await;
}
let zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
let _ = tokio::fs::remove_file(zip_path).await;
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
let _ = tokio::fs::remove_dir_all(orig_dir).await;
}
}
for task_id in task_ids {
let _ = sqlx::query("DELETE FROM tasks WHERE id = $1")
.bind(task_id)
.execute(&state.db)
.await;
if let Err(err) = cleanup_expired_task(state, task_id).await {
tracing::warn!(task_id = %task_id, error = %err, "expired task cleanup deferred");
}
}
Ok(())
}
async fn cleanup_expired_task(state: &AppState, task_id: Uuid) -> Result<(), AppError> {
let files: Vec<CleanupFileRow> = sqlx::query_as(
r#"
SELECT storage_backend, storage_endpoint_id,
COALESCE(storage_key, storage_path) AS storage_key,
input_path
FROM task_files
WHERE task_id = $1
"#,
)
.bind(task_id)
.fetch_all(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询过期任务文件失败").with_source(err))?;
for file in files {
if let Some(key) = file.storage_key {
storage::delete_object(
state,
&storage::ObjectLocator {
backend: file.storage_backend,
endpoint_id: file.storage_endpoint_id,
key,
},
)
.await?;
}
if let Some(input_path) = file.input_path {
let _ = tokio::fs::remove_file(input_path).await;
}
}
let zip: Option<CleanupZipRow> = sqlx::query_as(
"SELECT zip_storage_backend, zip_storage_endpoint_id, zip_storage_key FROM tasks WHERE id = $1",
)
.bind(task_id)
.fetch_optional(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "查询过期 ZIP 失败").with_source(err))?;
if let Some(zip) = zip {
if let (Some(backend), Some(key)) = (zip.zip_storage_backend, zip.zip_storage_key) {
storage::delete_object(
state,
&storage::ObjectLocator {
backend,
endpoint_id: zip.zip_storage_endpoint_id,
key,
},
)
.await?;
}
}
let legacy_zip_path = format!("{}/zips/{task_id}.zip", state.config.storage_path);
let _ = tokio::fs::remove_file(legacy_zip_path).await;
let orig_dir = format!("{}/orig/{task_id}", state.config.storage_path);
let _ = tokio::fs::remove_dir_all(orig_dir).await;
sqlx::query("DELETE FROM tasks WHERE id = $1 AND expires_at < NOW()")
.bind(task_id)
.execute(&state.db)
.await
.map_err(|err| {
AppError::new(ErrorCode::Internal, "删除过期任务记录失败").with_source(err)
})?;
Ok(())
}
fn stored_locator(stored: &storage::StoredObject) -> storage::ObjectLocator {
storage::ObjectLocator {
backend: stored.backend.clone(),
endpoint_id: stored.endpoint_id,
key: stored.key.clone(),
}
}