Private
Public Access
1
0

新增: SFTP直连+网站预览+OSS区域嗅探+热键+BGM播放

This commit is contained in:
2026-05-12 11:06:28 +08:00
parent 545d7a864d
commit 2a363fd729
62 changed files with 6687 additions and 660 deletions

View File

@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
@@ -18,12 +19,12 @@ import (
// Config 七牛云配置
type Config struct {
AccessKey string // 访问密钥
SecretKey string // 秘钥
Bucket string // 存储空间名称
Region string // 区域 z0=华东, as0=亚太0区
UseHTTPS bool // 是否使用 HTTPS
UploadDomain string // 上传域名(可选,默认根据 Region 自动选择
AccessKey string // 访问密钥
SecretKey string // 秘钥
Bucket string // 存储空间名称
Region string // 区域 z0=华东, z2=华南, as0=亚太0区
UseHTTPS bool // 是否使用 HTTPS
DownloadDomain string // 缓存的下载域名(由 resolveDownloadDomain 自动设置
}
// Client 七牛云客户端
@@ -61,84 +62,31 @@ func NewClient(config *Config) (*Client, error) {
}, nil
}
// generateSignature 生成七牛云管理凭证签名
// 根据官方文档https://developer.qiniu.com/kodo/1201/access-token
func (c *Client) generateSignature(method, path, host, contentType string, body []byte) string {
// 七牛云管理凭证签名格式:
// signingStr = Method + " " + Path + "\nHost: " + Host + "\n" + [Content-Type] + "\n\n" + [body]
var signingStr string
// 1. Method + " " + Path
signingStr = method + " " + path
// 2. Host header
signingStr += "\nHost: " + host
// 3. Content-Type header (如果设置了)
if contentType != "" {
signingStr += "\nContent-Type: " + contentType
}
// 4. 两个连续换行符
signingStr += "\n\n"
// 5. Body (如果设置了 Content-Type 且不是 application/octet-stream)
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
signingStr += string(body)
}
// 使用 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(signingStr))
// Base64 URL 安全编码
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
return signature
}
// generateAuthToken 生成管理认证 Token
func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string {
signature := c.generateSignature(method, path, host, contentType, body)
return "Qiniu " + c.config.AccessKey + ":" + signature
return c.generateAuthTokenWithQuery(method, path, "", host, contentType, body)
}
// generateAuthTokenWithQuery 生成管理认证 Token支持 query string
// https://developer.qiniu.com/kodo/1201/access-token
func (c *Client) generateAuthTokenWithQuery(method, path, query, host, contentType string, body []byte) string {
// 七牛云管理凭证签名格式:
// 如果 query 为非空字符串: signingStr = Method + " " + Path + "?" + query + "\nHost: " + Host + ...
// 如果 query 为空: signingStr = Method + " " + Path + "\nHost: " + Host + ...
var signingStr string
// 1. Method + " " + Path
signingStr = method + " " + path
// 2. Query string (如果有)
if query != "" {
signingStr += "?" + query
}
// 3. Host header
signingStr += "\nHost: " + host
// 4. Content-Type header (如果设置了)
if contentType != "" {
signingStr += "\nContent-Type: " + contentType
}
// 5. 两个连续换行符
signingStr += "\n\n"
// 6. Body (如果设置了 Content-Type 且不是 application/octet-stream)
if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 {
signingStr += string(body)
}
// 使用 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(signingStr))
// Base64 URL 安全编码
signature := base64.URLEncoding.EncodeToString(h.Sum(nil))
return "Qiniu " + c.config.AccessKey + ":" + signature
@@ -152,12 +100,11 @@ func (c *Client) encodeEntry(key string) string {
// getUploadDomain 获取上传域名
func (c *Client) getUploadDomain() string {
// 如果配置了自定义上传域名,使用自定义的
if c.config.UploadDomain != "" {
if c.config.DownloadDomain != "" {
if c.config.UseHTTPS {
return "https://" + c.config.UploadDomain
return "https://" + c.config.DownloadDomain
}
return "http://" + c.config.UploadDomain
return "http://" + c.config.DownloadDomain
}
// 根据区域选择默认上传域名
@@ -264,85 +211,169 @@ func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, optio
return uploadClient.Upload(ctx, key, reader)
}
// generateUploadToken 生成上传凭证
func (c *Client) generateUploadToken(key string) string {
// 七牛云上传凭证的生成
// 1. 创建 putPolicy
putPolicy := fmt.Sprintf(`{"scope":"%s:%s","deadline":%d}`,
c.config.Bucket, key, time.Now().Add(1*time.Hour).Unix())
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
// generateToken 生成上传凭证
func (c *Client) generateToken(scope string) string {
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`, scope, time.Now().Add(1*time.Hour).Unix())
encoded := base64.URLEncoding.EncodeToString([]byte(putPolicy))
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 4. 组合 token
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
h.Write([]byte(encoded))
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
return c.config.AccessKey + ":" + sign + ":" + encoded
}
func (c *Client) generateUploadToken(key string) string {
return c.generateToken(c.config.Bucket + ":" + key)
}
// generateBucketToken 生成 bucket 级别的上传凭证(用于分片上传 v2
func (c *Client) generateBucketToken() string {
// 分片上传 v2 需要 bucket 级别的 token
// 1. 创建 putPolicy
putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`,
c.config.Bucket, time.Now().Add(1*time.Hour).Unix())
return c.generateToken(c.config.Bucket)
}
// 2. 对 putPolicy 进行 base64 URL 编码
encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy))
// 七牛云临时域名后缀(平台分配的 CDN 域名,稳定性高)
var qiniuTempSuffixes = []string{
".qiniudns.com", ".clouddn.com", ".qbox.me",
".qnssl.com", ".qnybgz.cn", ".qiniudns.com.cn",
}
// 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
h.Write([]byte(encodedPutPolicy))
encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// extractHost 从 URL 提取主机名(去掉 scheme、path、port
func extractHost(domainURL string) string {
host := strings.TrimPrefix(domainURL, "http://")
host = strings.TrimPrefix(host, "https://")
if idx := strings.Index(host, "/"); idx >= 0 {
host = host[:idx]
}
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}
// 4. 组合 token
return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy
// isTempDomain 判断是否为七牛平台分配的临时域名(后缀匹配)
func (c *Client) isTempDomain(domain string) bool {
host := strings.ToLower(extractHost(domain))
for _, s := range qiniuTempSuffixes {
if strings.HasSuffix(host, s) {
return true
}
}
return false
}
// classifyDomains 将域名列表分为临时域名和自定义域名
func (c *Client) classifyDomains(domains []string) (tempDomains, customDomains []string) {
for _, d := range domains {
if !strings.HasPrefix(d, "http://") && !strings.HasPrefix(d, "https://") {
d = "http://" + d
}
if c.isTempDomain(d) {
tempDomains = append(tempDomains, d)
} else {
customDomains = append(customDomains, d)
}
}
return
}
// resolveDownloadDomain 解析并缓存下载域名
// 策略API 域名列表(临时优先→自定义)→ 兜底默认 CDN
// 不做 HTTP 探测Download 使用签名 URL即使有防盗链也能通过
func (c *Client) resolveDownloadDomain() (string, error) {
if c.config.UploadDomain != "" {
return c.config.UploadDomain, nil
if c.config.DownloadDomain != "" {
return c.config.DownloadDomain, nil
}
domains, err := c.GetBucketDomains(context.Background())
if err != nil || len(domains) == 0 {
return "", fmt.Errorf("无法获取桶 %s 的下载域名: %v", c.config.Bucket, err)
if err == nil && len(domains) > 0 {
tempDomains, customDomains := c.classifyDomains(domains)
// 精准获取桶的真实区域
c.resolveRegion(tempDomains)
// 优先使用临时域名(平台分配,稳定性高)
if len(tempDomains) > 0 {
d := tempDomains[0]
c.config.DownloadDomain = d
return d, nil
}
// 降级到自定义域名
if len(customDomains) > 0 {
d := customDomains[0]
c.config.DownloadDomain = d
return d, nil
}
}
domain := domains[0]
if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") {
domain = "http://" + domain
}
c.config.UploadDomain = domain
return domain, nil
// 无域名 → 兜底默认 CDN可能不存在但给一个机会
fallback := c.defaultCDNDomain()
c.config.DownloadDomain = fallback
return fallback, nil
}
// Download 下载文件
// defaultCDNDomain 构造七牛默认 CDN 域名
func (c *Client) defaultCDNDomain() string {
return fmt.Sprintf("http://%s-%s.qiniudns.com", c.config.Bucket, c.config.Region)
}
// ClearDownloadDomain 清除缓存的下载域名(下载失败时调用,下次重新解析)
func (c *Client) ClearDownloadDomain() {
c.config.DownloadDomain = ""
}
// resolveRegion 精准获取桶的真实区域
// 优先从临时域名提取 → 查询 API → 使用配置值兜底
func (c *Client) resolveRegion(tempDomains []string) {
// 1. 从临时域名提取
bucketLower := strings.ToLower(c.config.Bucket)
for _, d := range tempDomains {
host := extractHost(d)
host = strings.ToLower(host)
if !strings.HasPrefix(host, bucketLower+"-") {
continue
}
rest := host[len(bucketLower)+1:]
if idx := strings.Index(rest, "."); idx > 0 {
c.config.Region = rest[:idx]
return
}
}
// 2. 查询七牛 API
if region, err := c.GetBucketRegion(context.Background()); err == nil && region != "" {
c.config.Region = region
}
}
// Download 下载文件(使用签名 URL绕过防盗链
func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error {
baseURL, err := c.resolveDownloadDomain()
signedURL, err := c.GetSignedURL(ctx, key, 1*time.Hour)
if err != nil {
return oss.NewError("DOWNLOAD_ERROR", err.Error(), err)
}
url := fmt.Sprintf("%s/%s", baseURL, key)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
req, err := http.NewRequestWithContext(ctx, "GET", signedURL, nil)
if err != nil {
return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
c.ClearDownloadDomain()
return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return oss.NewError("DOWNLOAD_ERROR", fmt.Sprintf("download failed with status %d", resp.StatusCode), nil)
c.ClearDownloadDomain()
body, _ := io.ReadAll(resp.Body)
return oss.NewError("DOWNLOAD_ERROR",
fmt.Sprintf("download failed with status %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])), nil)
}
_, err = io.Copy(writer, resp.Body)
if err != nil {
c.ClearDownloadDomain()
}
return err
}
@@ -407,11 +438,27 @@ func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, er
return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d: %s", resp.StatusCode, string(body)), nil)
}
// 解析响应 (简化实现)
// 实际响应格式: {"hash":"xxx","fsize":123,"mimeType":"xxx","putTime":123}
// 这里返回一个简化的 FileInfo
var statResp struct {
Hash string `json:"hash"`
Fsize int64 `json:"fsize"`
MimeType string `json:"mimeType"`
PutTime int64 `json:"putTime"`
}
if err := json.Unmarshal(body, &statResp); err != nil {
return nil, oss.NewError("STAT_ERROR", "failed to parse response", err)
}
var modTime time.Time
if statResp.PutTime > 0 {
modTime = time.Unix(0, statResp.PutTime)
}
return &oss.FileInfo{
Key: key,
Key: key,
Size: statResp.Fsize,
ETag: statResp.Hash,
ContentType: statResp.MimeType,
LastModified: modTime,
}, nil
}
@@ -471,11 +518,16 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
// 转换为统一格式
files := make([]oss.FileInfo, 0, len(listResp.Items))
for _, item := range listResp.Items {
var modTime time.Time
if item.PutTime > 0 {
modTime = time.Unix(0, item.PutTime)
}
files = append(files, oss.FileInfo{
Key: item.Key,
Size: item.Fsize,
ETag: item.Hash,
ContentType: item.MimeType,
Key: item.Key,
Size: item.Fsize,
ETag: item.Hash,
ContentType: item.MimeType,
LastModified: modTime,
})
}
@@ -488,27 +540,22 @@ func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.
}
// GetSignedURL 获取预签名URL
// 签名格式: hmac_sha1(SecretKey, "<downloadURL>?e=<deadline>")
func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) {
// 七牛云私有空间下载需要生成私有下载 URL
deadline := time.Now().Add(expiresIn).Unix()
// 构建 download URL
baseURL, err := c.resolveDownloadDomain()
if err != nil {
return "", err
}
downloadURL := fmt.Sprintf("%s/%s", baseURL, key)
// 生成签名
// 签名字符串 = 完整 URL + ?e=deadline
urlToSign := fmt.Sprintf("%s/%s?e=%d", baseURL, key, deadline)
h := hmac.New(sha1.New, []byte(c.config.SecretKey))
signStr := fmt.Sprintf("%s\n%d", downloadURL, deadline)
h.Write([]byte(signStr))
h.Write([]byte(urlToSign))
sign := base64.URLEncoding.EncodeToString(h.Sum(nil))
// 构建最终 URL
signedURL := fmt.Sprintf("%s?e=%d&token=%s:%s", downloadURL, deadline, c.config.AccessKey, sign)
return signedURL, nil
return fmt.Sprintf("%s&token=%s:%s", urlToSign, c.config.AccessKey, sign), nil
}
// Copy 复制文件