Files
chiyiandClaude Opus 4.8 606caaf047 Initial open-source release (MIT): image2api AI gateway
Full Go backend + Vue 3 frontend, OpenAI-compatible API, multi-provider
account pools, billing/admin, Docker one-command deploy with auto HTTPS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 22:59:04 +08:00

295 lines
8.9 KiB
Go

// Package storage is a minimal S3-compatible client for RustFS, implemented with
// AWS Signature V4 over the standard library only (no external SDK — the build
// environment can't reach the Go module proxy). It's intentionally small: Put /
// Get / Delete / List cover everything the app needs (store generated media,
// proxy it back through /images, list for the admin gallery, prune by age).
//
// The surface mirrors what a thin wrapper over aws-sdk-go-v2 would expose, so it
// can be swapped for the official SDK later by reimplementing this one file.
package storage
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
region = "us-east-1" // RustFS ignores the value but SigV4 requires a fixed one
service = "s3"
)
type Client struct {
endpoint string // e.g. http://154.9.26.140:9000 (no trailing slash)
host string // e.g. 154.9.26.140:9000
bucket string
ak, sk string
http *http.Client
}
// Object is one entry returned by List.
type Object struct {
Key string
Size int64
LastModified time.Time
}
// New builds a client. endpoint must include the scheme (http:// or https://).
func New(endpoint, bucket, accessKey, secretKey string) *Client {
endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
host := endpoint
if i := strings.Index(host, "://"); i >= 0 {
host = host[i+3:]
}
return &Client{
endpoint: endpoint,
host: host,
bucket: strings.TrimSpace(bucket),
ak: strings.TrimSpace(accessKey),
sk: strings.TrimSpace(secretKey),
http: &http.Client{Timeout: 60 * time.Second},
}
}
// Configured reports whether the client has the minimum config to be usable.
func (c *Client) Configured() bool {
return c != nil && c.endpoint != "" && c.bucket != "" && c.ak != "" && c.sk != ""
}
// PublicURL is the direct object URL (used only for reference/debugging — the app
// serves through the authenticated /images proxy, not this).
func (c *Client) PublicURL(key string) string {
return c.endpoint + "/" + c.bucket + "/" + strings.TrimPrefix(key, "/")
}
// Put uploads body under key with the given content type.
func (c *Client) Put(ctx context.Context, key string, body []byte, contentType string) error {
resp, err := c.do(ctx, http.MethodPut, c.bucket+"/"+key, nil, body, contentType, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return c.statusErr("put", key, resp)
}
return nil
}
// Get fetches key. The caller owns resp.Body (must Close it) and streams it. A
// non-empty rangeHeader is forwarded verbatim (for video seeking). Returns the
// raw *http.Response so headers/status can be passed through by the proxy.
func (c *Client) Get(ctx context.Context, key, rangeHeader string) (*http.Response, error) {
extra := map[string]string{}
if strings.TrimSpace(rangeHeader) != "" {
extra["Range"] = rangeHeader
}
return c.do(ctx, http.MethodGet, c.bucket+"/"+key, nil, nil, "", extra)
}
// Delete removes key. A missing object is not an error.
func (c *Client) Delete(ctx context.Context, key string) error {
resp, err := c.do(ctx, http.MethodDelete, c.bucket+"/"+key, nil, nil, "", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 && resp.StatusCode != http.StatusNotFound {
return c.statusErr("delete", key, resp)
}
return nil
}
// List returns every object whose key starts with prefix (paginated internally).
func (c *Client) List(ctx context.Context, prefix string) ([]Object, error) {
var out []Object
token := ""
for {
q := map[string]string{"list-type": "2", "max-keys": "1000"}
if prefix != "" {
q["prefix"] = prefix
}
if token != "" {
q["continuation-token"] = token
}
resp, err := c.do(ctx, http.MethodGet, c.bucket, q, nil, "", nil)
if err != nil {
return nil, err
}
data, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("rustfs list: status %d: %s", resp.StatusCode, truncate(data))
}
var parsed struct {
Contents []struct {
Key string `xml:"Key"`
Size int64 `xml:"Size"`
LastModified time.Time `xml:"LastModified"`
} `xml:"Contents"`
IsTruncated bool `xml:"IsTruncated"`
NextContinuationToken string `xml:"NextContinuationToken"`
}
if err := xml.Unmarshal(data, &parsed); err != nil {
return nil, fmt.Errorf("rustfs list: parse: %w", err)
}
for _, it := range parsed.Contents {
out = append(out, Object{Key: it.Key, Size: it.Size, LastModified: it.LastModified})
}
if !parsed.IsTruncated || parsed.NextContinuationToken == "" {
break
}
token = parsed.NextContinuationToken
}
return out, nil
}
func (c *Client) statusErr(op, key string, resp *http.Response) error {
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("rustfs %s %q: status %d: %s", op, key, resp.StatusCode, truncate(data))
}
func truncate(b []byte) string {
s := strings.TrimSpace(string(b))
if len(s) > 300 {
return s[:300]
}
return s
}
// do builds, signs (SigV4) and sends a request. resourcePath is the path after
// the host WITHOUT a leading slash (e.g. "bucket/dir/file.png" or "bucket").
func (c *Client) do(ctx context.Context, method, resourcePath string, query map[string]string, body []byte, contentType string, extraHeaders map[string]string) (*http.Response, error) {
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
canonicalURI := "/" + uriEncode(resourcePath, true)
canonicalQuery := canonicalQueryString(query)
payloadHash := hexSHA256(body)
// Signed headers: always host + x-amz-content-sha256 + x-amz-date, plus
// content-type on PUT. Range etc. are sent unsigned.
signed := map[string]string{
"host": c.host,
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate,
}
if strings.TrimSpace(contentType) != "" {
signed["content-type"] = contentType
}
names := sortedKeys(signed)
var canonHeaders strings.Builder
for _, k := range names {
canonHeaders.WriteString(k + ":" + signed[k] + "\n")
}
signedHeaders := strings.Join(names, ";")
canonicalRequest := strings.Join([]string{
method, canonicalURI, canonicalQuery, canonHeaders.String(), signedHeaders, payloadHash,
}, "\n")
scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256", amzDate, scope, hexSHA256([]byte(canonicalRequest)),
}, "\n")
signature := hex.EncodeToString(hmacSHA256(signingKey(c.sk, dateStamp), []byte(stringToSign)))
auth := fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
c.ak, scope, signedHeaders, signature)
url := c.endpoint + canonicalURI
if canonicalQuery != "" {
url += "?" + canonicalQuery
}
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, url, rdr)
if err != nil {
return nil, err
}
req.Host = c.host
req.Header.Set("Authorization", auth)
req.Header.Set("x-amz-date", amzDate)
req.Header.Set("x-amz-content-sha256", payloadHash)
if ct := strings.TrimSpace(contentType); ct != "" {
req.Header.Set("Content-Type", ct)
}
for k, v := range extraHeaders {
req.Header.Set(k, v)
}
return c.http.Do(req)
}
// ---- SigV4 helpers ----
func hexSHA256(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func hmacSHA256(key, msg []byte) []byte {
h := hmac.New(sha256.New, key)
h.Write(msg)
return h.Sum(nil)
}
func signingKey(secret, dateStamp string) []byte {
kDate := hmacSHA256([]byte("AWS4"+secret), []byte(dateStamp))
kRegion := hmacSHA256(kDate, []byte(region))
kService := hmacSHA256(kRegion, []byte(service))
return hmacSHA256(kService, []byte("aws4_request"))
}
func sortedKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// simple insertion sort (small n)
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j-1] > out[j]; j-- {
out[j-1], out[j] = out[j], out[j-1]
}
}
return out
}
func canonicalQueryString(q map[string]string) string {
if len(q) == 0 {
return ""
}
keys := sortedKeys(q)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, uriEncode(k, false)+"="+uriEncode(q[k], false))
}
return strings.Join(parts, "&")
}
// uriEncode applies AWS's URI encoding rules. When keepSlash is true, '/' is left
// as-is (for object key paths); otherwise it's percent-encoded (for query parts).
func uriEncode(s string, keepSlash bool) string {
var b strings.Builder
for _, r := range []byte(s) {
switch {
case (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'),
r == '-', r == '_', r == '.', r == '~':
b.WriteByte(r)
case r == '/' && keepSlash:
b.WriteByte('/')
default:
b.WriteString(fmt.Sprintf("%%%02X", r))
}
}
return b.String()
}