- 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>
145 lines
4.6 KiB
Go
145 lines
4.6 KiB
Go
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
|
|
}
|