diff --git a/app/go.mod b/app/go.mod index 2f521fe..f6af8db 100644 --- a/app/go.mod +++ b/app/go.mod @@ -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 diff --git a/app/src/content.go b/app/src/content.go index b7b3e1f..f0a910a 100644 --- a/app/src/content.go +++ b/app/src/content.go @@ -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//.md and folder-per-article subjects///.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 { diff --git a/app/src/main.go b/app/src/main.go index 6fabc32..75434d2 100644 --- a/app/src/main.go +++ b/app/src/main.go @@ -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")) }) diff --git a/app/src/media.go b/app/src/media.go deleted file mode 100644 index 356a8dd..0000000 --- a/app/src/media.go +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/app/src/server.go b/app/src/server.go index 5356ab0..8e74829 100644 --- a/app/src/server.go +++ b/app/src/server.go @@ -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//img.jpg) +// referenced as {{media:}} — 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. diff --git a/app/src/templates.templ b/app/src/templates.templ index 13fbc99..e2d762d 100644 --- a/app/src/templates.templ +++ b/app/src/templates.templ @@ -300,39 +300,3 @@ templ AdminRedirect() { } - -// 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() { - - - - - - Kontra — Media Library - - - -
-
- - -
-
-
-
-

Media Library

-
- Upload an image — it is stored in Garage S3 and served at /media/<file>. - A {{media:...}} snippet is shown to paste into an article. -
- -
-
- - -} \ No newline at end of file diff --git a/app/src/web/admin/config.yml b/app/src/web/admin/config.yml index 00c817b..2c9d81b 100644 --- a/app/src/web/admin/config.yml +++ b/app/src/web/admin/config.yml @@ -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 }