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 (
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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/<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
|
||||
# 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 }
|
||||
|
||||
Reference in New Issue
Block a user