refactor(media): replace hand-rolled SigV4 with maintained minio-go SDK; add /admin/media/upload endpoint

This commit is contained in:
sam
2026-09-10 07:12:35 +10:00
parent df4a19933c
commit 5acddaa605
5 changed files with 166 additions and 157 deletions

View File

@@ -7,3 +7,13 @@ require (
github.com/goccy/go-yaml v1.19.2
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
)

View File

@@ -1,6 +1,20 @@
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/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/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/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=

View File

@@ -37,25 +37,16 @@ func main() {
log.Printf("loaded %d articles across %d subjects", len(c.Articles), len(c.Subjects))
s := NewSite(c)
// media proxy config (Garage S3)
s.Media = &S3MediaClient{
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"
}
// media (Garage S3 via minio-go SDK)
s.Media = NewMediaClient()
// Routes — register specific paths BEFORE the catch-all.
http.HandleFunc("/media/{path...}", func(w http.ResponseWriter, r *http.Request) {
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) {
s.subject(w, r, r.PathValue("slug"))
})

View File

@@ -1,157 +1,106 @@
// Kontra — Garage S3 media serving via AWS Signature Version 4.
// The app signs a GET for the scoped key and streams the object bytes back,
// so /media/{name} works through the same container (no public Garage).
// Kontra — Garage S3 media serving + upload via the maintained minio-go SDK.
// (No hand-rolled signing: minio-go handles AWS SigV4 + path-style against Garage.)
package main
import (
"crypto/hmac"
"crypto/sha256"
"bytes"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/minio/minio-go"
)
// hexLower encodes b as lowercase hex.
func hexLower(b []byte) string {
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 {
// MediaClient wraps minio-go for the kontra-day bucket.
type MediaClient struct {
Endpoint string
Bucket string
Key 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).
func (c *S3MediaClient) Get(name string, w http.ResponseWriter) int {
sg := signGet(c.Endpoint, c.Bucket, c.Key, c.Secret, name, time.Now())
req, err := http.NewRequest("GET", sg.URL, nil)
// NewMediaClient builds the client (call once at startup).
func NewMediaClient() *MediaClient {
mc := &MediaClient{
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 {
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)
req.Header.Set("x-amz-date", sg.Date)
req.Header.Set("x-amz-content-sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
req.Header.Set("x-amz-checksum-mode", "ENABLED")
req.Header.Set("Host", sg.Host)
resp, rerr := c.Client.Do(req)
if rerr != nil {
return 0
defer obj.Close()
info, ierr := obj.Stat()
if ierr == nil && info.ContentType != "" {
w.Header().Set("Content-Type", info.ContentType)
}
defer resp.Body.Close()
status := resp.StatusCode
if status == 200 {
if ct := resp.Header.Get("Content-Type"); ct != "" {
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)
}
data, derr := io.ReadAll(obj)
if derr != nil {
http.Error(w, "media read error", http.StatusBadGateway)
return false
}
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
}

View File

@@ -3,8 +3,9 @@
package main
import (
"io"
"net/http"
"os"
"strings"
"github.com/a-h/templ"
)
@@ -12,20 +13,64 @@ import (
// Site routes requests to content renderers.
type Site struct {
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) {
if s.Media == nil || s.Media.Key == "" {
if s.Media == nil {
http.Error(w, "media not configured", http.StatusServiceUnavailable)
return
}
status := s.Media.Get(name, w)
if status != 200 && status != 0 {
http.Error(w, "media not found", http.StatusNotFound)
s.Media.Get(name, w)
}
// 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.