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:
144
controllers/api/rss.go
Normal file
144
controllers/api/rss.go
Normal 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, "]]>", "]]>")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user