349 lines
10 KiB
Go
349 lines
10 KiB
Go
package qiniu
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha1"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"u-desk/internal/oss"
|
|
)
|
|
|
|
// BucketAccessControl 空间访问控制类型
|
|
type BucketAccessControl int
|
|
|
|
const (
|
|
// BucketAccessControlPublic 公开空间 (0)
|
|
BucketAccessControlPublic BucketAccessControl = 0
|
|
// BucketAccessControlPrivate 私有空间 (1)
|
|
BucketAccessControlPrivate BucketAccessControl = 1
|
|
)
|
|
|
|
// String 返回访问控制的字符串表示
|
|
func (a BucketAccessControl) String() string {
|
|
switch a {
|
|
case BucketAccessControlPublic:
|
|
return "公开"
|
|
case BucketAccessControlPrivate:
|
|
return "私有"
|
|
default:
|
|
return "未知"
|
|
}
|
|
}
|
|
|
|
// GetBucketDomains 获取空间绑定的域名列表
|
|
// 根据: https://developer.qiniu.com/kodo/api/3949/get-the-bucket-space-domain
|
|
//
|
|
// 返回:
|
|
// - []string: 域名列表
|
|
// - error: 错误信息
|
|
//
|
|
// 注意:
|
|
// - 返回的域名包括七牛云提供的默认域名和用户绑定的自定义域名
|
|
// - 默认域名格式: <bucket>.<region>.qiniudns.com 或 <bucket>.<region>.clouddn.com
|
|
func (c *Client) GetBucketDomains(ctx context.Context) ([]string, error) {
|
|
// 构建查询参数
|
|
params := url.Values{}
|
|
params.Set("tbl", c.config.Bucket)
|
|
|
|
// 构建 URL
|
|
// 格式: GET /v6/domain/list?tbl=<bucketName>
|
|
apiURL := fmt.Sprintf("%s/v6/domain/list?%s", c.apiAPI, params.Encode())
|
|
|
|
// 创建请求
|
|
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
|
|
if err != nil {
|
|
return nil, oss.NewError("BUCKET_ERROR", "failed to create request", err)
|
|
}
|
|
|
|
// 使用 API 服务的 host 生成认证
|
|
// API 接口使用简单的查询字符串认证
|
|
path := "/v6/domain/list"
|
|
queryString := params.Encode()
|
|
host := "api.qiniu.com"
|
|
authToken := c.generateAuthTokenWithQuery("GET", path, queryString, host, "application/x-www-form-urlencoded", nil)
|
|
|
|
req.Header.Set("Host", host)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Authorization", authToken)
|
|
|
|
// 发送请求
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, oss.NewError("BUCKET_ERROR", "failed to get bucket domains", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, oss.NewError("BUCKET_ERROR", "failed to read response", err)
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return nil, oss.NewError("BUCKET_ERROR",
|
|
fmt.Sprintf("get bucket domains failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
|
}
|
|
|
|
// 解析响应(JSON 数组)
|
|
var domains []string
|
|
if err := json.Unmarshal(body, &domains); err != nil {
|
|
return nil, oss.NewError("BUCKET_ERROR", "failed to parse response", err)
|
|
}
|
|
|
|
return domains, nil
|
|
}
|
|
|
|
// GetBucketRegion 查询桶的真实区域
|
|
// API: POST https://uc.qbox.me/v2/buckets → 遍历匹配桶名获取 region
|
|
func (c *Client) GetBucketRegion(ctx context.Context) (string, error) {
|
|
// 使用 UC API 获取所有桶列表(含 region)
|
|
req, err := http.NewRequestWithContext(ctx, "POST", "https://uc.qbox.me/v2/buckets", nil)
|
|
if err != nil {
|
|
return "", oss.NewError("BUCKET_ERROR", "failed to create request", err)
|
|
}
|
|
|
|
path := "/v2/buckets"
|
|
host := "uc.qbox.me"
|
|
authToken := c.generateAuthTokenWithQuery("POST", path, "", host, "application/x-www-form-urlencoded", nil)
|
|
req.Header.Set("Host", host)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Authorization", authToken)
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return "", oss.NewError("BUCKET_ERROR", "failed to query bucket region", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", oss.NewError("BUCKET_ERROR", "failed to read response", err)
|
|
}
|
|
|
|
if resp.StatusCode != 200 {
|
|
return "", oss.NewError("BUCKET_ERROR",
|
|
fmt.Sprintf("query bucket region failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
|
}
|
|
|
|
var buckets []struct {
|
|
ID string `json:"id"`
|
|
Region string `json:"region"`
|
|
}
|
|
if err := json.Unmarshal(body, &buckets); err != nil {
|
|
return "", oss.NewError("BUCKET_ERROR", "failed to parse response", err)
|
|
}
|
|
|
|
for _, b := range buckets {
|
|
if b.ID == c.config.Bucket {
|
|
return b.Region, nil
|
|
}
|
|
}
|
|
|
|
return "", oss.NewError("BUCKET_ERROR", fmt.Sprintf("bucket %s not found in account", c.config.Bucket), nil)
|
|
}
|
|
|
|
// SetBucketAccess 设置空间访问权限(公开/私有)
|
|
// 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private
|
|
//
|
|
// 参数:
|
|
// - access: BucketAccessControlPublic(公开) 或 BucketAccessControlPrivate(私有)
|
|
//
|
|
// 注意:
|
|
// - 公开空间:文件可通过 URL 直接访问
|
|
// - 私有空间:文件访问需要下载凭证
|
|
// - 修改权限会影响该空间下所有文件的访问方式
|
|
func (c *Client) SetBucketAccess(ctx context.Context, access BucketAccessControl) error {
|
|
// 构建查询参数
|
|
params := url.Values{}
|
|
params.Set("bucket", c.config.Bucket)
|
|
params.Set("private", fmt.Sprintf("%d", access))
|
|
|
|
// 构建 URL
|
|
// 格式: POST /private?bucket=<bucketName>&private=<0|1>
|
|
apiURL := fmt.Sprintf("%s/private?%s", c.apiAPI, params.Encode())
|
|
|
|
// 创建请求
|
|
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil)
|
|
if err != nil {
|
|
return oss.NewError("BUCKET_ERROR", "failed to create request", err)
|
|
}
|
|
|
|
// 使用 API 服务的 host 生成认证
|
|
path := "/private"
|
|
queryString := params.Encode()
|
|
host := "api.qiniu.com"
|
|
authToken := c.generateAuthTokenWithQuery("POST", path, queryString, host, "application/x-www-form-urlencoded", nil)
|
|
|
|
req.Header.Set("Host", host)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Authorization", authToken)
|
|
|
|
// 发送请求
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return oss.NewError("BUCKET_ERROR", "failed to set bucket access", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == 200 {
|
|
return nil
|
|
}
|
|
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return oss.NewError("BUCKET_ERROR",
|
|
fmt.Sprintf("set bucket access failed with status %d: %s", resp.StatusCode, string(body)), nil)
|
|
}
|
|
|
|
// SetBucketPublic 设置空间为公开空间
|
|
// 便捷方法:将空间设置为公开访问
|
|
func (c *Client) SetBucketPublic(ctx context.Context) error {
|
|
return c.SetBucketAccess(ctx, BucketAccessControlPublic)
|
|
}
|
|
|
|
// SetBucketPrivate 设置空间为私有空间
|
|
// 便捷方法:将空间设置为私有访问
|
|
func (c *Client) SetBucketPrivate(ctx context.Context) error {
|
|
return c.SetBucketAccess(ctx, BucketAccessControlPrivate)
|
|
}
|
|
|
|
// BucketInfo 空间信息
|
|
type BucketInfo struct {
|
|
Name string // 空间名称
|
|
Region string // 区域
|
|
Domains []string // 绑定的域名列表
|
|
IsPrivate bool // 是否为私有空间
|
|
}
|
|
|
|
// GetBucketInfo 获取空间信息
|
|
// 组合方法:获取空间的域名列表和访问权限等信息
|
|
//
|
|
// 注意:
|
|
// - 该方法会调用多个 API 接口获取完整信息
|
|
// - IsPrivate 字段无法通过 API 直接获取,需要通过测试文件访问来确定
|
|
func (c *Client) GetBucketInfo(ctx context.Context) (*BucketInfo, error) {
|
|
// 获取域名列表
|
|
domains, err := c.GetBucketDomains(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 构建基本信息
|
|
info := &BucketInfo{
|
|
Name: c.config.Bucket,
|
|
Region: c.config.Region,
|
|
Domains: domains,
|
|
// IsPrivate 需要通过其他方式确定
|
|
}
|
|
|
|
return info, nil
|
|
}
|
|
|
|
// CheckBucketAccess 检查空间访问权限
|
|
// 通过尝试访问一个不存在的文件来判断空间是否为私有
|
|
//
|
|
// 返回:
|
|
// - bool: true=私有空间, false=公开空间
|
|
// - error: 错误信息
|
|
//
|
|
// 注意:
|
|
// - 该方法会发送一个测试请求来判断空间权限
|
|
// - 如果空间内没有文件,可能无法准确判断
|
|
func (c *Client) CheckBucketAccess(ctx context.Context) (bool, error) {
|
|
// 尝试获取一个不存在的文件的信息
|
|
// 如果是公开空间,会返回明确的"文件不存在"错误
|
|
// 如果是私有空间,会返回认证错误
|
|
testKey := fmt.Sprintf("__qiniu_test_access_check_%d__", time.Now().UnixNano())
|
|
|
|
_, err := c.GetFileInfo(ctx, testKey)
|
|
if err == oss.ErrFileNotFound {
|
|
// 返回文件不存在,说明是公开空间
|
|
return false, nil
|
|
}
|
|
|
|
// 其他错误情况,可能需要根据错误信息判断
|
|
if err != nil {
|
|
// 检查错误信息中是否包含认证相关的内容
|
|
errStr := err.Error()
|
|
if contains(errStr, "permission") || contains(errStr, "unauthorized") || contains(errStr, "token") {
|
|
return true, nil // 私有空间
|
|
}
|
|
}
|
|
|
|
// 默认假设为公开空间
|
|
return false, nil
|
|
}
|
|
|
|
// contains 辅助函数:检查字符串是否包含子串(忽略大小写)
|
|
func contains(str, substr string) bool {
|
|
return len(str) >= len(substr) && (str == substr || len(str) > len(substr) && containsIgnoreCase(str, substr))
|
|
}
|
|
|
|
func containsIgnoreCase(str, substr string) bool {
|
|
// 简化实现,实际使用时可以使用 strings.ToLower
|
|
for i := 0; i <= len(str)-len(substr); i++ {
|
|
match := true
|
|
for j := 0; j < len(substr); j++ {
|
|
c1 := str[i+j]
|
|
c2 := substr[j]
|
|
if c1 >= 'A' && c1 <= 'Z' {
|
|
c1 += 32
|
|
}
|
|
if c2 >= 'A' && c2 <= 'Z' {
|
|
c2 += 32
|
|
}
|
|
if c1 != c2 {
|
|
match = false
|
|
break
|
|
}
|
|
}
|
|
if match {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ListBuckets 列出所有存储桶
|
|
func ListBuckets(accessKey, secretKey string) ([]oss.BucketEntry, error) {
|
|
signingStr := "/buckets\n"
|
|
token := accessKey + ":" + signHmacSha1(secretKey, signingStr)
|
|
|
|
req, _ := http.NewRequest("POST", "https://rs.qbox.me/buckets", nil)
|
|
req.Header.Set("Authorization", "QBox "+token)
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("列举存储桶失败: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("列举存储桶失败: HTTP %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var names []string
|
|
if err := json.NewDecoder(resp.Body).Decode(&names); err != nil {
|
|
return nil, fmt.Errorf("解析存储桶列表失败: %w", err)
|
|
}
|
|
|
|
entries := make([]oss.BucketEntry, len(names))
|
|
for i, name := range names {
|
|
entries[i] = oss.BucketEntry{Name: name}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func signHmacSha1(secretKey, data string) string {
|
|
mac := hmac.New(sha1.New, []byte(secretKey))
|
|
mac.Write([]byte(data))
|
|
return base64.URLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|