apps/feeds/handlers_public.go 7.1 K raw
1
package main
2
3
import (
4
	"encoding/xml"
5
	"fmt"
6
	"net/http"
7
	"net/url"
8
	"strings"
9
	"time"
10
11
	"github.com/stevedylandev/andromeda/pkg/web"
12
)
13
14
const opmlPreviewPerFeed = 5
15
16
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
17
	query := r.URL.Query().Get("url")
18
	if query == "" {
19
		query = r.URL.Query().Get("urls")
20
	}
21
	data := indexPageData{BaseURL: a.BaseURL}
22
	if query != "" {
23
		urls := splitAndTrim(query)
24
		data.FeedURLs = urls
25
		if len(urls) == 0 {
26
			data.Error = "No URLs provided"
27
			web.Render(a.Templates, w, "index.html", data, a.Log)
28
			return
29
		}
30
		perFeed := 0
31
		if len(urls) == 1 && isOPMLURL(urls[0]) {
32
			content, err := fetchOPMLDoc(r.Context(), urls[0])
33
			if err != nil {
34
				a.Log.Warn("opml preview fetch failed", "url", urls[0], "err", err)
35
				data.Error = "Could not load OPML"
36
				web.Render(a.Templates, w, "index.html", data, a.Log)
37
				return
38
			}
39
			entries := parseOPML(content)
40
			if len(entries) == 0 {
41
				data.Error = "No feeds found in OPML"
42
				web.Render(a.Templates, w, "index.html", data, a.Log)
43
				return
44
			}
45
			feedURLs := make([]string, 0, len(entries))
46
			for _, e := range entries {
47
				if e.XMLURL != "" {
48
					feedURLs = append(feedURLs, e.XMLURL)
49
				}
50
			}
51
			urls = feedURLs
52
			perFeed = opmlPreviewPerFeed
53
		}
54
		for _, item := range previewURLs(r.Context(), urls, perFeed, a.Log) {
55
			data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
56
		}
57
		web.Render(a.Templates, w, "index.html", data, a.Log)
58
		return
59
	}
60
61
	items, err := listItems(a.DB, ListItemsFilter{Limit: 100})
62
	if err != nil {
63
		a.Log.Error("index query failed", "err", err)
64
		data.Error = "Error loading feeds. Please try again later."
65
		web.Render(a.Templates, w, "index.html", data, a.Log)
66
		return
67
	}
68
	for _, item := range items {
69
		author := item.FeedTitle
70
		if item.Author != nil && strings.TrimSpace(*item.Author) != "" {
71
			author = item.FeedTitle + " - " + *item.Author
72
		}
73
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: author, FormattedDate: formatDate(item.PublishedAt)})
74
	}
75
	web.Render(a.Templates, w, "index.html", data, a.Log)
76
}
77
78
func (a *App) feedsExportHandler(w http.ResponseWriter, r *http.Request) {
79
	subs, err := listSubscriptions(a.DB)
80
	if err != nil {
81
		http.Error(w, "internal server error", http.StatusInternalServerError)
82
		return
83
	}
84
85
	switch r.URL.Query().Get("format") {
86
	case "", "json":
87
		rows := make([]map[string]any, 0, len(subs))
88
		for _, s := range subs {
89
			rows = append(rows, map[string]any{
90
				"id":      fmt.Sprintf("feed/%d", s.ID),
91
				"title":   s.Title,
92
				"url":     s.FeedURL,
93
				"htmlUrl": nullStringValue(s.SiteURL),
94
			})
95
		}
96
		web.WriteJSON(w, http.StatusOK, map[string]any{"subscriptions": rows})
97
	case "opml":
98
		a.writeOPMLExport(w, subs)
99
	default:
100
		web.WriteError(w, http.StatusBadRequest, "Invalid format. Use ?format=json or ?format=opml")
101
	}
102
}
103
104
func (a *App) atomFeedHandler(w http.ResponseWriter, r *http.Request) {
105
	items, err := listItems(a.DB, ListItemsFilter{Limit: 100})
106
	if err != nil {
107
		http.Error(w, "internal server error", http.StatusInternalServerError)
108
		return
109
	}
110
	type atomLink struct {
111
		Href string `xml:"href,attr"`
112
		Rel  string `xml:"rel,attr,omitempty"`
113
		Type string `xml:"type,attr,omitempty"`
114
	}
115
	type atomAuthor struct {
116
		Name string `xml:"name"`
117
	}
118
	type atomSource struct {
119
		Title string `xml:"title"`
120
	}
121
	type atomEntry struct {
122
		Title     string     `xml:"title"`
123
		Link      atomLink   `xml:"link"`
124
		ID        string     `xml:"id"`
125
		Updated   string     `xml:"updated"`
126
		Published string     `xml:"published"`
127
		Author    atomAuthor `xml:"author"`
128
		Source    atomSource `xml:"source"`
129
	}
130
	type atomFeed struct {
131
		XMLName xml.Name    `xml:"feed"`
132
		Xmlns   string      `xml:"xmlns,attr"`
133
		Title   string      `xml:"title"`
134
		Links   []atomLink  `xml:"link"`
135
		ID      string      `xml:"id"`
136
		Updated string      `xml:"updated"`
137
		Entries []atomEntry `xml:"entry"`
138
	}
139
140
	updated := time.Now().UTC().Format(time.RFC3339)
141
	if len(items) > 0 {
142
		updated = time.Unix(items[0].PublishedAt, 0).UTC().Format(time.RFC3339)
143
	}
144
	base := strings.TrimRight(a.BaseURL, "/")
145
	feed := atomFeed{
146
		Xmlns:   "http://www.w3.org/2005/Atom",
147
		Title:   "Feeds",
148
		ID:      base + "/feed.xml",
149
		Updated: updated,
150
		Links:   []atomLink{{Href: base + "/feed.xml", Rel: "self", Type: "application/atom+xml"}, {Href: base}},
151
	}
152
	for _, item := range items {
153
		author := item.FeedTitle
154
		if item.Author != nil && *item.Author != "" {
155
			author = *item.Author
156
		}
157
		entryID := item.GUID
158
		if strings.TrimSpace(entryID) == "" {
159
			entryID = item.Link
160
		}
161
		published := time.Unix(item.PublishedAt, 0).UTC().Format(time.RFC3339)
162
		feed.Entries = append(feed.Entries, atomEntry{Title: item.Title, Link: atomLink{Href: item.Link}, ID: entryID, Updated: published, Published: published, Author: atomAuthor{Name: author}, Source: atomSource{Title: item.FeedTitle}})
163
	}
164
	body, _ := xml.MarshalIndent(feed, "", "  ")
165
	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")
166
	_, _ = w.Write([]byte(xml.Header))
167
	_, _ = w.Write(body)
168
}
169
170
func (a *App) writeOPMLExport(w http.ResponseWriter, subs []Subscription) {
171
	cats, _ := listCategories(a.DB)
172
	catNames := map[int64]string{}
173
	for _, c := range cats {
174
		catNames[c.ID] = c.Name
175
	}
176
	grouped := map[string][]Subscription{}
177
	for _, s := range subs {
178
		key := ""
179
		if s.CategoryID.Valid {
180
			key = catNames[s.CategoryID.Int64]
181
		}
182
		grouped[key] = append(grouped[key], s)
183
	}
184
	type outline struct {
185
		XMLName xml.Name  `xml:"outline"`
186
		Text    string    `xml:"text,attr,omitempty"`
187
		Title   string    `xml:"title,attr,omitempty"`
188
		Type    string    `xml:"type,attr,omitempty"`
189
		XMLURL  string    `xml:"xmlUrl,attr,omitempty"`
190
		HTMLURL string    `xml:"htmlUrl,attr,omitempty"`
191
		Nodes   []outline `xml:"outline,omitempty"`
192
	}
193
	type opml struct {
194
		XMLName xml.Name `xml:"opml"`
195
		Version string   `xml:"version,attr"`
196
		Head    struct {
197
			Title       string `xml:"title"`
198
			DateCreated string `xml:"dateCreated"`
199
		} `xml:"head"`
200
		Body struct {
201
			Nodes []outline `xml:"outline"`
202
		} `xml:"body"`
203
	}
204
	doc := opml{Version: "2.0"}
205
	doc.Head.Title = "Feeds"
206
	doc.Head.DateCreated = time.Now().Format(time.RFC1123Z)
207
	for category, rows := range grouped {
208
		if category == "" {
209
			for _, s := range rows {
210
				doc.Body.Nodes = append(doc.Body.Nodes, outline{Text: s.Title, Title: s.Title, Type: "rss", XMLURL: s.FeedURL, HTMLURL: nullStringValue(s.SiteURL)})
211
			}
212
			continue
213
		}
214
		group := outline{Text: category, Title: category}
215
		for _, s := range rows {
216
			group.Nodes = append(group.Nodes, outline{Text: s.Title, Title: s.Title, Type: "rss", XMLURL: s.FeedURL, HTMLURL: nullStringValue(s.SiteURL)})
217
		}
218
		doc.Body.Nodes = append(doc.Body.Nodes, group)
219
	}
220
	body, _ := xml.MarshalIndent(doc, "", "  ")
221
	w.Header().Set("Content-Type", "application/xml")
222
	w.Header().Set("Content-Disposition", `attachment; filename="feeds.opml"`)
223
	_, _ = w.Write([]byte(xml.Header))
224
	_, _ = w.Write(body)
225
}
226
227
func isOPMLURL(raw string) bool {
228
	u, err := url.Parse(raw)
229
	if err != nil {
230
		return false
231
	}
232
	return strings.HasSuffix(strings.ToLower(u.Path), ".opml")
233
}