handlers.go 5.1 K raw
1
package main
2
3
import (
4
	"context"
5
	"fmt"
6
	"net/http"
7
	"net/url"
8
	"strings"
9
	"sync"
10
	"time"
11
)
12
13
const maxFeedURLs = 20
14
15
func (a *App) privacyHandler(w http.ResponseWriter, r *http.Request) {
16
	render(a.Templates, w, "privacy.html", nil, a.Log)
17
}
18
19
func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) {
20
	data := indexPageData{
21
		BaseURL:         a.BaseURL,
22
		MetaTitle:       "Feeds",
23
		MetaDescription: "An introduction to RSS",
24
		CanonicalURL:    a.BaseURL,
25
		OGImage:         a.BaseURL + "/static/og.png",
26
	}
27
28
	urls := feedURLsFromRequest(r)
29
	if len(urls) == 0 {
30
		render(a.Templates, w, "index.html", data, a.Log)
31
		return
32
	}
33
	data.CanonicalURL = a.BaseURL + r.URL.RequestURI()
34
	data.OGImage = a.BaseURL + "/og.png?" + r.URL.RawQuery
35
36
	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
37
	defer cancel()
38
	items, titles := previewURLs(ctx, urls, 0, a.Cache, a.Log)
39
	for _, item := range items {
40
		data.Items = append(data.Items, templateItem{Title: item.Title, Link: item.Link, Author: item.Author, FormattedDate: formatDate(item.Published)})
41
	}
42
	if len(data.Items) == 0 {
43
		data.Error = "No items could be loaded from these feeds"
44
	}
45
	for _, u := range urls {
46
		name := titles[u]
47
		if name == "" {
48
			name = hostName(u)
49
		}
50
		data.FeedURLs = append(data.FeedURLs, feedRef{Name: name, URL: u})
51
	}
52
	data.MetaTitle, data.MetaDescription = feedMeta(urls, titles, len(data.Items))
53
	render(a.Templates, w, "index.html", data, a.Log)
54
}
55
56
// feedURLsFromRequest extracts and normalizes the feed URLs from the "url" or
57
// "urls" query param, capped at maxFeedURLs.
58
func feedURLsFromRequest(r *http.Request) []string {
59
	query := r.URL.Query().Get("url")
60
	if query == "" {
61
		query = r.URL.Query().Get("urls")
62
	}
63
	urls := splitAndTrim(query)
64
	if len(urls) > maxFeedURLs {
65
		urls = urls[:maxFeedURLs]
66
	}
67
	return urls
68
}
69
70
// feedMeta builds an og:title and og:description from the shared feed URLs,
71
// their resolved titles (falling back to the URL host), and the item count.
72
func feedMeta(urls []string, titles map[string]string, itemCount int) (title, description string) {
73
	names := make([]string, 0, len(urls))
74
	for _, u := range urls {
75
		name := titles[u]
76
		if name == "" {
77
			name = hostName(u)
78
		}
79
		if name != "" {
80
			names = append(names, name)
81
		}
82
	}
83
84
	switch {
85
	case len(names) == 0:
86
		title = "Feeds"
87
	case len(names) == 1:
88
		title = names[0]
89
	default:
90
		title = fmt.Sprintf("%s +%d more", names[0], len(names)-1)
91
	}
92
93
	feedWord := "feed"
94
	if len(urls) != 1 {
95
		feedWord = "feeds"
96
	}
97
	postWord := "post"
98
	if itemCount != 1 {
99
		postWord = "posts"
100
	}
101
	description = fmt.Sprintf("%d %s from %d %s", itemCount, postWord, len(urls), feedWord)
102
	return title, description
103
}
104
105
// hostName returns the host of a URL with a leading "www." stripped.
106
func hostName(raw string) string {
107
	if !strings.Contains(raw, "://") {
108
		raw = "https://" + raw
109
	}
110
	u, err := url.Parse(raw)
111
	if err != nil {
112
		return ""
113
	}
114
	return strings.TrimPrefix(u.Host, "www.")
115
}
116
117
type resolvedFeed struct {
118
	URL     string `json:"url"`
119
	Title   string `json:"title"`
120
	Favicon string `json:"favicon,omitempty"`
121
}
122
123
// faviconFor discovers the favicon for a fetched feed, falling back to the
124
// feed URL's origin when the feed doesn't declare a site link.
125
func faviconFor(ctx context.Context, res *FetchResult, feedURL string) string {
126
	site := res.SiteURL
127
	if site == "" {
128
		if u, err := url.Parse(feedURL); err == nil && u.Host != "" {
129
			site = u.Scheme + "://" + u.Host
130
		}
131
	}
132
	if site == "" {
133
		return ""
134
	}
135
	return discoverFavicon(ctx, site)
136
}
137
138
func (a *App) resolveHandler(w http.ResponseWriter, r *http.Request) {
139
	raw := strings.TrimSpace(r.URL.Query().Get("url"))
140
	if raw == "" {
141
		writeError(w, http.StatusBadRequest, "url parameter is required")
142
		return
143
	}
144
	if !strings.Contains(raw, "://") {
145
		raw = "https://" + raw
146
	}
147
148
	ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
149
	defer cancel()
150
151
	// If the input parses as a feed, use it directly.
152
	if res, err := fetchFeed(ctx, raw, "", ""); err == nil {
153
		writeJSON(w, http.StatusOK, map[string]any{"feeds": []resolvedFeed{{URL: raw, Title: res.Title, Favicon: faviconFor(ctx, res, raw)}}})
154
		return
155
	}
156
157
	// Otherwise treat it as a site URL and discover feeds.
158
	candidates, err := discoverFeeds(ctx, raw)
159
	if err != nil {
160
		writeError(w, http.StatusUnprocessableEntity, "no feed found at this URL")
161
		return
162
	}
163
	if len(candidates) > 3 {
164
		candidates = candidates[:3]
165
	}
166
167
	var wg sync.WaitGroup
168
	var mu sync.Mutex
169
	found := map[string]resolvedFeed{}
170
	for _, candidate := range candidates {
171
		wg.Add(1)
172
		go func() {
173
			defer wg.Done()
174
			res, err := fetchFeed(ctx, candidate, "", "")
175
			if err != nil {
176
				a.Log.Warn("resolve candidate failed", "url", candidate, "err", err)
177
				return
178
			}
179
			favicon := faviconFor(ctx, res, candidate)
180
			mu.Lock()
181
			found[candidate] = resolvedFeed{URL: candidate, Title: res.Title, Favicon: favicon}
182
			mu.Unlock()
183
		}()
184
	}
185
	wg.Wait()
186
187
	feeds := make([]resolvedFeed, 0, len(found))
188
	for _, candidate := range candidates {
189
		if f, ok := found[candidate]; ok {
190
			feeds = append(feeds, f)
191
		}
192
	}
193
	if len(feeds) == 0 {
194
		writeError(w, http.StatusUnprocessableEntity, "no feed found at this URL")
195
		return
196
	}
197
	writeJSON(w, http.StatusOK, map[string]any{"feeds": feeds})
198
}