package app
import (
"bytes"
"crypto/sha1"
"encoding/xml"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
atomNamespace = "http://www.w3.org/2005/Atom"
xhtmlNamespace = "http://www.w3.org/1999/xhtml"
defaultAuthor = "rss-tools"
)
type AtomFeed struct {
XMLName xml.Name `xml:"feed"`
XMLNS string `xml:"xmlns,attr"`
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Authors []AtomPerson `xml:"author,omitempty"`
Subtitle string `xml:"subtitle,omitempty"`
Entries []AtomEntry `xml:"entry"`
}
type AtomEntry struct {
Title string `xml:"title"`
ID string `xml:"id"`
Updated string `xml:"updated"`
Links []AtomLink `xml:"link,omitempty"`
Content AtomContent `xml:"content"`
}
type AtomContent struct {
XMLName xml.Name `xml:"content"`
Type string `xml:"type,attr,omitempty"`
Value string `xml:",chardata"`
}
func (c AtomContent) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
contentType := c.Type
if contentType == "" {
contentType = "text"
}
start.Name = xml.Name{Local: "content"}
start.Attr = append(start.Attr, xml.Attr{
Name: xml.Name{Local: "type"},
Value: contentType,
})
if err := e.EncodeToken(start); err != nil {
return err
}
if contentType == "xhtml" {
if err := validateXHTMLFragment(c.Value); err != nil {
return err
}
if err := e.Encode(xhtmlDiv{
XMLNS: xhtmlNamespace,
Inner: c.Value,
}); err != nil {
return err
}
} else {
if err := e.EncodeToken(xml.CharData([]byte(c.Value))); err != nil {
return err
}
}
if err := e.EncodeToken(start.End()); err != nil {
return err
}
return e.Flush()
}
type xhtmlDiv struct {
XMLName xml.Name `xml:"div"`
XMLNS string `xml:"xmlns,attr"`
Inner string `xml:",innerxml"`
}
func validateXHTMLFragment(fragment string) error {
wrapped := fmt.Sprintf(`
%s
`, xhtmlNamespace, fragment)
dec := xml.NewDecoder(strings.NewReader(wrapped))
for {
_, err := dec.Token()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("invalid xhtml content: %w", err)
}
}
}
type AtomPerson struct {
Name string `xml:"name"`
}
type AtomLink struct {
Rel string `xml:"rel,attr,omitempty"`
Type string `xml:"type,attr,omitempty"`
Length string `xml:"length,attr,omitempty"`
Href string `xml:"href,attr"`
}
type FeedEntry struct {
Title string
ID string
Links []FeedLink
Content string
ContentType string // "text", "html", or "xhtml"; defaults to "text"
Updated time.Time
}
type FeedLink struct {
Rel string
Type string
Length string
Href string
}
type FeedBuilder struct{ f AtomFeed }
func NewFeed(title, id string) *FeedBuilder {
return &FeedBuilder{f: AtomFeed{
XMLNS: atomNamespace,
Title: title,
ID: id,
Updated: time.Now().Format(time.RFC3339),
Authors: []AtomPerson{{Name: defaultAuthor}},
}}
}
func (f *FeedBuilder) WithSubtitle(subtitle string) *FeedBuilder {
f.f.Subtitle = subtitle
return f
}
func (f *FeedBuilder) WithAuthor(name string) *FeedBuilder {
name = strings.TrimSpace(name)
if name == "" {
return f
}
f.f.Authors = []AtomPerson{{Name: name}}
return f
}
func (f *FeedBuilder) WithUpdated(updated time.Time) *FeedBuilder {
if !updated.IsZero() {
f.f.Updated = updated.Format(time.RFC3339)
}
return f
}
func (f *FeedBuilder) Add(entry FeedEntry) *FeedBuilder {
if entry.Updated.IsZero() {
entry.Updated = time.Now()
}
if entry.ID == "" {
hash := sha1.Sum(fmt.Appendf(nil, "%s|%s|%s", entry.Title, entry.Content, entry.Updated.Format(time.RFC3339Nano)))
entry.ID = fmt.Sprintf("urn:sha1:%x", hash)
}
contentType := entry.ContentType
if contentType == "" {
contentType = "text"
}
links := make([]AtomLink, 0, len(entry.Links))
for _, link := range entry.Links {
if link.Href == "" {
continue
}
links = append(links, AtomLink(link))
}
f.f.Entries = append(f.f.Entries, AtomEntry{
Title: entry.Title,
ID: entry.ID,
Updated: entry.Updated.Format(time.RFC3339),
Links: links,
Content: AtomContent{
Type: contentType,
Value: entry.Content,
},
})
feedUpdated, err := time.Parse(time.RFC3339, f.f.Updated)
if err != nil || entry.Updated.After(feedUpdated) {
f.f.Updated = entry.Updated.Format(time.RFC3339)
}
return f
}
func (f *FeedBuilder) SetUpdated(updated time.Time) *FeedBuilder {
if !updated.IsZero() {
f.f.Updated = updated.Format(time.RFC3339)
}
return f
}
func (f *FeedBuilder) WriteTo(w io.Writer) error {
enc := xml.NewEncoder(w)
enc.Indent("", " ")
return enc.Encode(f.f)
}
func (f *FeedBuilder) Bytes() ([]byte, error) {
var buf bytes.Buffer
if err := f.WriteTo(&buf); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func (f *FeedBuilder) Render(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
return f.WriteTo(w)
}