Skip to content

Commit dfc52a0

Browse files
committed
feat: new optional OAuth2 configuration for the Reddit widget.
This commit brings the following: - New `reddit-app-name`, `reddit-client-id` and `reddit-client-secret` optional configurations for the Reddit widget to authenticate requests sent to the Reddit API (avoiding being blocked when Glance is self-hosted) - Refactoring the of `fetchSubredditPosts` function which is now a method of `redditWidget` since it's unexported and only uses parameters which are fields of the `redditWidget` struct. ref: #509
1 parent 233de7f commit dfc52a0

File tree

2 files changed

+148
-58
lines changed

2 files changed

+148
-58
lines changed

Diff for: docs/configuration.md

+39-15
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,11 @@ Display a list of posts from a specific subreddit.
789789

790790
> [!WARNING]
791791
>
792-
> 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.
792+
> Reddit does not allow unauthorized API access from VPS IPs, if you're hosting Glance on a VPS you will get a 403
793+
> response. As a workaround you can either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the
794+
> generated ID and secret in the widget configuration to authenticate your requests (see `reddit-app-name`,
795+
> `reddit-client-id` and `reddit-client-secret`) or route the traffic from Glance through a VPN or your own HTTP proxy
796+
> using the `request-url-template` property.
793797

794798
Example:
795799

@@ -799,21 +803,24 @@ Example:
799803
```
800804

801805
#### Properties
802-
| Name | Type | Required | Default |
803-
| ---- | ---- | -------- | ------- |
804-
| subreddit | string | yes | |
805-
| style | string | no | vertical-list |
806-
| show-thumbnails | boolean | no | false |
807-
| show-flairs | boolean | no | false |
808-
| limit | integer | no | 15 |
809-
| collapse-after | integer | no | 5 |
806+
| Name | Type | Required | Default |
807+
|-----------------------| ---- | -------- | ------- |
808+
| subreddit | string | yes | |
809+
| style | string | no | vertical-list |
810+
| show-thumbnails | boolean | no | false |
811+
| show-flairs | boolean | no | false |
812+
| limit | integer | no | 15 |
813+
| collapse-after | integer | no | 5 |
810814
| comments-url-template | string | no | https://www.reddit.com/{POST-PATH} |
811-
| request-url-template | string | no | |
812-
| proxy | string or multiple parameters | no | |
813-
| sort-by | string | no | hot |
814-
| top-period | string | no | day |
815-
| search | string | no | |
816-
| extra-sort-by | string | no | |
815+
| request-url-template | string | no | |
816+
| proxy | string or multiple parameters | no | |
817+
| sort-by | string | no | hot |
818+
| top-period | string | no | day |
819+
| search | string | no | |
820+
| extra-sort-by | string | no | |
821+
| reddit-app-name | string | no | |
822+
| reddit-client-id | string | no | |
823+
| reddit-client-secret | string | no | |
817824

818825
##### `subreddit`
819826
The subreddit for which to fetch the posts from.
@@ -921,6 +928,23 @@ Can be used to specify an additional sort which will be applied on top of the al
921928

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

931+
##### `reddit-app-name`, `reddit-client-id`, `reddit-client-secret`
932+
Credentials generated when [registering an app on Reddit](https://ssl.reddit.com/prefs/apps/). Must be set for each Reddit
933+
widget. All three values should be populated otherwise the requests to the Reddit API will not be authenticated and will
934+
be rejected if Glance is self-hosted on a VPS.
935+
936+
Since `reddit-client-id` and `reddit-client-secret` are secrets, it is highly suggested to pass these values in the
937+
configuration by using environment variables instead of storing them as is.
938+
939+
```yaml
940+
widgets:
941+
- type: reddit
942+
subreddit: technology
943+
reddit-app-name: ${REDDIT_APP_NAME} # Values stored in a .env
944+
reddit-client-id: ${REDDIT_APP_CLIENT_ID}
945+
reddit-client-secret: ${REDDIT_APP_SECRET}
946+
```
947+
924948
### Search Widget
925949
Display a search bar that can be used to search for specific terms on various search engines.
926950

Diff for: internal/glance/widget-reddit.go

+109-43
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package glance
22

33
import (
44
"context"
5+
"encoding/base64"
6+
"encoding/json"
57
"errors"
68
"fmt"
79
"html"
810
"html/template"
11+
"io"
912
"net/http"
1013
"net/url"
1114
"strings"
@@ -33,6 +36,17 @@ type redditWidget struct {
3336
Limit int `yaml:"limit"`
3437
CollapseAfter int `yaml:"collapse-after"`
3538
RequestUrlTemplate string `yaml:"request-url-template"`
39+
RedditAppName string `yaml:"reddit-app-name"`
40+
RedditClientID string `yaml:"reddit-client-id"`
41+
RedditClientSecret string `yaml:"reddit-client-secret"`
42+
redditAccessToken string
43+
}
44+
45+
type redditTokenResponse struct {
46+
AccessToken string `json:"access_token"`
47+
TokenType string `json:"token_type"`
48+
Scope string `json:"scope"`
49+
ExpiresIn int `json:"expires_in"`
3650
}
3751

3852
func (widget *redditWidget) initialize() error {
@@ -62,6 +76,10 @@ func (widget *redditWidget) initialize() error {
6276
}
6377
}
6478

79+
if err := widget.fetchRedditAccessToken(); err != nil {
80+
return fmt.Errorf("fetching Reddit API access token: %w", err)
81+
}
82+
6583
widget.
6684
withTitle("r/" + widget.Subreddit).
6785
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
@@ -87,17 +105,7 @@ func isValidRedditTopPeriod(period string) bool {
87105
}
88106

89107
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-
)
108+
posts, err := widget.fetchSubredditPosts()
101109

102110
if !widget.canContinueUpdateAfterHandlingErr(err) {
103111
return
@@ -163,49 +171,55 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str
163171
return template
164172
}
165173

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) {
176-
query := url.Values{}
177-
var requestUrl string
174+
func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
175+
var baseURL string
178176

179-
if search != "" {
180-
query.Set("q", search+" subreddit:"+subreddit)
181-
query.Set("sort", sort)
177+
if widget.redditAccessToken != "" {
178+
baseURL = "https://oauth.reddit.com"
179+
} else {
180+
baseURL = "https://www.reddit.com"
182181
}
183182

184-
if sort == "top" {
185-
query.Set("t", topPeriod)
186-
}
183+
query := url.Values{}
184+
var requestURL string
185+
186+
if widget.Search != "" {
187+
query.Set("q", widget.Search+" subreddit:"+widget.Subreddit)
188+
query.Set("sort", widget.SortBy)
187189

188-
if search != "" {
189-
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
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+
196+
requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode())
192197
}
193198

194199
var client requestDoer = defaultHTTPClient
195200

196-
if requestUrlTemplate != "" {
197-
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl)
198-
} else if proxyClient != nil {
199-
client = proxyClient
201+
if widget.RequestUrlTemplate != "" {
202+
requestURL = strings.ReplaceAll(widget.RequestUrlTemplate, "{REQUEST-URL}", requestURL)
203+
} else if widget.Proxy.client != nil {
204+
client = widget.Proxy.client
200205
}
201206

202-
request, err := http.NewRequest("GET", requestUrl, nil)
207+
request, err := http.NewRequest("GET", requestURL, nil)
203208
if err != nil {
204209
return nil, err
205210
}
206211

207212
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
208-
setBrowserUserAgentHeader(request)
213+
if widget.RedditAppName != "" {
214+
request.Header.Set("User-Agent", fmt.Sprintf("%s/1.0", widget.RedditAppName))
215+
} else {
216+
setBrowserUserAgentHeader(request)
217+
}
218+
219+
if widget.redditAccessToken != "" {
220+
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", widget.redditAccessToken))
221+
}
222+
209223
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
210224
if err != nil {
211225
return nil, err
@@ -226,10 +240,10 @@ func fetchSubredditPosts(
226240

227241
var commentsUrl string
228242

229-
if commentsUrlTemplate == "" {
243+
if widget.CommentsUrlTemplate == "" {
230244
commentsUrl = "https://www.reddit.com" + post.Permalink
231245
} else {
232-
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink)
246+
commentsUrl = templateRedditCommentsURL(widget.CommentsUrlTemplate, widget.Subreddit, post.Id, post.Permalink)
233247
}
234248

235249
forumPost := forumPost{
@@ -249,19 +263,19 @@ func fetchSubredditPosts(
249263
forumPost.TargetUrl = post.Url
250264
}
251265

252-
if showFlairs && post.Flair != "" {
266+
if widget.ShowFlairs && post.Flair != "" {
253267
forumPost.Tags = append(forumPost.Tags, post.Flair)
254268
}
255269

256270
if len(post.ParentList) > 0 {
257271
forumPost.IsCrosspost = true
258272
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
259273

260-
if commentsUrlTemplate == "" {
274+
if widget.CommentsUrlTemplate == "" {
261275
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
262276
} else {
263277
forumPost.TargetUrl = templateRedditCommentsURL(
264-
commentsUrlTemplate,
278+
widget.CommentsUrlTemplate,
265279
post.ParentList[0].Subreddit,
266280
post.ParentList[0].Id,
267281
post.ParentList[0].Permalink,
@@ -274,3 +288,55 @@ func fetchSubredditPosts(
274288

275289
return posts, nil
276290
}
291+
292+
func (widget *redditWidget) fetchRedditAccessToken() (err error) {
293+
// Only execute if all three parameters are set
294+
if widget.RedditAppName == "" || widget.RedditClientID == "" || widget.RedditClientSecret == "" {
295+
return nil
296+
}
297+
298+
auth := base64.StdEncoding.EncodeToString([]byte(widget.RedditClientID + ":" + widget.RedditClientSecret))
299+
300+
data := url.Values{"grant_type": {"client_credentials"}}
301+
302+
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", strings.NewReader(data.Encode()))
303+
if err != nil {
304+
return fmt.Errorf("requesting an access token to the Reddit API: %w", err)
305+
}
306+
307+
req.Header.Add("Authorization", "Basic "+auth)
308+
req.Header.Add("User-Agent", widget.RedditAppName+"/1.0")
309+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
310+
311+
client := &http.Client{
312+
Timeout: time.Second * 10,
313+
}
314+
315+
resp, err := client.Do(req)
316+
if err != nil {
317+
return fmt.Errorf("querying Reddit API: %w", err)
318+
}
319+
320+
defer func() {
321+
err = errors.Join(err, resp.Body.Close())
322+
}()
323+
324+
body, err := io.ReadAll(resp.Body)
325+
if err != nil {
326+
return fmt.Errorf("reading response body: %w", err)
327+
}
328+
329+
if resp.StatusCode != http.StatusOK {
330+
return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
331+
}
332+
333+
var tokenResp redditTokenResponse
334+
err = json.Unmarshal(body, &tokenResp)
335+
if err != nil {
336+
return fmt.Errorf("unmarshalling Reddit API response: %w", err)
337+
}
338+
339+
widget.redditAccessToken = tokenResp.AccessToken
340+
341+
return
342+
}

0 commit comments

Comments
 (0)