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/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/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=
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
221
app/src/media.go
221
app/src/media.go
@@ -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 {
|
||||
return 0
|
||||
mc.Err = err
|
||||
} else {
|
||||
mc.Reader = cl
|
||||
}
|
||||
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
|
||||
} else {
|
||||
mc.Err = fmt.Errorf("KONTRA_S3_KEY/SECRET not set")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
return status
|
||||
return mc
|
||||
}
|
||||
|
||||
var _ = fmt.Sprintf("") // silence unused-import lint
|
||||
// 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 {
|
||||
// treat missing object as 404
|
||||
log.Printf("media get %s: %v", name, err)
|
||||
http.Error(w, "media not found", http.StatusNotFound)
|
||||
return false
|
||||
}
|
||||
defer obj.Close()
|
||||
info, ierr := obj.Stat()
|
||||
if ierr == nil && info.ContentType != "" {
|
||||
w.Header().Set("Content-Type", info.ContentType)
|
||||
}
|
||||
data, derr := io.ReadAll(obj)
|
||||
if derr != nil {
|
||||
http.Error(w, "media read error", http.StatusBadGateway)
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
|
||||
_, _ = w.Write(data)
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user