diff --git a/app/go.mod b/app/go.mod index db4a282..2f521fe 100644 --- a/app/go.mod +++ b/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 +) diff --git a/app/go.sum b/app/go.sum index 7a84557..eaa18d7 100644 --- a/app/go.sum +++ b/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= diff --git a/app/src/main.go b/app/src/main.go index a9a862c..407fba0 100644 --- a/app/src/main.go +++ b/app/src/main.go @@ -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")) }) diff --git a/app/src/media.go b/app/src/media.go index 28b8227..cf8954e 100644 --- a/app/src/media.go +++ b/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: //. -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 \ No newline at end of file +// 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 +} \ No newline at end of file diff --git a/app/src/server.go b/app/src/server.go index 6cbc4fb..803fa93 100644 --- a/app/src/server.go +++ b/app/src/server.go @@ -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.