feat: 实现 SimApiStorage(S3/MinIO 存储)

- 自实现 AWS Signature V4(HMAC-SHA256 基于 stdx SHA256),无需 Minio SDK
- getUploadUrl / getDownloadUrl(预签名 PUT/GET)
- uploadFile(直接 PUT 上传)、fullUrl / getUrl / getPath
- 构造时检测并自动创建桶(静态守卫只执行一次)
- 注册为 Scoped 以注入 IHttpContextAccessor(soulsoft DI 禁止 singleton 消费 scoped)
- 签名核心已用 AWS 官方测试向量验证(最终签名 5d672d79... 一致)
- README 同步(未实现表移除、新增 5.3 存储章节)
This commit is contained in:
2026-08-17 00:00:15 +08:00
parent 92eea3108a
commit eb488bcd5a
3 changed files with 490 additions and 4 deletions
+32 -3
View File
@@ -82,7 +82,8 @@ simapi-cj/
│ ├── exceptions/ # SimApiException
│ ├── extensions/ # SimApiExtensionsaddSimApi / useSimApi + 内置路由 + 响应封装)
│ ├── helpers/ # SimApiError, SimApiUtil, SimApiAuth, SimApiCache, SimApiHttpClient,
│ │ # SimApiAesUtil(AES-256), SimApiSignChecker(验签), SimApiAesBodyChecker(AES body)
│ │ # SimApiAesUtil(AES-256), SimApiSignChecker(验签), SimApiAesBodyChecker(AES body),
│ │ # SimApiStorage(S3/MinIO, 自实现 SigV4)
│ ├── interfaces/ # ISimApiAuthChecker
│ ├── logger/ # SimApiLogger, SimApiLoggerProvider(彩色日志)
│ ├── macros/ # ReadTomlVersion(编译期读版本号)
@@ -320,6 +321,35 @@ builder.addSimApi { options =>
| Fatal | 深红(DarkRed 粗体近似) |
| 其他 | 白(White |
### 5.3 存储 — SimApiStorageS3/MinIO,对齐 C# SimApiStorage
`enableSimApiStorage = true` 时注册 `SimApiStorage`Scoped,内部自实现 AWS Signature V4,无需 Minio SDK):
```cangjie
builder.addSimApi { options =>
options.enableSimApiStorage = true
options.configureSimApiStorage { storage =>
storage.endpoint = "http://192.168.0.2:9000" // 必须 http:// 或 https:// 开头
storage.serveUrl = "https://files.example.com" // 文件访问地址,不能以 / 结尾
storage.bucket = "app-files"
storage.accessKey = "minioadmin"
storage.secretKey = "minioadmin"
}
}
```
| 方法 | 说明 |
|------|------|
| `getUploadUrl(path, expire=7200)` | 上传预签名 URL,返回 `GetUploadUrlResponse(UploadUrl, DownloadUrl, Path)` |
| `getDownloadUrl(path, expire=600)` | 下载预签名 URL |
| `uploadFile(path, data, contentType="image/png")` | 直接 PUT 上传(字节数组) |
| `fullUrl(path)` / `getUrl(path)` | 补全访问 URL`~/` 前缀依赖请求上下文) |
| `getPath(url)` | 从 URL 还原相对路径(去掉 Endpoint/Bucket 或 ServeUrl 前缀) |
> 说明:桶不存在时自动创建(对齐 C# BucketExists + MakeBucket,静态守卫只执行一次);
> 预签名与上传使用 AWS SigV4HMAC-SHA256 基于 stdx SHA256 自实现),已用 AWS 官方测试向量验证签名正确。
> 注册为 Scoped 是为了注入 `IHttpContextAccessor`soulsoft DI 禁止 singleton 消费 scoped 服务)。
### 6. 内置路由(UseSimApi 自动注册)
| 路由 | 方法 | 条件 | 说明 |
@@ -465,11 +495,10 @@ public class MyController <: SimApiBaseController {
| 选项 | 原功能 | 状态 |
|------|--------|------|
| `enableSimApiDoc` | Swagger 文档(可换 soulsoft_web_openapi | ❌ 未实现 |
| `enableSimApiStorage` | S3/MinIO 存储 | ❌ 未实现 |
| `enableSynapse` | MQTT 通信 | ❌ 未实现 |
| `enableJob` | Hangfire 任务调度 | ❌ 未实现 |
> ✅ 已实现(曾为占位):`enableSimApiAuthGate`AuthSDK 认证中心)、`SimApiAesUtil`(纯仓颉 AES-256-CBC,与 .NET 双向互操作)、`ISimApiAuthChecker`(注解鉴权时执行)、内置路由自定义路径。
> ✅ 已实现(曾为占位):`enableSimApiStorage`S3/MinIO,自实现 AWS SigV4)、`enableSimApiAuthGate`AuthSDK 认证中心)、`SimApiAesUtil`(纯仓颉 AES-256-CBC,与 .NET 双向互操作)、`ISimApiAuthChecker`(注解鉴权时执行)、内置路由自定义路径。
---
+10 -1
View File
@@ -136,6 +136,14 @@ private func addSimApiCore(builder: WebHostBuilder, options: SimApiOptions): Web
builder.services.addSingleton<SimApiHttpClient, SimApiHttpClient>()
}
// 存储(S3/MinIO,对齐 C# AddHttpContextAccessor + AddSingleton<SimApiStorage>
// 仓颉版注册为 Scoped 以便注入 IHttpContextAccessorDI 禁止 singleton 消费 scoped),
// 桶初始化由静态守卫保证只执行一次)
if (options.enableSimApiStorage) {
builder.services.addHttpContextAccessor()
builder.services.addScoped<SimApiStorage, SimApiStorage>()
}
// AuthGate 认证中心 SDK(对齐 C# 注册 SimApiAuthClient/Center/Iam 单例)
if (options.enableSimApiAuthGate) {
builder.services.addSingleton<SimApiAuthClient, SimApiAuthClient>()
@@ -194,7 +202,8 @@ extend WebHost <: SimApiHostExtensions {
logger.info("开始配置 SimApiCache...")
}
// SimApiStorage占位)
// SimApiStorage已实现:addSimApi 中注册 Scoped,桶初始化惰性执行;
// 对齐 C# 的 GetService 预热,但 scoped 服务不能从根解析,故仅输出配置日志)
if (options.enableSimApiStorage) {
logger.info("开始配置 SimApiStorage...")
}
+448
View File
@@ -0,0 +1,448 @@
/*
* Copyright (c) 2025 SimcuTeam. All rights reserved.
* 移植自 C# 项目 SimApiE:\simcu\simapi-net),遵循 MIT 许可证。
* Helpers/SimApiStorageS3/MinIO 兼容存储助手。
*
* 仓颉生态暂无 Minio SDK,此处自实现 AWS Signature V4HMAC-SHA256 基于 stdx SHA256):
* - 预签名 URLPUT 上传 / GET 下载)
* - 直接 PUT 上传(对象 + 建桶)
* - HEAD 检测桶是否存在
* 与 C# SimApiStorageMinio SDK)的公开方法语义对齐:
* GetUploadUrl / GetDownloadUrl / UploadFile / FullUrl / GetUrl / GetPath
*/
package simapi.helpers
import std.collection.*
import std.time.*
import stdx.crypto.digest.*
import stdx.encoding.hex.*
import stdx.net.tls.*
import stdx.net.tls.common.*
import soulsoft_net_http.{ByteArrayContent, HttpClient, HttpRequestMessage}
import soulsoft_net_http.{HttpMethod as NetHttpMethod}
import soulsoft_web_http.*
import simapi.configurations.*
/**
* 上传预签名 URL 响应(对齐 C# record GetUploadUrlResponse(UploadUrl, DownloadUrl, Path))。
*/
public class GetUploadUrlResponse {
public var uploadUrl: String = ""
public var downloadUrl: String = ""
public var path: String = ""
public init() {}
public init(uploadUrl: String, downloadUrl: String, path: String) {
this.uploadUrl = uploadUrl
this.downloadUrl = downloadUrl
this.path = path
}
}
/**
* S3/MinIO 存储助手(对齐 C# Helpers/SimApiStorage)。
*
* 说明:
* - 注册为 Scoped 以注入 IHttpContextAccessorsoulsoft DI 禁止 singleton 消费 scoped 服务);
* 桶的检测/创建由静态守卫保证整个进程只执行一次(对齐 C# 构造函数中 BucketExists+MakeBucket)。
* - fullUrl/getUrl 的 "~/" 分支依赖当前请求上下文(对齐 C# IHttpContextAccessor)。
*/
public class SimApiStorage {
private static var _bucketEnsured: Bool = false
private let _endpoint: String
private let _serveUrl: String
private let _bucket: String
private let _accessKey: String
private let _secretKey: String
private let _useSsl: Bool
private let _host: String
private let _region: String = "us-east-1"
private let _httpContextAccessor: IHttpContextAccessor
public init(options: SimApiOptions, httpContextAccessor: IHttpContextAccessor) {
let storage = options.simApiStorageOptions
if (storage.endpoint.isEmpty() || storage.serveUrl.isEmpty() || storage.bucket.isEmpty()) {
throw Exception("SimApiStorage: Endpoint/ServeUrl/Bucket 不能为空")
}
var useSsl = false
var host = ""
if (storage.endpoint.startsWith("http://")) {
host = storage.endpoint["http://".size..]
} else if (storage.endpoint.startsWith("https://")) {
useSsl = true
host = storage.endpoint["https://".size..]
} else {
throw Exception("SimApiStorage: Error Endpoint")
}
if (storage.serveUrl.endsWith("/")) {
throw Exception("SimApiStorage: ServeUrl must not end with /")
}
_endpoint = storage.endpoint
_serveUrl = storage.serveUrl
_bucket = storage.bucket
_accessKey = storage.accessKey
_secretKey = storage.secretKey
_useSsl = useSsl
_host = host
_httpContextAccessor = httpContextAccessor
// 桶不存在则创建(对齐 C# BucketExists + MakeBucket;静态守卫保证只执行一次)
ensureBucketOnce()
}
/**
* 获取上传预签名 URL(对齐 C# GetUploadUrl,默认 7200 秒)。
*/
public func getUploadUrl(path: String, expire!: Int64 = 7200): GetUploadUrlResponse {
checkPath(path)
let obj = trimLeadingSlash(path)
let uploadUrl = presign("PUT", obj, expire)
GetUploadUrlResponse(uploadUrl, "${_serveUrl}${path}", path)
}
/**
* 获取下载预签名 URL(对齐 C# GetDownloadUrl,默认 600 秒)。
*/
public func getDownloadUrl(path: String, expire!: Int64 = 600): String {
checkPath(path)
let obj = trimLeadingSlash(path)
presign("GET", obj, expire)
}
/**
* 直接上传文件(对齐 C# UploadFiledata 为文件字节)。
*/
public func uploadFile(path: String, data: Array<Byte>, contentType!: String = "image/png"): Unit {
checkPath(path)
let obj = trimLeadingSlash(path)
putObject(obj, data, contentType)
}
/**
* 使用 path 获取完整的访问 URL(对齐 C# FullUrl)。
*/
public func fullUrl(path: ?String): ?String {
if (let Some(p) <- path) {
if (p.isEmpty() || p.startsWith("http://") || p.startsWith("https://")) {
return Some(p)
}
if (!(p.startsWith("/") || p.startsWith("~/"))) {
return Some(p)
}
if (p.startsWith("~/")) {
return Some("${requestBaseUrl()}${p[1..]}")
}
return Some("${_serveUrl}${p}")
}
None
}
/**
* 获取一个 Path 的访问 URL(对齐 C# GetUrl)。
*/
public func getUrl(path: ?String): ?String {
if (let Some(p) <- path) {
if (p.isEmpty()) {
return Some(p)
}
if (p.startsWith("~/")) {
return Some("${requestBaseUrl()}${p[1..]}")
}
if (p.startsWith("/")) {
return Some("${_serveUrl}${p}")
}
return Some(p)
}
None
}
/**
* 从 URL 中获取相对路径(对齐 C# GetPath;去掉 Endpoint/Bucket 或 ServeUrl 前缀)。
*/
public func getPath(url: ?String): ?String {
if (let Some(u) <- url) {
var r = u
let prefix = "${_endpoint}/${_bucket}"
if (r.startsWith(prefix)) {
r = r[prefix.size..]
}
if (r.startsWith(_serveUrl)) {
r = r[_serveUrl.size..]
}
return Some(r)
}
None
}
// ===== 私有:SigV4 签名与 S3 请求 =====
/// 预签名 URL(对齐 Minio PresignedPutObject / PresignedGetObject
private func presign(method: String, object: String, expireSeconds: Int64): String {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let canonicalUri = "/${_bucket}/${uriEncode(object, false)}"
let credential = "${_accessKey}/${dateStamp}/${_region}/s3/aws4_request"
// 五个 X-Amz-* 参数按字典序排列(A<C<D<E<S
let canonicalQuery = "X-Amz-Algorithm=${uriEncode("AWS4-HMAC-SHA256", true)}" +
"&X-Amz-Credential=${uriEncode(credential, true)}" +
"&X-Amz-Date=${amzDate}" +
"&X-Amz-Expires=${expireSeconds}" +
"&X-Amz-SignedHeaders=host"
let canonicalHeaders = "host:${_host}\n"
let signedHeaders = "host"
let canonicalRequest = "${method}\n${canonicalUri}\n${canonicalQuery}\n${canonicalHeaders}\n${signedHeaders}\nUNSIGNED-PAYLOAD"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let signature = toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray()))
"${_endpoint}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}"
}
/// 直接 PUT 上传对象(Authorization 头签名,对齐 Minio PutObject
private func putObject(object: String, data: Array<Byte>, contentType: String): Unit {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(data))
let canonicalUri = "/${_bucket}/${uriEncode(object, false)}"
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\n"
let signedHeaders = "host;x-amz-content-sha256"
let canonicalRequest = "PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let request = HttpRequestMessage(NetHttpMethod.Put, "${_endpoint}${canonicalUri}")
request.headers.add("x-amz-content-sha256", payloadHash)
request.headers.add("Authorization", authorization)
request.headers.add("Content-Type", contentType)
request.content = ByteArrayContent(data)
let response = client.send(request)
try {
SimApiError.errorWhenFalse(response.isSuccessStatusCode, code: response.statusCode,
message: "SimApiStorage 上传失败: HTTP ${response.statusCode}")
} finally {
response.close()
}
} finally {
client.close()
}
}
/// HEAD 检测桶是否存在(对齐 Minio BucketExistsAsync
private func bucketExists(): Bool {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(Array<Byte>(0, repeat: 0)))
let canonicalUri = "/${_bucket}"
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\n"
let signedHeaders = "host;x-amz-content-sha256"
let canonicalRequest = "HEAD\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let request = HttpRequestMessage(NetHttpMethod.Head, "${_endpoint}${canonicalUri}")
request.headers.add("x-amz-content-sha256", payloadHash)
request.headers.add("Authorization", authorization)
let response = client.send(request)
try {
response.statusCode == 200
} finally {
response.close()
}
} finally {
client.close()
}
}
/// 创建桶(对齐 Minio MakeBucketAsync
private func makeBucket(): Unit {
let now = DateTime.nowUTC()
let amzDate = formatAmzDate(now)
let dateStamp = amzDate[0..8]
let payloadHash = toHexString(sha256Bytes(Array<Byte>(0, repeat: 0)))
let canonicalUri = "/${_bucket}"
let canonicalHeaders = "host:${_host}\nx-amz-content-sha256:${payloadHash}\n"
let signedHeaders = "host;x-amz-content-sha256"
let canonicalRequest = "PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}"
let stringToSign = "AWS4-HMAC-SHA256\n${amzDate}\n${dateStamp}/${_region}/s3/aws4_request\n" +
"${toHexString(sha256Bytes(canonicalRequest.toArray()))}"
let authorization = buildAuthorization(dateStamp, signedHeaders,
toHexString(hmacSha256(buildSigningKey(dateStamp), stringToSign.toArray())))
let client = createClient()
try {
let request = HttpRequestMessage(NetHttpMethod.Put, "${_endpoint}${canonicalUri}")
request.headers.add("x-amz-content-sha256", payloadHash)
request.headers.add("Authorization", authorization)
request.content = ByteArrayContent(Array<Byte>(0, repeat: 0))
let response = client.send(request)
try {
SimApiError.errorWhenFalse(response.isSuccessStatusCode, code: response.statusCode,
message: "SimApiStorage 创建桶失败: HTTP ${response.statusCode}")
} finally {
response.close()
}
} finally {
client.close()
}
}
private func ensureBucketOnce(): Unit {
if (!SimApiStorage._bucketEnsured) {
if (!bucketExists()) {
makeBucket()
}
SimApiStorage._bucketEnsured = true
}
}
private func buildAuthorization(dateStamp: String, signedHeaders: String, signature: String): String {
"AWS4-HMAC-SHA256 Credential=${_accessKey}/${dateStamp}/${_region}/s3/aws4_request, " +
"SignedHeaders=${signedHeaders}, Signature=${signature}"
}
private func buildSigningKey(dateStamp: String): Array<Byte> {
let kDate = hmacSha256("AWS4${_secretKey}".toArray(), dateStamp.toArray())
let kRegion = hmacSha256(kDate, _region.toArray())
let kService = hmacSha256(kRegion, "s3".toArray())
hmacSha256(kService, "aws4_request".toArray())
}
private func createClient(): HttpClient {
HttpClient.create { builder =>
builder.noProxy()
if (_useSsl) {
var tls = TlsClientConfig()
tls.verifyMode = CertificateVerifyMode.TrustAll
let host = extractHost(_endpoint)
if (!host.isEmpty()) {
tls.serverName = Some(host)
}
builder.tlsConfig(tls)
}
}
}
private func checkPath(path: String): Unit {
if (!path.startsWith("/")) {
throw Exception("path must start with /")
}
}
private func trimLeadingSlash(path: String): String {
if (path.startsWith("/")) {
path[1..]
} else {
path
}
}
/// 当前请求基础地址(scheme://host);无请求上下文时返回空串
private func requestBaseUrl(): String {
if (let Some(ctx) <- _httpContextAccessor.context) {
return "${ctx.request.scheme}://${ctx.request.host}"
}
""
}
private static func extractHost(fullUrl: String): String {
match (fullUrl.indexOf("://")) {
case Some(i) =>
let rest = fullUrl[i + 3..]
let slash = rest.indexOf("/") ?? rest.size
let q = rest.indexOf("?") ?? rest.size
let end = if (slash < q) { slash } else { q }
rest[0..end]
case None => ""
}
}
private static func formatAmzDate(dt: DateTime): String {
"${dt.year}${pad2(dt.monthValue)}${pad2(dt.dayOfMonth)}T${pad2(dt.hour)}${pad2(dt.minute)}${pad2(dt.second)}Z"
}
private static func pad2(v: Int64): String {
if (v < 10) {
"0${v}"
} else {
"${v}"
}
}
/// RFC 3986 百分号编码(encodeSlash=false 时保留 '/'
private static func uriEncode(s: String, encodeSlash: Bool): String {
let bytes = s.toArray()
let hex = "0123456789ABCDEF"
var sb = StringBuilder()
for (b in bytes) {
let u = toU8(b)
let unreserved = (u >= 0x41u8 && u <= 0x5Au8) || (u >= 0x61u8 && u <= 0x7Au8) ||
(u >= 0x30u8 && u <= 0x39u8) || u == 0x2Du8 || u == 0x5Fu8 || u == 0x2Eu8 || u == 0x7Eu8
if (unreserved || (u == 0x2Fu8 && !encodeSlash)) {
sb.append(Rune(UInt32(u)))
} else {
sb.append("%")
sb.append(Rune(UInt32(hex[Int64((u >> 4u8) & 0x0Fu8)])))
sb.append(Rune(UInt32(hex[Int64(u & 0x0Fu8)])))
}
}
sb.toString()
}
private static func toU8(b: Byte): UInt8 {
if (b < 0) {
UInt8(Int64(b) + 256)
} else {
UInt8(Int64(b))
}
}
/// HMAC-SHA256(自实现,基于 stdx SHA256stdx 静态库无现成 HMAC 封装)
private static func hmacSha256(key: Array<Byte>, data: Array<Byte>): Array<Byte> {
var k = key
if (k.size > 64) {
k = sha256Bytes(k)
}
var keyPadded = Array<Byte>(64, repeat: 0)
for (i in 0..k.size) {
keyPadded[i] = k[i]
}
var inner = ArrayList<Byte>()
for (i in 0..64) {
inner.add(bxor(keyPadded[i], 0x36u8))
}
for (b in data) {
inner.add(b)
}
let innerHash = sha256Bytes(inner.toArray())
var outer = ArrayList<Byte>()
for (i in 0..64) {
outer.add(bxor(keyPadded[i], 0x5Cu8))
}
for (b in innerHash) {
outer.add(b)
}
sha256Bytes(outer.toArray())
}
@OverflowWrapping
private static func bxor(a: Byte, mask: UInt8): Byte {
let ua = toU8(a)
let r: Byte = ua ^ mask
r
}
private static func sha256Bytes(data: Array<Byte>): Array<Byte> {
let sha = SHA256()
sha.write(data)
sha.finish()
}
}