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:
@@ -10,7 +10,6 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
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
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
golang.org/x/crypto v0.48.0 // indirect
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ func (c *Content) loadSubject(slug string) (*Subject, error) {
|
|||||||
return s, nil
|
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 {
|
func (c *Content) loadFilesUnder(dir string, out *[]*Article) error {
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,7 +186,15 @@ func (c *Content) loadFilesUnder(dir string, out *[]*Article) error {
|
|||||||
}
|
}
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
name := e.Name()
|
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
|
continue
|
||||||
}
|
}
|
||||||
if a, err := c.loadFile(path.Join(dir, name)); err == nil {
|
if a, err := c.loadFile(path.Join(dir, name)); err == nil {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import (
|
|||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// bundled static assets + admin UI (served under /web/)
|
// 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))
|
log.Printf("loaded %d articles across %d subjects", len(c.Articles), len(c.Subjects))
|
||||||
|
|
||||||
s := NewSite(c)
|
s := NewSite(c)
|
||||||
// media (Garage S3 via minio-go SDK)
|
|
||||||
s.Media = NewMediaClient()
|
|
||||||
|
|
||||||
// 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("/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) {
|
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"))
|
||||||
})
|
})
|
||||||
|
|||||||
108
app/src/media.go
108
app/src/media.go
@@ -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
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
"github.com/a-h/templ"
|
||||||
@@ -13,64 +15,46 @@ import (
|
|||||||
// Site routes requests to content renderers.
|
// Site routes requests to content renderers.
|
||||||
type Site struct {
|
type Site struct {
|
||||||
Content *Content
|
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) {
|
func (s *Site) media(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
if s.Media == nil {
|
root := s.Content.Root
|
||||||
http.Error(w, "media not configured", http.StatusServiceUnavailable)
|
// 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
|
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.
|
// mimeTypeFor returns a content type for common media extensions.
|
||||||
func (s *Site) mediaUpload(w http.ResponseWriter, r *http.Request) {
|
func mimeTypeFor(ext string) string {
|
||||||
file, fh, ferr := r.FormFile("file")
|
switch ext {
|
||||||
if ferr != nil || file == nil || fh == nil {
|
case ".jpg", ".jpeg": return "image/jpeg"
|
||||||
http.Error(w, "no file part", http.StatusBadRequest)
|
case ".png": return "image/png"
|
||||||
return
|
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.
|
// home renders the front page.
|
||||||
|
|||||||
@@ -300,39 +300,3 @@ templ AdminRedirect() {
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html><head><meta http-equiv="refresh" content="0; url=/admin/"/></head><body></body></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/<file></b>.
|
|
||||||
A {{media:...}} 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>
|
|
||||||
}
|
|
||||||
@@ -15,8 +15,10 @@ backend:
|
|||||||
# Media goes to Garage S3 via shortcode at render time; Decap just stores the
|
# 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,
|
# 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.
|
# point media_folder at content/media and public_folder at /media.
|
||||||
media_folder: "" # no local uploads by default
|
media_folder: ""
|
||||||
public_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
|
# Collections
|
||||||
@@ -37,7 +39,7 @@ collections:
|
|||||||
- { name: kicker, label: Kicker (section tag), widget: string, required: false }
|
- { name: kicker, label: Kicker (section tag), widget: string, required: false }
|
||||||
- { name: template, label: Template, widget: string, default: article }
|
- { 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: 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: excerpt, label: Excerpt / deck, widget: text, required: false }
|
||||||
- { name: tags, label: Tags, widget: list, allow_add: true, required: false }
|
- { name: tags, label: Tags, widget: list, allow_add: true, required: false }
|
||||||
- { name: featured, label: Featured on front page, widget: boolean, default: false }
|
- { name: featured, label: Featured on front page, widget: boolean, default: false }
|
||||||
|
|||||||
Reference in New Issue
Block a user