feat(s3): 图片上传到 S3——设置/自动上传/右键单张/批量上传(docs/30)
Rust 新增 s3.rs:SigV4 签名 + PUT Object(reqwest blocking+rustls, 签名对照 AWS 官方示例向量逐字一致;同份代码对本地模拟 S3 端到端 实测通过)。命令 s3_upload_image / s3_test_connection(总数 30 对账)。 配置存 UiState.s3(本机偏好),设置弹窗含测试连接与自动上传开关。 未上传判定 = src 非 http/data/blob 的本地引用;上传成功后同 src 的 图片节点一次事务回换为 S3 地址,本地 .assets 文件保留。 入口:粘贴/插入后自动上传回换(扑空重试一次、失败必提示);右键 命中本地图片多一项「上传到 S3」;文件菜单 + 状态栏批量上传带进度 弹窗。验证:vitest 133+2、tsc、构建通过,设置弹窗截图核对。
This commit is contained in:
403
src-tauri/Cargo.lock
generated
403
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,3 +19,6 @@ serde_json = "1"
|
||||
encoding_rs = "0.8"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
trash = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
|
||||
@@ -925,3 +925,25 @@ pub async fn git_sync_now(root: String, message: String) -> Result<GitSyncOutcom
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
// ————————————————— S3 图片上传(docs/30) —————————————————
|
||||
// 配置随调用从前端传入(设置弹窗收集、存本机偏好),Rust 侧不保存密钥;
|
||||
// 实现在 s3.rs(SigV4 签名已对照 AWS 官方示例向量验证)。
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_upload_image(
|
||||
config: crate::s3::S3Config,
|
||||
file_path: String,
|
||||
file_name: String,
|
||||
) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || crate::s3::upload(&config, &file_path, &file_name))
|
||||
.await
|
||||
.map_err(|e| format!("上传任务失败:{e}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn s3_test_connection(config: crate::s3::S3Config) -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || crate::s3::test_connection(&config))
|
||||
.await
|
||||
.map_err(|e| format!("连接测试失败:{e}"))?
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod commands;
|
||||
mod s3;
|
||||
mod search;
|
||||
mod storage;
|
||||
|
||||
@@ -66,6 +67,8 @@ pub fn run() {
|
||||
commands::git_init_repo,
|
||||
commands::git_set_remote,
|
||||
commands::git_sync_now,
|
||||
commands::s3_upload_image,
|
||||
commands::s3_test_connection,
|
||||
commands::delete_to_trash,
|
||||
commands::db_recent_list,
|
||||
commands::db_recent_add,
|
||||
|
||||
322
src-tauri/src/s3.rs
Normal file
322
src-tauri/src/s3.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! S3 兼容对象存储上传(docs/30):AWS SigV4 签名 + PUT Object。
|
||||
//! 配置由前端设置弹窗收集、随每次调用传入,Rust 侧不落盘保存密钥。
|
||||
//! endpoint 为空 = AWS S3(虚拟主机风格);填了自定义 endpoint(MinIO /
|
||||
//! R2 / OSS 等 S3 兼容服务)走路径风格 {endpoint}/{bucket}/{key}。
|
||||
//! 签名实现已对照 AWS 官方文档示例向量验证(签名值逐字一致)。
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct S3Config {
|
||||
/// 自定义服务端点(空 = AWS 官方),如 https://minio.example.com:9000
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
pub region: String,
|
||||
pub bucket: String,
|
||||
pub access_key: String,
|
||||
pub secret_key: String,
|
||||
/// 对象键前缀(可空),如 notes/
|
||||
#[serde(default)]
|
||||
pub prefix: String,
|
||||
/// 自定义访问域名(可空),如 https://cdn.example.com;填了则回填文档的
|
||||
/// 是这个域名下的地址,否则用上传请求本身的地址
|
||||
#[serde(default)]
|
||||
pub public_base: String,
|
||||
}
|
||||
|
||||
const MAX_IMAGE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
fn validate(cfg: &S3Config) -> Result<(), String> {
|
||||
if cfg.bucket.trim().is_empty() {
|
||||
return Err("请先在 S3 设置里填写存储桶(Bucket)。".into());
|
||||
}
|
||||
if cfg.region.trim().is_empty() {
|
||||
return Err("请先在 S3 设置里填写区域(Region)。".into());
|
||||
}
|
||||
if cfg.access_key.trim().is_empty() || cfg.secret_key.trim().is_empty() {
|
||||
return Err("请先在 S3 设置里填写 Access Key 与 Secret Key。".into());
|
||||
}
|
||||
let ep = cfg.endpoint.trim();
|
||||
if !ep.is_empty() && !ep.starts_with("http://") && !ep.starts_with("https://") {
|
||||
return Err("服务端点需以 http:// 或 https:// 开头。".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 对象键:前缀(归一为无前导 /、非空时以 / 结尾)+ 文件名。
|
||||
fn object_key(cfg: &S3Config, file_name: &str) -> String {
|
||||
let prefix = cfg.prefix.trim().trim_start_matches('/');
|
||||
if prefix.is_empty() {
|
||||
file_name.to_string()
|
||||
} else if prefix.ends_with('/') {
|
||||
format!("{prefix}{file_name}")
|
||||
} else {
|
||||
format!("{prefix}/{file_name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传目标:返回 (请求 URL, 签名用 host, canonical URI)。
|
||||
fn target(cfg: &S3Config, key: &str) -> (String, String, String) {
|
||||
let encoded_key = encode_uri_path(&format!("/{}", key.trim_start_matches('/')));
|
||||
let endpoint = cfg.endpoint.trim().trim_end_matches('/');
|
||||
if endpoint.is_empty() {
|
||||
let host = format!("{}.s3.{}.amazonaws.com", cfg.bucket.trim(), cfg.region.trim());
|
||||
(format!("https://{host}{encoded_key}"), host, encoded_key)
|
||||
} else {
|
||||
let after_scheme = endpoint.split("://").nth(1).unwrap_or(endpoint);
|
||||
let host = after_scheme.split('/').next().unwrap_or(after_scheme).to_string();
|
||||
let uri = format!("/{bucket}{encoded_key}", bucket = cfg.bucket.trim());
|
||||
(format!("{endpoint}{uri}"), host, uri)
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type_of(file_name: &str) -> &'static str {
|
||||
let ext = file_name.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
|
||||
match ext.as_str() {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"svg" => "image/svg+xml",
|
||||
"bmp" => "image/bmp",
|
||||
"ico" => "image/x-icon",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::blocking::Client, String> {
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| format!("初始化网络客户端失败:{e}"))
|
||||
}
|
||||
|
||||
/// 从 S3 的 XML 错误体里粗提取 Code / Message,给用户一句能看懂的话。
|
||||
fn s3_error(status: reqwest::StatusCode, body: &str) -> String {
|
||||
let grab = |tag: &str| -> Option<String> {
|
||||
let open = format!("<{tag}>");
|
||||
let close = format!("</{tag}>");
|
||||
let start = body.find(&open)? + open.len();
|
||||
let end = body[start..].find(&close)? + start;
|
||||
Some(body[start..end].trim().to_string())
|
||||
};
|
||||
match (grab("Code"), grab("Message")) {
|
||||
(Some(code), Some(msg)) => format!("S3 返回 {status}({code}):{msg}"),
|
||||
_ => format!("S3 返回 {status}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn now_amz_dates() -> (String, String) {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
amz_dates(secs)
|
||||
}
|
||||
|
||||
fn signed_request(
|
||||
cfg: &S3Config,
|
||||
method: &str,
|
||||
host: &str,
|
||||
canonical_uri: &str,
|
||||
content_type: Option<&str>,
|
||||
payload_hash: &str,
|
||||
) -> (String, String, String) {
|
||||
let (amz_date, date_stamp) = now_amz_dates();
|
||||
let mut extras: Vec<(&str, &str)> = vec![("x-amz-content-sha256", payload_hash)];
|
||||
if let Some(ct) = content_type {
|
||||
extras.push(("content-type", ct));
|
||||
}
|
||||
let auth = authorization_header(&SignParams {
|
||||
method,
|
||||
host,
|
||||
canonical_uri,
|
||||
canonical_query: "",
|
||||
region: cfg.region.trim(),
|
||||
service: "s3",
|
||||
access_key: cfg.access_key.trim(),
|
||||
secret_key: cfg.secret_key.trim(),
|
||||
amz_date: &amz_date,
|
||||
date_stamp: &date_stamp,
|
||||
payload_hash,
|
||||
extra_headers: &extras,
|
||||
});
|
||||
(auth, amz_date, date_stamp)
|
||||
}
|
||||
|
||||
/// 上传一个本地文件到 S3,成功返回文档应回填的公开访问地址。
|
||||
pub fn upload(cfg: &S3Config, file_path: &str, file_name: &str) -> Result<String, String> {
|
||||
validate(cfg)?;
|
||||
let meta = std::fs::metadata(file_path)
|
||||
.map_err(|e| format!("读取待上传图片失败({file_path}):{e}"))?;
|
||||
if meta.len() > MAX_IMAGE_BYTES {
|
||||
return Err("图片超过 64MB,未上传。".into());
|
||||
}
|
||||
let bytes = std::fs::read(file_path).map_err(|e| format!("读取待上传图片失败:{e}"))?;
|
||||
let key = object_key(cfg, file_name);
|
||||
let (url, host, canonical_uri) = target(cfg, &key);
|
||||
let payload_hash = sha256_hex(&bytes);
|
||||
let content_type = content_type_of(file_name);
|
||||
let (auth, amz_date, _) = signed_request(cfg, "PUT", &host, &canonical_uri, Some(content_type), &payload_hash);
|
||||
let resp = client()?
|
||||
.put(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("x-amz-date", amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.header("Content-Type", content_type)
|
||||
.body(bytes)
|
||||
.send()
|
||||
.map_err(|e| format!("连接 S3 失败:{e}"))?;
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
let base = cfg.public_base.trim().trim_end_matches('/');
|
||||
if base.is_empty() {
|
||||
Ok(url)
|
||||
} else {
|
||||
Ok(format!("{base}{}", encode_uri_path(&format!("/{key}"))))
|
||||
}
|
||||
} else {
|
||||
let body = resp.text().unwrap_or_default();
|
||||
Err(s3_error(status, &body))
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试连接:对存储桶发一个签名的 HEAD 请求,按状态码给出可读结论。
|
||||
pub fn test_connection(cfg: &S3Config) -> Result<String, String> {
|
||||
validate(cfg)?;
|
||||
let (url, host, canonical_uri) = target(cfg, "");
|
||||
// target 以空 key 生成的是「桶根」地址:虚拟主机风格即桶域名根,
|
||||
// 路径风格即 {endpoint}/{bucket}/——HEAD 桶正合适
|
||||
let payload_hash = sha256_hex(b"");
|
||||
let (auth, amz_date, _) = signed_request(cfg, "HEAD", &host, &canonical_uri, None, &payload_hash);
|
||||
let resp = client()?
|
||||
.head(&url)
|
||||
.header("Authorization", auth)
|
||||
.header("x-amz-date", amz_date)
|
||||
.header("x-amz-content-sha256", &payload_hash)
|
||||
.send()
|
||||
.map_err(|e| format!("连接 S3 失败:{e}"))?;
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
Ok("连接成功:存储桶可访问,密钥有效。".into())
|
||||
} else if status.as_u16() == 403 {
|
||||
Err("服务器返回 403:密钥不正确,或该密钥无此存储桶的访问权限。".into())
|
||||
} else if status.as_u16() == 404 {
|
||||
Err("服务器返回 404:存储桶不存在(检查桶名与区域)。".into())
|
||||
} else {
|
||||
Err(format!("服务器返回 {status}。"))
|
||||
}
|
||||
}
|
||||
|
||||
// ————————————————— SigV4 签名(已对照 AWS 官方示例向量验证) —————————————————
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(data);
|
||||
hex(&h.finalize())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hmac(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC 接受任意长度密钥");
|
||||
mac.update(data);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// 由 epoch 秒生成 SigV4 用的 UTC 时间串(amzDate 如 20150830T123600Z、dateStamp 如 20150830)。
|
||||
pub fn amz_dates(epoch_secs: u64) -> (String, String) {
|
||||
let days = (epoch_secs / 86_400) as i64;
|
||||
let secs = epoch_secs % 86_400;
|
||||
let (hh, mm, ss) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
// civil_from_days(Howard Hinnant)
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = if m <= 2 { y + 1 } else { y };
|
||||
(
|
||||
format!("{year:04}{m:02}{d:02}T{hh:02}{mm:02}{ss:02}Z"),
|
||||
format!("{year:04}{m:02}{d:02}"),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct SignParams<'a> {
|
||||
pub method: &'a str,
|
||||
pub host: &'a str,
|
||||
pub canonical_uri: &'a str,
|
||||
pub canonical_query: &'a str,
|
||||
pub region: &'a str,
|
||||
pub service: &'a str,
|
||||
pub access_key: &'a str,
|
||||
pub secret_key: &'a str,
|
||||
pub amz_date: &'a str,
|
||||
pub date_stamp: &'a str,
|
||||
pub payload_hash: &'a str,
|
||||
/// 除 host / x-amz-date 外参与签名的请求头(名须小写)
|
||||
pub extra_headers: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
/// 计算 Authorization 头的完整值。
|
||||
pub fn authorization_header(p: &SignParams) -> String {
|
||||
let mut headers: Vec<(&str, &str)> = vec![("host", p.host), ("x-amz-date", p.amz_date)];
|
||||
headers.extend_from_slice(p.extra_headers);
|
||||
headers.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let canonical_headers: String = headers
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}:{}\n", v.trim()))
|
||||
.collect();
|
||||
let signed_headers: String = headers
|
||||
.iter()
|
||||
.map(|(k, _)| *k)
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
let canonical_request = format!(
|
||||
"{}\n{}\n{}\n{}\n{}\n{}",
|
||||
p.method, p.canonical_uri, p.canonical_query, canonical_headers, signed_headers, p.payload_hash
|
||||
);
|
||||
let scope = format!("{}/{}/{}/aws4_request", p.date_stamp, p.region, p.service);
|
||||
let string_to_sign = format!(
|
||||
"AWS4-HMAC-SHA256\n{}\n{}\n{}",
|
||||
p.amz_date,
|
||||
scope,
|
||||
sha256_hex(canonical_request.as_bytes())
|
||||
);
|
||||
let k_date = hmac(format!("AWS4{}", p.secret_key).as_bytes(), p.date_stamp.as_bytes());
|
||||
let k_region = hmac(&k_date, p.region.as_bytes());
|
||||
let k_service = hmac(&k_region, p.service.as_bytes());
|
||||
let k_signing = hmac(&k_service, b"aws4_request");
|
||||
let signature = hex(&hmac(&k_signing, string_to_sign.as_bytes()));
|
||||
format!(
|
||||
"AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}",
|
||||
p.access_key, scope, signed_headers, signature
|
||||
)
|
||||
}
|
||||
|
||||
/// URI 路径编码(SigV4 canonical URI 与公开地址共用):保留 / 与 RFC 3986 非保留字符。
|
||||
pub fn encode_uri_path(path: &str) -> String {
|
||||
let mut out = String::with_capacity(path.len());
|
||||
for b in path.bytes() {
|
||||
match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => {
|
||||
out.push(b as char)
|
||||
}
|
||||
_ => out.push_str(&format!("%{b:02X}")),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user