From eb488bcd5a369f71fbf2d4f9f8e0043968432d1e Mon Sep 17 00:00:00 2001 From: xRain Date: Mon, 17 Aug 2026 00:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20SimApiStorage?= =?UTF-8?q?=EF=BC=88S3/MinIO=20=E5=AD=98=E5=82=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 自实现 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 存储章节) --- README.md | 35 ++- src/extensions/SimApiExtensions.cj | 11 +- src/helpers/SimApiStorage.cj | 448 +++++++++++++++++++++++++++++ 3 files changed, 490 insertions(+), 4 deletions(-) create mode 100644 src/helpers/SimApiStorage.cj diff --git a/README.md b/README.md index 4b09b2e..3b0e3df 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,8 @@ simapi-cj/ │ ├── exceptions/ # SimApiException │ ├── extensions/ # SimApiExtensions(addSimApi / 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 存储 — SimApiStorage(S3/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 SigV4(HMAC-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`(注解鉴权时执行)、内置路由自定义路径。 --- diff --git a/src/extensions/SimApiExtensions.cj b/src/extensions/SimApiExtensions.cj index 711e2ff..b48a160 100644 --- a/src/extensions/SimApiExtensions.cj +++ b/src/extensions/SimApiExtensions.cj @@ -136,6 +136,14 @@ private func addSimApiCore(builder: WebHostBuilder, options: SimApiOptions): Web builder.services.addSingleton() } + // 存储(S3/MinIO,对齐 C# AddHttpContextAccessor + AddSingleton; + // 仓颉版注册为 Scoped 以便注入 IHttpContextAccessor(DI 禁止 singleton 消费 scoped), + // 桶初始化由静态守卫保证只执行一次) + if (options.enableSimApiStorage) { + builder.services.addHttpContextAccessor() + builder.services.addScoped() + } + // AuthGate 认证中心 SDK(对齐 C# 注册 SimApiAuthClient/Center/Iam 单例) if (options.enableSimApiAuthGate) { builder.services.addSingleton() @@ -194,7 +202,8 @@ extend WebHost <: SimApiHostExtensions { logger.info("开始配置 SimApiCache...") } - // SimApiStorage(占位) + // SimApiStorage(已实现:addSimApi 中注册 Scoped,桶初始化惰性执行; + // 对齐 C# 的 GetService 预热,但 scoped 服务不能从根解析,故仅输出配置日志) if (options.enableSimApiStorage) { logger.info("开始配置 SimApiStorage...") } diff --git a/src/helpers/SimApiStorage.cj b/src/helpers/SimApiStorage.cj new file mode 100644 index 0000000..ca1293a --- /dev/null +++ b/src/helpers/SimApiStorage.cj @@ -0,0 +1,448 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + * Helpers/SimApiStorage:S3/MinIO 兼容存储助手。 + * + * 仓颉生态暂无 Minio SDK,此处自实现 AWS Signature V4(HMAC-SHA256 基于 stdx SHA256): + * - 预签名 URL(PUT 上传 / GET 下载) + * - 直接 PUT 上传(对象 + 建桶) + * - HEAD 检测桶是否存在 + * 与 C# SimApiStorage(Minio 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 以注入 IHttpContextAccessor(soulsoft 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# UploadFile;data 为文件字节)。 + */ + public func uploadFile(path: String, data: Array, 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, 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(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(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(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 { + 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 SHA256;stdx 静态库无现成 HMAC 封装) + private static func hmacSha256(key: Array, data: Array): Array { + var k = key + if (k.size > 64) { + k = sha256Bytes(k) + } + var keyPadded = Array(64, repeat: 0) + for (i in 0..k.size) { + keyPadded[i] = k[i] + } + var inner = ArrayList() + 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() + 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): Array { + let sha = SHA256() + sha.write(data) + sha.finish() + } +}