Skip to content

Commit d7a17aa

Browse files
svilenmarkovs0ders
andcommitted
Add reddit app auth #529
Co-authored-by: s0ders <39492740+s0ders@users.noreply.github.com>
1 parent 65adf9b commit d7a17aa

File tree

3 files changed

+122
-74
lines changed

3 files changed

+122
-74
lines changed

docs/configuration.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -843,7 +843,10 @@ Display a list of posts from a specific subreddit.
843843

844844
> [!WARNING]
845845
>
846-
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403 response. As a workaround you can route the traffic from Glance through a VPN or your own HTTP proxy using the `request-url-template` property.
846+
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403
847+
> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the
848+
> generated ID and secret in the widget configuration to authenticate your requests (see `app-auth` property), use a proxy
849+
> (see `proxy` property) or route the traffic from Glance through a VPN.
847850

848851
Example:
849852

@@ -868,6 +871,7 @@ Example:
868871
| top-period | string | no | day |
869872
| search | string | no | |
870873
| extra-sort-by | string | no | |
874+
| app-auth | object | no | |
871875

872876
##### `subreddit`
873877
The subreddit for which to fetch the posts from.
@@ -975,6 +979,17 @@ Can be used to specify an additional sort which will be applied on top of the al
975979

976980
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
977981

982+
##### `app-auth`
983+
```yaml
984+
widgets:
985+
- type: reddit
986+
subreddit: technology
987+
app-auth:
988+
name: ${REDDIT_APP_NAME}
989+
id: ${REDDIT_APP_CLIENT_ID}
990+
secret: ${REDDIT_APP_SECRET}
991+
```
992+
978993
### Search Widget
979994
Display a search bar that can be used to search for specific terms on various search engines.
980995

internal/glance/widget-reddit.go

Lines changed: 100 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,20 @@ type redditWidget struct {
2929
TopPeriod string `yaml:"top-period"`
3030
Search string `yaml:"search"`
3131
ExtraSortBy string `yaml:"extra-sort-by"`
32-
CommentsUrlTemplate string `yaml:"comments-url-template"`
32+
CommentsURLTemplate string `yaml:"comments-url-template"`
3333
Limit int `yaml:"limit"`
3434
CollapseAfter int `yaml:"collapse-after"`
35-
RequestUrlTemplate string `yaml:"request-url-template"`
35+
RequestURLTemplate string `yaml:"request-url-template"`
36+
37+
AppAuth struct {
38+
Name string `yaml:"name"`
39+
ID string `yaml:"id"`
40+
Secret string `yaml:"secret"`
41+
42+
enabled bool
43+
accessToken string
44+
tokenExpiresAt time.Time
45+
} `yaml:"app-auth"`
3646
}
3747

3848
func (widget *redditWidget) initialize() error {
@@ -48,20 +58,30 @@ func (widget *redditWidget) initialize() error {
4858
widget.CollapseAfter = 5
4959
}
5060

51-
if !isValidRedditSortType(widget.SortBy) {
61+
s := widget.SortBy
62+
if s != "hot" && s != "new" && s != "top" && s != "rising" {
5263
widget.SortBy = "hot"
5364
}
5465

55-
if !isValidRedditTopPeriod(widget.TopPeriod) {
66+
p := widget.TopPeriod
67+
if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" {
5668
widget.TopPeriod = "day"
5769
}
5870

59-
if widget.RequestUrlTemplate != "" {
60-
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") {
71+
if widget.RequestURLTemplate != "" {
72+
if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") {
6173
return errors.New("no `{REQUEST-URL}` placeholder specified")
6274
}
6375
}
6476

77+
a := &widget.AppAuth
78+
if a.Name != "" || a.ID != "" || a.Secret != "" {
79+
if a.Name == "" || a.ID == "" || a.Secret == "" {
80+
return errors.New("application name, client ID and client secret are required")
81+
}
82+
a.enabled = true
83+
}
84+
6585
widget.
6686
withTitle("r/" + widget.Subreddit).
6787
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
@@ -70,35 +90,8 @@ func (widget *redditWidget) initialize() error {
7090
return nil
7191
}
7292

73-
func isValidRedditSortType(sortBy string) bool {
74-
return sortBy == "hot" ||
75-
sortBy == "new" ||
76-
sortBy == "top" ||
77-
sortBy == "rising"
78-
}
79-
80-
func isValidRedditTopPeriod(period string) bool {
81-
return period == "hour" ||
82-
period == "day" ||
83-
period == "week" ||
84-
period == "month" ||
85-
period == "year" ||
86-
period == "all"
87-
}
88-
8993
func (widget *redditWidget) update(ctx context.Context) {
90-
// TODO: refactor, use a struct to pass all of these
91-
posts, err := fetchSubredditPosts(
92-
widget.Subreddit,
93-
widget.SortBy,
94-
widget.TopPeriod,
95-
widget.Search,
96-
widget.CommentsUrlTemplate,
97-
widget.RequestUrlTemplate,
98-
widget.Proxy.client,
99-
widget.ShowFlairs,
100-
)
101-
94+
posts, err := widget.fetchSubredditPosts()
10295
if !widget.canContinueUpdateAfterHandlingErr(err) {
10396
return
10497
}
@@ -155,57 +148,65 @@ type subredditResponseJson struct {
155148
} `json:"data"`
156149
}
157150

158-
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string {
159-
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit)
151+
func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string {
152+
template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit)
160153
template = strings.ReplaceAll(template, "{POST-ID}", postId)
161154
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
162155

163156
return template
164157
}
165158

166-
func fetchSubredditPosts(
167-
subreddit,
168-
sort,
169-
topPeriod,
170-
search,
171-
commentsUrlTemplate,
172-
requestUrlTemplate string,
173-
proxyClient *http.Client,
174-
showFlairs bool,
175-
) (forumPostList, error) {
159+
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
160+
var client requestDoer = defaultHTTPClient
161+
var baseURL string
162+
var requestURL string
163+
var headers http.Header
176164
query := url.Values{}
177-
var requestUrl string
165+
app := &widget.AppAuth
178166

179-
if search != "" {
180-
query.Set("q", search+" subreddit:"+subreddit)
181-
query.Set("sort", sort)
182-
}
167+
if !app.enabled {
168+
baseURL = "https://www.reddit.com"
169+
headers = http.Header{
170+
"User-Agent": []string{getBrowserUserAgentHeader()},
171+
}
172+
} else {
173+
baseURL = "https://oauth.reddit.com"
174+
175+
if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) {
176+
if err := widget.fetchNewAppAccessToken(); err != nil {
177+
return nil, fmt.Errorf("fetching new app access token: %v", err)
178+
}
179+
}
183180

184-
if sort == "top" {
185-
query.Set("t", topPeriod)
181+
headers = http.Header{
182+
"Authorization": []string{"Bearer " + app.accessToken},
183+
"User-Agent": []string{app.Name + "/1.0"},
184+
}
186185
}
187186

188-
if search != "" {
189-
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
187+
if widget.Search != "" {
188+
query.Set("q", widget.Search+" subreddit:"+widget.Subreddit)
189+
query.Set("sort", widget.SortBy)
190+
requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode())
190191
} else {
191-
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
192+
if widget.SortBy == "top" {
193+
query.Set("t", widget.TopPeriod)
194+
}
195+
requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode())
192196
}
193197

194-
var client requestDoer = defaultHTTPClient
195-
196-
if requestUrlTemplate != "" {
197-
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", url.QueryEscape(requestUrl))
198-
} else if proxyClient != nil {
199-
client = proxyClient
198+
if widget.RequestURLTemplate != "" {
199+
requestURL = strings.ReplaceAll(widget.RequestURLTemplate, "{REQUEST-URL}", requestURL)
200+
} else if widget.Proxy.client != nil {
201+
client = widget.Proxy.client
200202
}
201203

202-
request, err := http.NewRequest("GET", requestUrl, nil)
204+
request, err := http.NewRequest("GET", requestURL, nil)
203205
if err != nil {
204206
return nil, err
205207
}
208+
request.Header = headers
206209

207-
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
208-
setBrowserUserAgentHeader(request)
209210
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
210211
if err != nil {
211212
return nil, err
@@ -226,10 +227,10 @@ func fetchSubredditPosts(
226227

227228
var commentsUrl string
228229

229-
if commentsUrlTemplate == "" {
230+
if widget.CommentsURLTemplate == "" {
230231
commentsUrl = "https://www.reddit.com" + post.Permalink
231232
} else {
232-
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
233+
commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink)
233234
}
234235

235236
forumPost := forumPost{
@@ -249,19 +250,18 @@ func fetchSubredditPosts(
249250
forumPost.TargetUrl = post.Url
250251
}
251252

252-
if showFlairs && post.Flair != "" {
253+
if widget.ShowFlairs && post.Flair != "" {
253254
forumPost.Tags = append(forumPost.Tags, post.Flair)
254255
}
255256

256257
if len(post.ParentList) > 0 {
257258
forumPost.IsCrosspost = true
258259
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
259260

260-
if commentsUrlTemplate == "" {
261+
if widget.CommentsURLTemplate == "" {
261262
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
262263
} else {
263-
forumPost.TargetUrl = templateRedditCommentsURL(
264-
commentsUrlTemplate,
264+
forumPost.TargetUrl = widget.parseCustomCommentsURL(
265265
post.ParentList[0].Subreddit,
266266
post.ParentList[0].Id,
267267
post.ParentList[0].Permalink,
@@ -274,3 +274,32 @@ func fetchSubredditPosts(
274274

275275
return posts, nil
276276
}
277+
278+
func (widget *redditWidget) fetchNewAppAccessToken() (err error) {
279+
body := strings.NewReader("grant_type=client_credentials")
280+
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body)
281+
if err != nil {
282+
return fmt.Errorf("creating request for app access token: %v", err)
283+
}
284+
285+
app := &widget.AppAuth
286+
req.SetBasicAuth(app.ID, app.Secret)
287+
req.Header.Add("User-Agent", app.Name+"/1.0")
288+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
289+
290+
type tokenResponse struct {
291+
AccessToken string `json:"access_token"`
292+
ExpiresIn int `json:"expires_in"`
293+
}
294+
295+
client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient)
296+
response, err := decodeJsonFromRequest[tokenResponse](client, req)
297+
if err != nil {
298+
return fmt.Errorf("decoding Reddit API response: %v", err)
299+
}
300+
301+
app.accessToken = response.AccessToken
302+
app.tokenExpiresAt = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
303+
304+
return nil
305+
}

internal/glance/widget-utils.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,17 @@ type requestDoer interface {
4040

4141
var userAgentPersistentVersion atomic.Int32
4242

43-
func setBrowserUserAgentHeader(request *http.Request) {
43+
func getBrowserUserAgentHeader() string {
4444
if rand.IntN(2000) == 0 {
4545
userAgentPersistentVersion.Store(rand.Int32N(5))
4646
}
4747

4848
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
49-
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:"+version+".0) Gecko/20100101 Firefox/"+version+".0")
49+
return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + version + ".0) Gecko/20100101 Firefox/" + version + ".0"
50+
}
51+
52+
func setBrowserUserAgentHeader(request *http.Request) {
53+
request.Header.Set("User-Agent", getBrowserUserAgentHeader())
5054
}
5155

5256
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {

0 commit comments

Comments
 (0)