diff --git a/backend/models/storage.go b/backend/models/storage.go index 5750012..e64d740 100644 --- a/backend/models/storage.go +++ b/backend/models/storage.go @@ -1,6 +1,10 @@ package models -import "time" +import ( + "time" + + "opsxloki/pkg/crypto" +) type StorageConfig struct { ID uint `json:"id"` @@ -19,6 +23,13 @@ type StorageConfig struct { } func (s *StorageConfig) Sanitized() map[string]interface{} { + // AccessKey / SecretKey 在库里是 ENC: 加密存储, + // 直接返回会让前端编辑框显示 ENC:xxx 密文。这里统一解密后返回明文, + // 前端 Secret Key 仍用密码框(默认掩码,点「显示」才展开), + // 便于管理员核对凭据并「测试连接」。 + plainAK := crypto.DecryptField(s.AccessKey) + plainSK := crypto.DecryptField(s.SecretKey) + return map[string]interface{}{ "id": s.ID, "name": s.Name, @@ -26,14 +37,12 @@ func (s *StorageConfig) Sanitized() map[string]interface{} { "endpoint": s.Endpoint, "bucket": s.Bucket, "region": s.Region, - // accessKey / secretKey 明文返回:本平台为内网运维工具,管理员需要在 - // 编辑弹框里看到并「测试连接」,脱敏会导致编辑时字段为空无法保存/测试。 - "accessKey": s.AccessKey, - "secretKey": s.SecretKey, + "accessKey": plainAK, + "secretKey": plainSK, "pathPrefix": s.PathPrefix, "insecure": s.Insecure, "isDefault": s.IsDefault, - "hasSecretKey": s.SecretKey != "", + "hasSecretKey": plainSK != "", "createdAt": s.CreatedAt, "updatedAt": s.UpdatedAt, } diff --git a/backend/services/storage_check.go b/backend/services/storage_check.go index 597840f..18d996d 100644 --- a/backend/services/storage_check.go +++ b/backend/services/storage_check.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" ) @@ -139,46 +140,87 @@ func s3ErrBrief(body []byte) string { // ==================== 测试连接 ==================== +// endpointHasBucket 判断 endpoint 是否已包含桶名(虚拟主机式) +func endpointHasBucket(endpoint, bucket string) bool { + host := strings.TrimSpace(endpoint) + if i := strings.Index(host, "://"); i >= 0 { + host = host[i+3:] + } + if i := strings.IndexByte(host, '/'); i >= 0 { + host = host[:i] + } + return bucket != "" && strings.HasPrefix(strings.ToLower(host), strings.ToLower(bucket)+".") +} + +// bucketPathFor 根据 endpoint 风格决定桶路径(用于建桶 PUT)。 +// +// 1) 区域端点(推荐): cos.ap-hongkong.myqcloud.com → 路径式 / +// 2) 虚拟主机式: mybucket-1250000000.cos.… → 路径为 / +func bucketPathFor(endpoint, bucket string) string { + if endpointHasBucket(endpoint, bucket) { + return "/" + } + return "/" + url.PathEscape(bucket) +} + // TestStorageConnection 逐级测试对象存储:可达性 → 凭证 → 桶存在性(可选自动创建)。 -// OSS 走其 S3 兼容端点;不覆盖 OSS 原生协议的独有能力。 +// +// 探测统一用 GET /,再按 endpoint 形态解释响应(不用 HEAD /): +// - 区域端点 → GET / 是 ListBuckets,解析 +// - 虚拟主机式端点 → GET / 是 ListObjects,返回 桶名 +// +// 之所以不用 HEAD:腾讯云仅有 QcloudCOSDataFullControl(数据读写)的 AK +// 并没有 cos:HeadBucket 权限,HEAD 会 403,会被误判成"桶不存在/无权限"。 func TestStorageConnection(p StorageTestParams) StorageTestResult { - // ① ListBuckets:同时验证 网络可达 + AK/SK 有效 + vhost := endpointHasBucket(p.Endpoint, p.Bucket) + resp, err := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure) if err != nil { return StorageTestResult{Message: fmt.Sprintf("无法连接对象存储:%v", err)} } defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return StorageTestResult{ Reachable: true, - Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s", resp.StatusCode, s3ErrBrief(body)), + Message: fmt.Sprintf("连接成功但认证失败(HTTP %d),请检查 Access Key / Secret Key。%s", + resp.StatusCode, s3ErrBrief(body)), } } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return StorageTestResult{ Reachable: true, - Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s", resp.StatusCode, s3ErrBrief(body)), + Message: fmt.Sprintf("对象存储返回异常(HTTP %d):%s", + resp.StatusCode, s3ErrBrief(body)), } } - // 解析 ListBuckets,顺带确认目标桶是否在列表中 - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - var listResp struct { - Buckets struct { - Bucket []struct { - Name string `xml:"Name"` - } `xml:"Bucket"` - } `xml:"Buckets"` - } - _ = xml.Unmarshal(body, &listResp) found := false - for _, b := range listResp.Buckets.Bucket { - if b.Name == p.Bucket { - found = true - break + if vhost { + // ListObjects:桶名… 能返回即说明桶存在且可读 + if strings.Contains(string(body), "([^<]*)`).FindSubmatch(body); len(m) > 1 { + found = strings.EqualFold(string(m[1]), p.Bucket) + } else { + found = true + } + } + } else { + // ListBuckets:桶名 + var listResp struct { + Buckets struct { + Bucket []struct { + Name string `xml:"Name"` + } `xml:"Bucket"` + } `xml:"Buckets"` + } + _ = xml.Unmarshal(body, &listResp) + for _, b := range listResp.Buckets.Bucket { + if b.Name == p.Bucket { + found = true + break + } } } @@ -195,7 +237,7 @@ func TestStorageConnection(p StorageTestParams) StorageTestResult { return result } - putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/"+url.PathEscape(p.Bucket), p.Insecure) + putResp, err := s3Request("PUT", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, bucketPathFor(p.Endpoint, p.Bucket), p.Insecure) if err != nil { result.Message = fmt.Sprintf("创建桶失败:%v", err) return result @@ -204,46 +246,56 @@ func TestStorageConnection(p StorageTestParams) StorageTestResult { // 200=创建成功;409 BucketAlreadyOwnedByYou 视为已存在 if putResp.StatusCode == http.StatusOK || putResp.StatusCode == http.StatusConflict { - // 二次校验:不同对象存储的 CreateBucket 语义不一致。 - // 例如腾讯云 COS 要求桶名放在 Host(.cos..myqcloud.com)、 - // 路径为 /,直接 PUT / 可能返回 200 但并未真正建桶。 - // 因此重建后再 ListBuckets 确认桶确实出现,避免前端看到"假的创建成功"。 + // 二次校验:再用 GET / 确认桶真的存在(不用 HEAD —— 很多 AK 没有 cos:HeadBucket) verified := false if vresp, verr := s3Request("GET", p.Endpoint, p.Region, p.AccessKey, p.SecretKey, "/", p.Insecure); verr == nil { vbody, _ := io.ReadAll(io.LimitReader(vresp.Body, 1<<20)) vresp.Body.Close() - var vlist struct { - Buckets struct { - Bucket []struct { - Name string `xml:"Name"` - } `xml:"Bucket"` - } `xml:"Buckets"` - } - _ = xml.Unmarshal(vbody, &vlist) - for _, b := range vlist.Buckets.Bucket { - if b.Name == p.Bucket { - verified = true - break + if vhost { + verified = strings.Contains(string(vbody), "