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>
295 lines
8.9 KiB
Go
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()
|
|
}
|