feat(15-03): add journal media upload, RSS, and off-by-default Typesense gate

- POST /_journal/api/v1/media/upload requires access_posts and stays under journal/
- Anonymous GET rss is RSS 2.0 honoring rss_* settings; unpublished posts are omitted
- beachcomber Gate reads search_use_typesense (default false) so a fresh save makes no Typesense HTTP

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 19:03:35 +02:00
parent 3a1dda45ec
commit bdd1c1be61
12 changed files with 748 additions and 21 deletions

147
controllers/api/media.go Normal file
View File

@@ -0,0 +1,147 @@
package api
import (
"io"
"net/http"
"path"
"regexp"
"strings"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/wire"
"gocloud.dev/blob"
"gocloud.dev/gcerrors"
)
const mediaMaxBytes = 10240 << 10
var mediaFolderPattern = regexp.MustCompile(`^[A-Za-z0-9_\-/]*$`)
// UploadMedia is POST /_journal/api/v1/media/upload (JOURNAL-006).
func UploadMedia(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r, pr, ok := requireBackendPrincipal(app, w, r)
if !ok {
return
}
if !requireAccessPosts(w, pr) {
return
}
folder := strings.TrimSpace(r.FormValue("folder"))
if len(folder) > 100 || !mediaFolderPattern.MatchString(folder) || strings.Contains(folder, "..") {
writeValidation(w, map[string][]string{"folder": {"The folder format is invalid."}})
return
}
file, header, err := r.FormFile("file")
if err != nil {
writeValidation(w, map[string][]string{"file": {"The file field is required."}})
return
}
defer file.Close()
name := path.Base(strings.ReplaceAll(header.Filename, `\`, "/"))
ext := strings.ToLower(strings.TrimPrefix(path.Ext(name), "."))
switch ext {
case "jpg", "jpeg", "png", "gif", "webp":
default:
writeValidation(w, map[string][]string{"file": {"The file must be an image."}})
return
}
bucket, ok := app.Lookup[*blob.Bucket]()
if !ok || bucket == nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
keyPath := mediaObjectPath(folder, name)
body, err := io.ReadAll(io.LimitReader(file, mediaMaxBytes+1))
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if int64(len(body)) > mediaMaxBytes {
writeValidation(w, map[string][]string{"file": {"The file may not be greater than 10240 kilobytes."}})
return
}
keyPath, err = uniqueMediaKey(r, bucket, keyPath)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
wtr, err := bucket.NewWriter(r.Context(), strings.TrimPrefix(keyPath, "/"), nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if _, err := wtr.Write(body); err != nil {
_ = wtr.Close()
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
if err := wtr.Close(); err != nil {
writeAPIError(w, http.StatusInternalServerError, "Internal server error")
return
}
wire.WriteJSON(w, http.StatusCreated, map[string]any{
"data": map[string]any{
"url": attach.PublicURL(strings.TrimPrefix(keyPath, "/")),
"path": keyPath,
},
})
}
}
func mediaObjectPath(folder, fileName string) string {
segments := make([]string, 0, 4)
for _, part := range strings.Split(strings.Trim(folder, "/"), "/") {
if part == "" || part == "." || part == ".." {
continue
}
segments = append(segments, part)
}
if len(segments) > 0 && strings.EqualFold(segments[0], "journal") {
segments = segments[1:]
}
folder = strings.Join(segments, "/")
if folder == "" {
return "/journal/" + fileName
}
return "/journal/" + folder + "/" + fileName
}
func uniqueMediaKey(r *http.Request, bucket *blob.Bucket, pathName string) (string, error) {
key := strings.TrimPrefix(pathName, "/")
ok, err := bucket.Exists(r.Context(), key)
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
return "", err
}
if !ok {
return pathName, nil
}
ext := path.Ext(pathName)
base := strings.TrimSuffix(pathName, ext)
for n := 1; n < 1000; n++ {
candidate := base + "-" + itoa(n) + ext
exists, err := bucket.Exists(r.Context(), strings.TrimPrefix(candidate, "/"))
if err != nil && gcerrors.Code(err) != gcerrors.NotFound {
return "", err
}
if !exists {
return candidate, nil
}
}
return pathName, nil
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [16]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}

View File

@@ -77,11 +77,19 @@ func Index(app *backpack.App) http.HandlerFunc {
search = ""
}
if search != "" {
like := "%" + escapeLike(search) + "%"
q = q.Where(
"(title ILIKE ? ESCAPE '\\' OR slug ILIKE ? ESCAPE '\\' OR excerpt ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')",
like, like, like, like,
)
if ids, used := searchWithEngine(app, r, db.WithContext(r.Context()), search, publishedOnly, page, perPage); used {
if len(ids) == 0 {
q = q.Where("1 = 0")
} else {
q = q.Where("id IN ?", ids)
}
} else {
like := "%" + escapeLike(search) + "%"
q = q.Where(
"(title ILIKE ? ESCAPE '\\' OR slug ILIKE ? ESCAPE '\\' OR excerpt ILIKE ? ESCAPE '\\' OR content ILIKE ? ESCAPE '\\')",
like, like, like, like,
)
}
}
var total int64
@@ -181,15 +189,6 @@ func Tags(app *backpack.App) http.HandlerFunc {
}
}
// RSS is GET /_journal/api/v1/rss. Task 3 fills the PHP RSS contract.
func RSS(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel></channel></rss>`))
}
}
// Store is POST /_journal/api/v1/posts. Field-by-field create; never lagoon.Fill.
func Store(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,53 @@
package api
import (
"log/slog"
"net/http"
"strconv"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"gorm.io/gorm"
)
func searchWithEngine(app *backpack.App, r *http.Request, db *gorm.DB, search string, publishedOnly bool, page, perPage int) ([]uint, bool) {
if app == nil || db == nil || !models.SearchGateEnabled() {
return nil, false
}
svc, err := beachcomber.From(app)
if err != nil || svc == nil || svc.Engine() == nil || !svc.Engine().Configured() {
return nil, false
}
var settings models.Settings
if err := db.First(&settings, 1).Error; err != nil {
settings.SearchTitleWeight = 5
settings.SearchExcerptWeight = 3
settings.SearchContentWeight = 1
}
q := beachcomber.Query{
Q: search,
QueryBy: []string{"title", "excerpt", "content"},
QueryByWeights: []int{settings.SearchTitleWeight, settings.SearchExcerptWeight, settings.SearchContentWeight},
SortBy: "_text_match:desc,published_at:desc",
Page: page,
PerPage: perPage,
}
if publishedOnly {
q.FilterBy = "published:=1"
}
res, err := beachcomber.SearchPage(r.Context(), svc.Engine(), svc.IndexName(&models.Post{}), q)
if err != nil {
slog.Default().Warn("journal: Typesense search failed, falling back to DB search", "err", err)
return nil, false
}
ids := make([]uint, 0, len(res.IDs))
for _, raw := range res.IDs {
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil || n == 0 {
continue
}
ids = append(ids, uint(n))
}
return ids, true
}

144
controllers/api/rss.go Normal file
View File

@@ -0,0 +1,144 @@
package api
import (
"encoding/xml"
"html"
"net/http"
"strings"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-journal-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
)
// RSS is GET /_journal/api/v1/rss. Empty or disabled settings still yield a
// well-formed RSS 2.0 document (D-14).
func RSS(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, err := gdb(app)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
var settings models.Settings
if err := db.WithContext(r.Context()).First(&settings, 1).Error; err != nil {
settings = models.Settings{RSSLanguage: "en-us", RSSPostsPerFeed: 20, RSSEnabled: true}
}
limit := settings.RSSPostsPerFeed
if limit <= 0 {
limit = 20
}
var posts []models.Post
if err := applyPublished(db.WithContext(r.Context()).Model(&models.Post{})).
Preload("User").Preload("Categories").
Order("published_at DESC").Limit(limit).Find(&posts).Error; err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
title := "Journal"
if settings.RSSTitle != nil && strings.TrimSpace(*settings.RSSTitle) != "" {
title = strings.TrimSpace(*settings.RSSTitle)
}
desc := ""
if settings.RSSDescription != nil {
desc = *settings.RSSDescription
}
site := requestOrigin(r, app)
self := site + r.URL.RequestURI()
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>` + "\n")
b.WriteString(`<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/">` + "\n")
b.WriteString("<channel>\n")
writeXMLTag(&b, "title", title)
writeXMLTag(&b, "link", site+"/")
writeXMLTag(&b, "description", desc)
if settings.RSSLanguage != "" {
writeXMLTag(&b, "language", settings.RSSLanguage)
}
if settings.RSSCopyright != nil && strings.TrimSpace(*settings.RSSCopyright) != "" {
writeXMLTag(&b, "copyright", *settings.RSSCopyright)
}
writeXMLTag(&b, "lastBuildDate", time.Now().UTC().Format(time.RFC1123Z))
writeXMLTag(&b, "generator", "Golem15 Journal")
b.WriteString(`<atom:link rel="self" href="` + xmlEsc(self) + `" type="application/rss+xml" />` + "\n")
if settings.RSSImageURL != nil && strings.TrimSpace(*settings.RSSImageURL) != "" {
b.WriteString("<image>\n")
writeXMLTag(&b, "url", *settings.RSSImageURL)
writeXMLTag(&b, "title", title)
writeXMLTag(&b, "link", site+"/")
b.WriteString("</image>\n")
}
for i := range posts {
p := &posts[i]
postURL := site + "/blog/" + p.Slug
summary := p.Excerpt
if strings.TrimSpace(summary) == "" {
summary = truncateRunes(stripTags(p.ContentHTML), 300)
}
b.WriteString("<item>\n")
writeXMLTag(&b, "title", p.Title)
writeXMLTag(&b, "link", postURL)
b.WriteString(`<guid isPermaLink="true">` + xmlEsc(postURL) + "</guid>\n")
if p.PublishedAt != nil {
writeXMLTag(&b, "pubDate", p.PublishedAt.UTC().Format(time.RFC1123Z))
}
b.WriteString("<description><![CDATA[" + cdata(summary) + "]]></description>\n")
if settings.RSSIncludeContent && p.ContentHTML != "" {
b.WriteString("<content:encoded><![CDATA[" + cdata(p.ContentHTML) + "]]></content:encoded>\n")
}
if p.User != nil {
creator := strings.TrimSpace(p.User.FirstName + " " + p.User.LastName)
if creator != "" {
b.WriteString("<dc:creator>" + xmlEsc(creator) + "</dc:creator>\n")
}
}
for _, cat := range p.Categories {
writeXMLTag(&b, "category", cat.Name)
}
b.WriteString("</item>\n")
}
b.WriteString("</channel>\n</rss>\n")
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(b.String()))
}
}
func writeXMLTag(b *strings.Builder, name, value string) {
b.WriteString("<" + name + ">" + xmlEsc(value) + "</" + name + ">\n")
}
func xmlEsc(s string) string {
var b strings.Builder
_ = xml.EscapeText(&b, []byte(s))
return b.String()
}
func cdata(s string) string {
return strings.ReplaceAll(s, "]]>", "]]&gt;")
}
func requestOrigin(r *http.Request, app *backpack.App) string {
if app != nil && app.Config != nil {
if u := strings.TrimRight(strings.TrimSpace(app.Config.String("url")), "/"); u != "" {
return u
}
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host
}
func truncateRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
runes := []rune(html.UnescapeString(s))
if len(runes) > n {
return string(runes[:n])
}
return s
}