media: drop Garage S3/minio-go; images are git files beside articles; /media serves from content root (safe-path); loader recurses into article folders; remove admin upload page

This commit is contained in:
sam
2026-09-10 09:12:38 +10:00
parent 644ee9f292
commit ee80b58352
7 changed files with 51 additions and 210 deletions

View File

@@ -10,7 +10,6 @@ require (
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

View File

@@ -176,7 +176,9 @@ func (c *Content) loadSubject(slug string) (*Subject, error) {
return s, nil
}
// loadFilesUnder loads every *.md directly under dir (non-recursive; skips _*).
// loadFilesUnder loads every *.md under dir, recursing into subfolders
// (skips _prefixed dirs/files: config, templates). Supports both flat
// subjects/<s>/<slug>.md and folder-per-article subjects/<s>/<slug>/<slug>.md.
func (c *Content) loadFilesUnder(dir string, out *[]*Article) error {
entries, err := os.ReadDir(dir)
if err != nil {
@@ -184,7 +186,15 @@ func (c *Content) loadFilesUnder(dir string, out *[]*Article) error {
}
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".md") || strings.HasPrefix(name, "_") {
if strings.HasPrefix(name, "_") {
continue
}
if e.IsDir() {
// recurse one level (article folder) — skip hidden
c.loadFilesUnder(path.Join(dir, name), out)
continue
}
if !strings.HasSuffix(name, ".md") {
continue
}
if a, err := c.loadFile(path.Join(dir, name)); err == nil {

View File

@@ -9,8 +9,6 @@ import (
"mime"
"net/http"
"os"
"github.com/a-h/templ"
)
// bundled static assets + admin UI (served under /web/)
@@ -39,19 +37,11 @@ func main() {
log.Printf("loaded %d articles across %d subjects", len(c.Articles), len(c.Subjects))
s := NewSite(c)
// 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("/admin/media", func(w http.ResponseWriter, r *http.Request) {
templ.Handler(AdminMediaPage()).ServeHTTP(w, r)
})
http.HandleFunc("/subjects/{slug}", func(w http.ResponseWriter, r *http.Request) {
s.subject(w, r, r.PathValue("slug"))
})

View File

@@ -1,108 +0,0 @@
// 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 (
"bytes"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"github.com/minio/minio-go"
)
// MediaClient wraps minio-go for the kontra-day bucket.
type MediaClient struct {
Endpoint string
Bucket string
Key string
Secret string
Reader *minio.Client // nil until New() succeeds
Err error
}
// 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://")
}
// Garage region is "garage" (not AWS us-east-1) — critical for SigV4 scope.
cl, err := minio.NewWithRegion(addr, mc.Key, mc.Secret, secure, "garage")
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 {
// 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 {
log.Printf("media read %s: %v", name, derr)
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
}

View File

@@ -3,8 +3,10 @@
package main
import (
"io"
"fmt"
"net/http"
"os"
"path"
"strings"
"github.com/a-h/templ"
@@ -13,64 +15,46 @@ import (
// Site routes requests to content renderers.
type Site struct {
Content *Content
Media *MediaClient
}
// media serves /media/{name} from the Garage bucket (via minio-go SDK).
// media serves /media/{path...} by reading the file from the content repo root.
// Images are git-stored files beside their articles (e.g. subjects/world/<slug>/img.jpg)
// referenced as {{media:<path>}} — this maps path -> content root, safe-path guarded.
func (s *Site) media(w http.ResponseWriter, r *http.Request, name string) {
if s.Media == nil {
http.Error(w, "media not configured", http.StatusServiceUnavailable)
root := s.Content.Root
// guard: reject any path that escapes the content root (no .., no leading /)
if strings.Contains(name, "..") || strings.HasPrefix(name, "/") {
http.Error(w, "bad media path", http.StatusBadRequest)
return
}
s.Media.Get(name, w)
full := path.Join(root, name)
data, err := os.ReadFile(full)
if err != nil {
http.Error(w, "media not found", http.StatusNotFound)
return
}
ctype := mimeTypeFor(path.Ext(full))
if ctype != "" {
w.Header().Set("Content-Type", ctype)
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data)))
_, _ = w.Write(data)
}
// 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
// mimeTypeFor returns a content type for common media extensions.
func mimeTypeFor(ext string) string {
switch ext {
case ".jpg", ".jpeg": return "image/jpeg"
case ".png": return "image/png"
case ".gif": return "image/gif"
case ".webp": return "image/webp"
case ".svg": return "image/svg+xml"
case ".avif": return "image/avif"
case ".mp4": return "video/mp4"
case ".webm": return "video/webm"
case ".mp3": return "audio/mpeg"
default: return ""
}
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(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.

View File

@@ -300,39 +300,3 @@ templ AdminRedirect() {
<!DOCTYPE html>
<html><head><meta http-equiv="refresh" content="0; url=/admin/"/></head><body></body></html>
}
// AdminMediaPage is a browser-based media uploader that posts to
// /admin/media/upload (which stores into Garage S3 via minio-go) and
// returns the {{media:...}} snippet to paste into an article.
templ AdminMediaPage() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Kontra — Media Library</title>
<link rel="stylesheet" href="/web/assets/kontra.css"/>
</head>
<body class="admin-body">
<header class="masthead"><div class="container">
<div class="brand-row"><div aria-hidden="true"></div>
<div class="brand"><a href="/" class="display-masthead">Kontra</a></div>
<div class="brand-tools"><a href="/admin" class="nav-link">CMS</a></div>
</div>
</div></header>
<main class="container">
<section style="padding:24px 0;">
<h1 class="headline-lg" style="color:var(--ink);">Media Library</h1>
<div class="body-sm" style="color:var(--ink-muted);margin:8px 0 16px;">
Upload an image — it is stored in Garage S3 and served at <b>/media/&lt;file&gt;</b>.
A &#123;&#123;media:...&#125;&#125; snippet is shown to paste into an article.
</div>
<form method="post" action="/admin/media/upload" enctype="multipart/form-data" class="newsletter-card">
<input class="field" type="file" name="file" required/>
<button class="btn-primary" type="submit">Upload</button>
</form>
</section>
</main>
</body>
</html>
}

View File

@@ -15,8 +15,10 @@ backend:
# Media goes to Garage S3 via shortcode at render time; Decap just stores the
# object key inside the .md. If you instead want files committed to the repo,
# point media_folder at content/media and public_folder at /media.
media_folder: "" # no local uploads by default
media_folder: ""
public_folder: ""
# Media library is DISABLED — images are uploaded via the Kontra app at /admin/media
# (stored in Garage S3) and referenced in front matter as {{media:filename}}.
# ------------------------------------------------------------------
# Collections
@@ -37,7 +39,7 @@ collections:
- { name: kicker, label: Kicker (section tag), widget: string, required: false }
- { name: template, label: Template, widget: string, default: article }
- { name: subject, label: Subject, widget: relation, collection: subjects, value_field: slug, search_fields: [name], display_fields: [name] }
- { name: image, label: Cover image, widget: image, required: false }
- { name: image, label: 'Cover image (upload first at /admin/media; then use: {{media:filename}})', widget: string, required: false }
- { name: excerpt, label: Excerpt / deck, widget: text, required: false }
- { name: tags, label: Tags, widget: list, allow_add: true, required: false }
- { name: featured, label: Featured on front page, widget: boolean, default: false }