diff --git a/src/internal/app/app.go b/src/internal/app/app.go index 5f4edc3..9f6697d 100644 --- a/src/internal/app/app.go +++ b/src/internal/app/app.go @@ -9,12 +9,14 @@ import ( type App struct { articles *database.ArticleRepository articleVMs *database.ArticleViewModelRepository + rssItems *database.RSSItemRepository } func NewApp(db *sql.DB) *App { return &App{ articles: &database.ArticleRepository{DB: db}, articleVMs: &database.ArticleViewModelRepository{DB: db}, + rssItems: &database.RSSItemRepository{DB: db}, } } @@ -22,6 +24,8 @@ func (app *App) Routes() http.Handler { mux := http.NewServeMux() // dynamic routes + mux.Handle("GET /rss.xml", http.HandlerFunc(app.RSS)) + mux.Handle("GET /", http.HandlerFunc(app.Index)) mux.Handle("GET /page/{id}", http.HandlerFunc(app.Index)) mux.Handle("POST /up/search", http.HandlerFunc(app.UpSearch)) diff --git a/src/internal/app/rss.go b/src/internal/app/rss.go new file mode 100644 index 0000000..e6c0a87 --- /dev/null +++ b/src/internal/app/rss.go @@ -0,0 +1,28 @@ +package app + +import ( + "crowsnest/internal/model" + "encoding/xml" + "net/http" +) + +// List the latest articles using the base template. +func (app *App) RSS(w http.ResponseWriter, req *http.Request) { + // set response headers + w.Header().Set("Content-Type", "application/rss+xml") + w.WriteHeader(http.StatusOK) + + // get articles + feed, err := app.rssItems.All(30) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // write RSS feed to response + encoder := xml.NewEncoder(w) + encoder.Indent("", " ") + if err := encoder.Encode(model.RSSFeedFromItems(feed)); err != nil { + http.Error(w, "Error generating RSS feed", http.StatusInternalServerError) + } +} diff --git a/src/internal/model/database/rssitemrepository.go b/src/internal/model/database/rssitemrepository.go new file mode 100644 index 0000000..fe287c2 --- /dev/null +++ b/src/internal/model/database/rssitemrepository.go @@ -0,0 +1,60 @@ +package database + +import ( + "crowsnest/internal/model" + "database/sql" + "strconv" + "time" +) + +type RSSItemRepository struct { + DB *sql.DB +} + +// Gets all the article as view models objects from the database. This may throw +// an error if the connection to the database fails. +func (m *RSSItemRepository) All(limit int) ([]model.RSSItem, error) { + stmt := ` + SELECT a.id, a.title, a.sourceUrl, a.publishDate, d.summary + FROM articles a JOIN documents d ON a.document_id = d.id + ORDER BY a.publishDate DESC + LIMIT $1 + ` + rows, err := m.DB.Query(stmt, limit) + if err != nil { + return nil, err + } + + items := make([]model.RSSItem, 0, limit) + for rows.Next() { + i := model.RSSItem{} + + var id int + var sourceUrl string + var date time.Time + + err := rows.Scan(&id, &i.Title, &sourceUrl, &date, &i.Description) + if err != nil { + return nil, err + } + + // description + if i.Description == "" { + i.Description = "N/A" + } + // format date + i.PubDate = date.Format(time.RFC1123Z) + // link + i.Link = "https://crowsnest.kohout-dev.de/article/" + strconv.Itoa(id) + // identifier + i.Guid = model.RSSGuid{IsPermaLink: "false", Value: strconv.Itoa(id)} + + items = append(items, i) + } + + if err = rows.Err(); err != nil { + return nil, err + } + + return items, nil +} diff --git a/src/internal/model/rss.go b/src/internal/model/rss.go new file mode 100644 index 0000000..654cd68 --- /dev/null +++ b/src/internal/model/rss.go @@ -0,0 +1,51 @@ +package model + +import ( + "encoding/xml" + "time" +) + +// RSS represents the RSS feed structure. +type RSSFeed struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + Channel RSSChannel `xml:"channel"` +} + +// Channel represents the channel element in the RSS feed. +type RSSChannel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + Items []RSSItem `xml:"item"` +} + +// Item represents an individual item in the RSS feed. +type RSSItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + Description string `xml:"description"` + Guid RSSGuid `xml:"guid"` + PubDate string `xml:"pubDate"` +} + +type RSSGuid struct { + IsPermaLink string `xml:"isPermaLink,attr,omitempty"` + Value string `xml:",chardata"` +} + +func RSSFeedFromItems(items []RSSItem) *RSSFeed { + rssChannel := RSSChannel{ + Title: "crowsnest", + Link: "https://crowsnest.kohout-dev.de", + Description: "N/A", + PubDate: time.Now().Format(time.RFC1123Z), + Items: items, + } + + return &RSSFeed{ + Version: "2.0", + Channel: rssChannel, + } +}