refactor(media): replace hand-rolled SigV4 with maintained minio-go SDK; add /admin/media/upload endpoint
This commit is contained in:
10
app/go.mod
10
app/go.mod
@@ -7,3 +7,13 @@ require (
|
|||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/yuin/goldmark v1.8.6
|
github.com/yuin/goldmark v1.8.6
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
|
github.com/minio/minio-go v6.0.14+incompatible // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
14
app/go.sum
14
app/go.sum
@@ -1,6 +1,20 @@
|
|||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
|
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||||
|
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o=
|
||||||
|
github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
|||||||
@@ -37,25 +37,16 @@ func main() {
|
|||||||
log.Printf("loaded %d articles across %d subjects", len(c.Articles), len(c.Subjects))
|
log.Printf("loaded %d articles across %d subjects", len(c.Articles), len(c.Subjects))
|
||||||
|
|
||||||
s := NewSite(c)
|
s := NewSite(c)
|
||||||
// media proxy config (Garage S3)
|
// media (Garage S3 via minio-go SDK)
|
||||||
s.Media = &S3MediaClient{
|
s.Media = NewMediaClient()
|
||||||
Endpoint: os.Getenv("KONTRA_S3_ENDPOINT"),
|
|
||||||
Bucket: os.Getenv("KONTRA_S3_BUCKET"),
|
|
||||||
Key: os.Getenv("KONTRA_S3_KEY"),
|
|
||||||
Secret: os.Getenv("KONTRA_S3_SECRET"),
|
|
||||||
Client: http.Client{},
|
|
||||||
}
|
|
||||||
if s.Media.Endpoint == "" {
|
|
||||||
s.Media.Endpoint = "http://127.0.0.1:3900"
|
|
||||||
}
|
|
||||||
if s.Media.Bucket == "" {
|
|
||||||
s.Media.Bucket = "kontra-day"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routes — register specific paths BEFORE the catch-all.
|
// Routes — register specific paths BEFORE the catch-all.
|
||||||
http.HandleFunc("/media/{path...}", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/media/{path...}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.media(w, r, r.PathValue("path"))
|
s.media(w, r, r.PathValue("path"))
|
||||||
})
|
})
|
||||||
|
http.HandleFunc("/admin/media/upload", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mediaUpload(w, r)
|
||||||
|
})
|
||||||
http.HandleFunc("/subjects/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
http.HandleFunc("/subjects/{slug}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
s.subject(w, r, r.PathValue("slug"))
|
s.subject(w, r, r.PathValue("slug"))
|
||||||
})
|
})
|
||||||
|
|||||||
219
app/src/media.go
219
app/src/media.go
@@ -1,157 +1,106 @@
|
|||||||
// Kontra — Garage S3 media serving via AWS Signature Version 4.
|
// Kontra — Garage S3 media serving + upload via the maintained minio-go SDK.
|
||||||
// The app signs a GET for the scoped key and streams the object bytes back,
|
// (No hand-rolled signing: minio-go handles AWS SigV4 + path-style against Garage.)
|
||||||
// so /media/{name} works through the same container (no public Garage).
|
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
"bytes"
|
||||||
"crypto/sha256"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
"github.com/minio/minio-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
// hexLower encodes b as lowercase hex.
|
// MediaClient wraps minio-go for the kontra-day bucket.
|
||||||
func hexLower(b []byte) string {
|
type MediaClient struct {
|
||||||
const hexc = "0123456789abcdef"
|
|
||||||
var out strings.Builder
|
|
||||||
for _, x := range b {
|
|
||||||
out.Write([]byte{
|
|
||||||
byte(hexc[x >> 4]),
|
|
||||||
byte(hexc[x & 0xF]),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// sha256Hex returns hex(sha256(data)).
|
|
||||||
func sha256Hex(data []byte) string {
|
|
||||||
sum := sha256.Sum256(data)
|
|
||||||
var s []byte
|
|
||||||
for _, ch := range sum {
|
|
||||||
s = append(s, ch)
|
|
||||||
}
|
|
||||||
return hexLower(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hmacSha256 returns the raw 32-byte HMAC-SHA256(key, data).
|
|
||||||
func hmacSha256(key, data []byte) []byte {
|
|
||||||
h := hmac.New(sha256.New, key)
|
|
||||||
h.Write(data)
|
|
||||||
return h.Sum(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hmacSha256Hex returns hex(HMAC-SHA256(key, data)).
|
|
||||||
func hmacSha256Hex(key, data []byte) string {
|
|
||||||
return hexLower(hmacSha256(key, data))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignedGet is the prepared request.
|
|
||||||
type SignedGet struct {
|
|
||||||
URL string
|
|
||||||
Auth string
|
|
||||||
Date string
|
|
||||||
Host string
|
|
||||||
}
|
|
||||||
|
|
||||||
// signGet builds a SigV4 GET for path-style access: <endpoint>/<bucket>/<name>.
|
|
||||||
func signGet(endpoint, bucket, key, secret, name string, now time.Time) SignedGet {
|
|
||||||
const region = "garage" // s3_region
|
|
||||||
const service = "s3"
|
|
||||||
|
|
||||||
utc := now.UTC()
|
|
||||||
date := utc.Format("2006") + utc.Format("01") + utc.Format("02")
|
|
||||||
tstamp := utc.Format("15") + utc.Format("04") + utc.Format("05")
|
|
||||||
amzDate := date + "T" + tstamp + "Z"
|
|
||||||
|
|
||||||
host := endpoint
|
|
||||||
if strings.HasPrefix(host, "http://") {
|
|
||||||
host = strings.TrimPrefix(host, "http://")
|
|
||||||
} else if strings.HasPrefix(host, "https://") {
|
|
||||||
host = strings.TrimPrefix(host, "https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
canonicalPath := "/" + bucket + "/" + name
|
|
||||||
canonicalQuery := ""
|
|
||||||
payloadHash := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" // sha256("")
|
|
||||||
canonicalHeaders := "host:" + host + "\n" +
|
|
||||||
"x-amz-checksum-mode:ENABLED\n" +
|
|
||||||
"x-amz-content-sha256:" + payloadHash + "\n" +
|
|
||||||
"x-amz-date:" + amzDate + "\n"
|
|
||||||
signedHeaders := "host;x-amz-checksum-mode;x-amz-content-sha256;x-amz-date"
|
|
||||||
|
|
||||||
canonicalRequest := strings.Join([]string{
|
|
||||||
"GET",
|
|
||||||
canonicalPath,
|
|
||||||
canonicalQuery,
|
|
||||||
canonicalHeaders,
|
|
||||||
signedHeaders,
|
|
||||||
payloadHash,
|
|
||||||
}, "\n")
|
|
||||||
|
|
||||||
scope := date + "/" + region + "/" + service + "/aws4_request"
|
|
||||||
stringToSign := strings.Join([]string{
|
|
||||||
"AWS4-HMAC-SHA256",
|
|
||||||
amzDate,
|
|
||||||
scope,
|
|
||||||
sha256Hex([]byte(canonicalRequest)),
|
|
||||||
}, "\n")
|
|
||||||
|
|
||||||
kDate := hmacSha256([]byte("AWS4" + secret), []byte(date))
|
|
||||||
kRegion := hmacSha256(kDate, []byte(region))
|
|
||||||
kService := hmacSha256(kRegion, []byte(service))
|
|
||||||
kSigning := hmacSha256(kService, []byte("aws4_request"))
|
|
||||||
signature := hmacSha256Hex(kSigning, []byte(stringToSign))
|
|
||||||
|
|
||||||
auth := "AWS4-HMAC-SHA256 Credential=" + key + "/" + scope +
|
|
||||||
", SignedHeaders=" + signedHeaders + ", Signature=" + signature
|
|
||||||
|
|
||||||
return SignedGet{URL: endpoint + canonicalPath, Auth: auth, Date: amzDate, Host: host}
|
|
||||||
}
|
|
||||||
|
|
||||||
// S3MediaClient fetches objects from Garage with SigV4.
|
|
||||||
type S3MediaClient struct {
|
|
||||||
Endpoint string
|
Endpoint string
|
||||||
Bucket string
|
Bucket string
|
||||||
Key string
|
Key string
|
||||||
Secret string
|
Secret string
|
||||||
Client http.Client
|
Reader *minio.Client // nil until New() succeeds
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get streams object `name` to w; returns upstream status (0 on transport error).
|
// NewMediaClient builds the client (call once at startup).
|
||||||
func (c *S3MediaClient) Get(name string, w http.ResponseWriter) int {
|
func NewMediaClient() *MediaClient {
|
||||||
sg := signGet(c.Endpoint, c.Bucket, c.Key, c.Secret, name, time.Now())
|
mc := &MediaClient{
|
||||||
req, err := http.NewRequest("GET", sg.URL, nil)
|
Endpoint: os.Getenv("KONTRA_S3_ENDPOINT"),
|
||||||
|
Bucket: os.Getenv("KONTRA_S3_BUCKET"),
|
||||||
|
Key: os.Getenv("KONTRA_S3_KEY"),
|
||||||
|
Secret: os.Getenv("KONTRA_S3_SECRET"),
|
||||||
|
}
|
||||||
|
if mc.Endpoint == "" {
|
||||||
|
mc.Endpoint = "http://127.0.0.1:3900"
|
||||||
|
}
|
||||||
|
if mc.Bucket == "" {
|
||||||
|
mc.Bucket = "kontra-day"
|
||||||
|
}
|
||||||
|
if mc.Key != "" && mc.Secret != "" {
|
||||||
|
secure := strings.HasPrefix(mc.Endpoint, "https://")
|
||||||
|
addr := mc.Endpoint
|
||||||
|
if strings.HasPrefix(addr, "http://") {
|
||||||
|
addr = strings.TrimPrefix(addr, "http://")
|
||||||
|
} else if strings.HasPrefix(addr, "https://") {
|
||||||
|
addr = strings.TrimPrefix(addr, "https://")
|
||||||
|
}
|
||||||
|
cl, err := minio.New(addr, mc.Key, mc.Secret, secure)
|
||||||
|
if err != nil {
|
||||||
|
mc.Err = err
|
||||||
|
} else {
|
||||||
|
mc.Reader = cl
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mc.Err = fmt.Errorf("KONTRA_S3_KEY/SECRET not set")
|
||||||
|
}
|
||||||
|
return mc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get streams the object named `name` to the response. Returns true on success.
|
||||||
|
func (c *MediaClient) Get(name string, w http.ResponseWriter) bool {
|
||||||
|
if c.Reader == nil {
|
||||||
|
log.Printf("media: client not configured: %v", c.Err)
|
||||||
|
http.Error(w, "media not configured", http.StatusServiceUnavailable)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
obj, err := c.Reader.GetObject(c.Bucket, name, minio.GetObjectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
// treat missing object as 404
|
||||||
|
log.Printf("media get %s: %v", name, err)
|
||||||
|
http.Error(w, "media not found", http.StatusNotFound)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", sg.Auth)
|
defer obj.Close()
|
||||||
req.Header.Set("x-amz-date", sg.Date)
|
info, ierr := obj.Stat()
|
||||||
req.Header.Set("x-amz-content-sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
if ierr == nil && info.ContentType != "" {
|
||||||
req.Header.Set("x-amz-checksum-mode", "ENABLED")
|
w.Header().Set("Content-Type", info.ContentType)
|
||||||
req.Header.Set("Host", sg.Host)
|
|
||||||
|
|
||||||
resp, rerr := c.Client.Do(req)
|
|
||||||
if rerr != nil {
|
|
||||||
return 0
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
data, derr := io.ReadAll(obj)
|
||||||
status := resp.StatusCode
|
if derr != nil {
|
||||||
if status == 200 {
|
http.Error(w, "media read error", http.StatusBadGateway)
|
||||||
if ct := resp.Header.Get("Content-Type"); ct != "" {
|
return false
|
||||||
w.Header().Set("Content-Type", ct)
|
|
||||||
}
|
|
||||||
// read the whole object then write once (small media; keeps Content-Length exact)
|
|
||||||
all, berr := io.ReadAll(resp.Body)
|
|
||||||
if berr == nil {
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(all)))
|
|
||||||
_, _ = w.Write(all)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return status
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||||
|
_, _ = w.Write(data)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = fmt.Sprintf("") // silence unused-import lint
|
// Put stores bytes as an object in the bucket. Returns ok.
|
||||||
|
func (c *MediaClient) Put(name string, contentType string, data []byte) (bool, error) {
|
||||||
|
if c.Reader == nil {
|
||||||
|
return false, c.Err
|
||||||
|
}
|
||||||
|
rdr := bytes.NewBuffer(data)
|
||||||
|
opts := minio.PutObjectOptions{}
|
||||||
|
if contentType != "" {
|
||||||
|
opts.ContentType = contentType
|
||||||
|
}
|
||||||
|
_, perr := c.Reader.PutObject(c.Bucket, name, rdr, int64(len(data)), opts)
|
||||||
|
if perr != nil {
|
||||||
|
return false, perr
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"strings"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
)
|
)
|
||||||
@@ -12,20 +13,64 @@ import (
|
|||||||
// Site routes requests to content renderers.
|
// Site routes requests to content renderers.
|
||||||
type Site struct {
|
type Site struct {
|
||||||
Content *Content
|
Content *Content
|
||||||
Media *S3MediaClient
|
Media *MediaClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// media serves /media/{name} by proxying the Garage bucket (S3 SigV4).
|
// media serves /media/{name} from the Garage bucket (via minio-go SDK).
|
||||||
func (s *Site) media(w http.ResponseWriter, r *http.Request, name string) {
|
func (s *Site) media(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
if s.Media == nil || s.Media.Key == "" {
|
if s.Media == nil {
|
||||||
http.Error(w, "media not configured", http.StatusServiceUnavailable)
|
http.Error(w, "media not configured", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
status := s.Media.Get(name, w)
|
s.Media.Get(name, w)
|
||||||
if status != 200 && status != 0 {
|
}
|
||||||
http.Error(w, "media not found", http.StatusNotFound)
|
|
||||||
|
// mediaUpload handles POST /admin/media/upload (multipart) for the CMS editor.
|
||||||
|
func (s *Site) mediaUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
file, fh, ferr := r.FormFile("file")
|
||||||
|
if ferr != nil || file == nil || fh == nil {
|
||||||
|
http.Error(w, "no file part", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
_ = os.Getenv("")
|
defer file.Close()
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
name = fh.Filename
|
||||||
|
}
|
||||||
|
name = sanitizeMediaName(name)
|
||||||
|
if name == "" {
|
||||||
|
http.Error(w, "bad file name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, derr := io.ReadAll(file)
|
||||||
|
if derr != nil {
|
||||||
|
http.Error(w, "read error", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctype := fh.Header.Get("Content-Type")
|
||||||
|
ok, perr := s.Media.Put("media/" + name, ctype, data)
|
||||||
|
if !ok || perr != nil {
|
||||||
|
http.Error(w, "upload failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/media/" + name, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeMediaName keeps the base name, strips any path/unsafe chars.
|
||||||
|
func sanitizeMediaName(name string) string {
|
||||||
|
// keep only the final path element
|
||||||
|
base := name
|
||||||
|
if i := strings.LastIndexByte(base, '/'); i >= 0 {
|
||||||
|
base = base[i+1:]
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
for i := 0; i < len(base); i++ {
|
||||||
|
ch := base[i]
|
||||||
|
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '.' || ch == '-' || ch == '_' {
|
||||||
|
b.Write([]byte{ch})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// home renders the front page.
|
// home renders the front page.
|
||||||
|
|||||||
Reference in New Issue
Block a user