chore: initialize go-sip repository
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
package oss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
|
||||
aliyunoss "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
)
|
||||
|
||||
const defaultGrantTTL = 15 * time.Minute
|
||||
|
||||
// Config is Dispatcher-owned OSS configuration. Access keys never leave the
|
||||
// Dispatcher; Agents receive only presigned upload grants.
|
||||
type Config struct {
|
||||
Endpoint string
|
||||
Region string
|
||||
Bucket string
|
||||
AccessKeyID string
|
||||
AccessKeySecret string
|
||||
KeyPrefix string
|
||||
GrantTTL time.Duration
|
||||
MaxAssetBytes int64
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if strings.TrimSpace(c.Endpoint) == "" || strings.TrimSpace(c.Region) == "" || strings.TrimSpace(c.Bucket) == "" {
|
||||
return errors.New("OSS endpoint, region and bucket are required")
|
||||
}
|
||||
if strings.TrimSpace(c.AccessKeyID) == "" || strings.TrimSpace(c.AccessKeySecret) == "" {
|
||||
return errors.New("OSS access key ID and secret are required")
|
||||
}
|
||||
endpoint := c.Endpoint
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Host == "" || parsed.Scheme != "https" {
|
||||
return errors.New("OSS endpoint must be a valid HTTPS URL")
|
||||
}
|
||||
if c.GrantTTL <= 0 || c.GrantTTL > 7*24*time.Hour {
|
||||
return errors.New("OSS grant TTL must be between 1 second and 7 days")
|
||||
}
|
||||
if c.MaxAssetBytes <= 0 {
|
||||
return errors.New("OSS max asset bytes must be positive")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client signs and verifies object operations using Alibaba's official OSS
|
||||
// SDK. It is intentionally only constructed in the Dispatcher process.
|
||||
type Client struct {
|
||||
config Config
|
||||
client *aliyunoss.Client
|
||||
}
|
||||
|
||||
func NewClient(config Config) (*Client, error) {
|
||||
if config.GrantTTL == 0 {
|
||||
config.GrantTTL = defaultGrantTTL
|
||||
}
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := config.Endpoint
|
||||
if !strings.Contains(endpoint, "://") {
|
||||
endpoint = "https://" + endpoint
|
||||
}
|
||||
sdkConfig := aliyunoss.LoadDefaultConfig().
|
||||
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(config.AccessKeyID, config.AccessKeySecret)).
|
||||
WithRegion(config.Region).
|
||||
WithEndpoint(endpoint).
|
||||
WithSignatureVersion(aliyunoss.SignatureVersionV4)
|
||||
return &Client{config: config, client: aliyunoss.NewClient(sdkConfig)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Config() Config { return c.config }
|
||||
|
||||
func (c *Client) Grant(ctx context.Context, uploadID, objectKey, checksum string, maxBytes int64, now time.Time) (*agentv1.UploadGrant, error) {
|
||||
if c == nil || c.client == nil {
|
||||
return nil, errors.New("OSS client is not configured")
|
||||
}
|
||||
if uploadID == "" || objectKey == "" || checksum == "" {
|
||||
return nil, errors.New("upload ID, object key and checksum are required")
|
||||
}
|
||||
if maxBytes <= 0 || maxBytes > c.config.MaxAssetBytes {
|
||||
maxBytes = c.config.MaxAssetBytes
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
expiresAt := now.Add(c.config.GrantTTL)
|
||||
request := &aliyunoss.PutObjectRequest{
|
||||
Bucket: aliyunoss.Ptr(c.config.Bucket),
|
||||
Key: aliyunoss.Ptr(objectKey),
|
||||
Metadata: map[string]string{
|
||||
"sha256": checksum,
|
||||
},
|
||||
}
|
||||
presigned, err := c.client.Presign(ctx, request, aliyunoss.PresignExpiration(expiresAt))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("presign OSS PUT: %w", err)
|
||||
}
|
||||
headers := make([]*agentv1.Header, 0, len(presigned.SignedHeaders))
|
||||
for name, value := range presigned.SignedHeaders {
|
||||
headers = append(headers, &agentv1.Header{Name: name, Value: value})
|
||||
}
|
||||
return &agentv1.UploadGrant{
|
||||
UploadId: uploadID,
|
||||
TargetUrl: presigned.URL,
|
||||
Headers: headers,
|
||||
ExpiresAtUnixMs: presigned.Expiration.UnixMilli(),
|
||||
ObjectKey: objectKey,
|
||||
RequiredChecksumSha256: checksum,
|
||||
MaxBytes: maxBytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Verify(ctx context.Context, objectKey, checksum string, size int64) error {
|
||||
if c == nil || c.client == nil {
|
||||
return errors.New("OSS client is not configured")
|
||||
}
|
||||
result, err := c.client.HeadObject(ctx, &aliyunoss.HeadObjectRequest{
|
||||
Bucket: aliyunoss.Ptr(c.config.Bucket),
|
||||
Key: aliyunoss.Ptr(objectKey),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("head OSS object: %w", err)
|
||||
}
|
||||
if result.ContentLength != size {
|
||||
return fmt.Errorf("OSS object size mismatch: expected %d got %d", size, result.ContentLength)
|
||||
}
|
||||
storedChecksum := ""
|
||||
for key, value := range result.Metadata {
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
key = strings.TrimPrefix(key, "x-oss-meta-")
|
||||
if key == "sha256" {
|
||||
storedChecksum = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(checksum), strings.TrimSpace(storedChecksum)) {
|
||||
return errors.New("OSS object checksum metadata mismatch")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ObjectID(objectKey string) string {
|
||||
return "oss://" + c.config.Bucket + "/" + strings.TrimPrefix(objectKey, "/")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package oss_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ipao.vip/rogee/go-sip/internal/agent"
|
||||
ossclient "git.ipao.vip/rogee/go-sip/internal/oss"
|
||||
)
|
||||
|
||||
func TestAlibabaOSSGrantPutHeadIntegration(t *testing.T) {
|
||||
if os.Getenv("AGENT_CALL_OSS_INTEGRATION") != "1" {
|
||||
t.Skip("set AGENT_CALL_OSS_INTEGRATION=1 to use the authorized Alibaba OSS test bucket")
|
||||
}
|
||||
config := ossclient.Config{
|
||||
Endpoint: os.Getenv("DISPATCHER_OSS_ENDPOINT"),
|
||||
Region: os.Getenv("DISPATCHER_OSS_REGION"),
|
||||
Bucket: os.Getenv("DISPATCHER_OSS_BUCKET"),
|
||||
AccessKeyID: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_ID"),
|
||||
AccessKeySecret: os.Getenv("DISPATCHER_OSS_ACCESS_KEY_SECRET"),
|
||||
KeyPrefix: "agent-call/integration-tests",
|
||||
GrantTTL: 15 * time.Minute,
|
||||
MaxAssetBytes: 1 << 20,
|
||||
}
|
||||
client, err := ossclient.NewClient(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := []byte(strings.Repeat("agent-call-oss-integration\n", 1024))
|
||||
digest := sha256.Sum256(payload)
|
||||
checksum := hex.EncodeToString(digest[:])
|
||||
objectKey := "agent-call/integration-tests/" + checksum + ".txt"
|
||||
grant, err := client.Grant(context.Background(), "integration-"+checksum[:16], objectKey, checksum, int64(len(payload)), time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "oss-upload-*.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.Write(payload); err != nil {
|
||||
_ = file.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uploader := agent.UploadClient{Now: time.Now, AllowedHosts: map[string]struct{}{}}
|
||||
result, err := uploader.UploadFile(context.Background(), grant, file.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("upload failed without exposing the presigned URL: %s", redactError(err, config.AccessKeyID))
|
||||
}
|
||||
if result.SizeBytes != int64(len(payload)) || !strings.EqualFold(result.SHA256, checksum) {
|
||||
t.Fatalf("upload result mismatch: %+v", result)
|
||||
}
|
||||
if err := client.Verify(context.Background(), objectKey, checksum, int64(len(payload))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func redactError(err error, accessKeyID string) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
message := err.Error()
|
||||
if accessKeyID != "" {
|
||||
message = strings.ReplaceAll(message, accessKeyID, "<access-key-id>")
|
||||
}
|
||||
return message
|
||||
}
|
||||
Reference in New Issue
Block a user