From db475e8e884496ae23561dee709005604b045cab Mon Sep 17 00:00:00 2001 From: s0ders <39492740+s0ders@users.noreply.github.com> Date: Fri, 28 Mar 2025 11:20:57 +0100 Subject: [PATCH] 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 --- docs/configuration.md | 54 ++++++--- internal/glance/widget-reddit.go | 200 ++++++++++++++++++++++--------- 2 files changed, 182 insertions(+), 72 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 15ee95ff..d68f45e3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -789,7 +789,11 @@ Display a list of posts from a specific subreddit. > [!WARNING] > -> 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. +> 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 either [register an app on Reddit](https://ssl.reddit.com/prefs/apps/) and use the +> generated ID and secret in the widget configuration to authenticate your requests (see `reddit-app-name`, +> `reddit-client-id` and `reddit-client-secret`) or route the traffic from Glance through a VPN or your own HTTP proxy +> using the `request-url-template` property. Example: @@ -799,21 +803,24 @@ Example: ``` #### Properties -| Name | Type | Required | Default | -| ---- | ---- | -------- | ------- | -| subreddit | string | yes | | -| style | string | no | vertical-list | -| show-thumbnails | boolean | no | false | -| show-flairs | boolean | no | false | -| limit | integer | no | 15 | -| collapse-after | integer | no | 5 | +| Name | Type | Required | Default | +|-----------------------| ---- | -------- | ------- | +| subreddit | string | yes | | +| style | string | no | vertical-list | +| show-thumbnails | boolean | no | false | +| show-flairs | boolean | no | false | +| limit | integer | no | 15 | +| collapse-after | integer | no | 5 | | comments-url-template | string | no | https://www.reddit.com/{POST-PATH} | -| request-url-template | string | no | | -| proxy | string or multiple parameters | no | | -| sort-by | string | no | hot | -| top-period | string | no | day | -| search | string | no | | -| extra-sort-by | string | no | | +| request-url-template | string | no | | +| proxy | string or multiple parameters | no | | +| sort-by | string | no | hot | +| top-period | string | no | day | +| search | string | no | | +| extra-sort-by | string | no | | +| reddit-app-name | string | no | | +| reddit-client-id | string | no | | +| reddit-client-secret | string | no | | ##### `subreddit` 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 The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. +##### `reddit-app-name`, `reddit-client-id`, `reddit-client-secret` +Credentials generated when [registering an app on Reddit](https://ssl.reddit.com/prefs/apps/). Must be set for each Reddit +widget. All three values should be populated otherwise the requests to the Reddit API will not be authenticated and will +be rejected if Glance is self-hosted on a VPS. + +Since `reddit-client-id` and `reddit-client-secret` are secrets, it is highly suggested to pass these values in the +configuration by using environment variables instead of storing them as is. + +```yaml +widgets: + - type: reddit + subreddit: technology + reddit-app-name: ${REDDIT_APP_NAME} # Values stored in a .env + reddit-client-id: ${REDDIT_APP_CLIENT_ID} + reddit-client-secret: ${REDDIT_APP_SECRET} +``` + ### Search Widget Display a search bar that can be used to search for specific terms on various search engines. diff --git a/internal/glance/widget-reddit.go b/internal/glance/widget-reddit.go index e7109fa8..df2ca292 100644 --- a/internal/glance/widget-reddit.go +++ b/internal/glance/widget-reddit.go @@ -2,10 +2,13 @@ package glance import ( "context" + "encoding/base64" + "encoding/json" "errors" "fmt" "html" "html/template" + "io" "net/http" "net/url" "strings" @@ -17,22 +20,36 @@ var ( redditWidgetVerticalCardsTemplate = mustParseTemplate("reddit-vertical-cards.html", "widget-base.html") ) +var ErrAccessTokenMissingParams = errors.New("application name, client ID and client secret are required to get a Reddit access token") + type redditWidget struct { - widgetBase `yaml:",inline"` - Posts forumPostList `yaml:"-"` - Subreddit string `yaml:"subreddit"` - Proxy proxyOptionsField `yaml:"proxy"` - Style string `yaml:"style"` - ShowThumbnails bool `yaml:"show-thumbnails"` - ShowFlairs bool `yaml:"show-flairs"` - SortBy string `yaml:"sort-by"` - TopPeriod string `yaml:"top-period"` - Search string `yaml:"search"` - ExtraSortBy string `yaml:"extra-sort-by"` - CommentsUrlTemplate string `yaml:"comments-url-template"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - RequestUrlTemplate string `yaml:"request-url-template"` + widgetBase `yaml:",inline"` + Posts forumPostList `yaml:"-"` + Subreddit string `yaml:"subreddit"` + Proxy proxyOptionsField `yaml:"proxy"` + Style string `yaml:"style"` + ShowThumbnails bool `yaml:"show-thumbnails"` + ShowFlairs bool `yaml:"show-flairs"` + SortBy string `yaml:"sort-by"` + TopPeriod string `yaml:"top-period"` + Search string `yaml:"search"` + ExtraSortBy string `yaml:"extra-sort-by"` + CommentsUrlTemplate string `yaml:"comments-url-template"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + RequestUrlTemplate string `yaml:"request-url-template"` + RedditAppName string `yaml:"reddit-app-name"` + RedditClientID string `yaml:"reddit-client-id"` + RedditClientSecret string `yaml:"reddit-client-secret"` + redditAccessToken string + redditAccessTokenExpiresAt time.Time +} + +type redditTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + ExpiresIn int `json:"expires_in"` } func (widget *redditWidget) initialize() error { @@ -87,17 +104,7 @@ func isValidRedditTopPeriod(period string) bool { } func (widget *redditWidget) update(ctx context.Context) { - // TODO: refactor, use a struct to pass all of these - posts, err := fetchSubredditPosts( - widget.Subreddit, - widget.SortBy, - widget.TopPeriod, - widget.Search, - widget.CommentsUrlTemplate, - widget.RequestUrlTemplate, - widget.Proxy.client, - widget.ShowFlairs, - ) + posts, err := widget.fetchSubredditPosts() if !widget.canContinueUpdateAfterHandlingErr(err) { return @@ -163,49 +170,60 @@ func templateRedditCommentsURL(template, subreddit, postId, postPath string) str return template } -func fetchSubredditPosts( - subreddit, - sort, - topPeriod, - search, - commentsUrlTemplate, - requestUrlTemplate string, - proxyClient *http.Client, - showFlairs bool, -) (forumPostList, error) { - query := url.Values{} - var requestUrl string +func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { + var baseURL string - if search != "" { - query.Set("q", search+" subreddit:"+subreddit) - query.Set("sort", sort) + accessToken, err := widget.getRedditAccessToken() + if err != nil { + return nil, fmt.Errorf("getting Reddit access token: %w", err) } - if sort == "top" { - query.Set("t", topPeriod) + if accessToken != "" { + baseURL = "https://oauth.reddit.com" + } else { + baseURL = "https://www.reddit.com" } - if search != "" { - requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode()) + query := url.Values{} + var requestURL string + + if widget.Search != "" { + query.Set("q", widget.Search+" subreddit:"+widget.Subreddit) + query.Set("sort", widget.SortBy) + + requestURL = fmt.Sprintf("%s/search.json?%s", baseURL, query.Encode()) } else { - requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode()) + if widget.SortBy == "top" { + query.Set("t", widget.TopPeriod) + } + + requestURL = fmt.Sprintf("%s/r/%s/%s.json?%s", baseURL, widget.Subreddit, widget.SortBy, query.Encode()) } var client requestDoer = defaultHTTPClient - if requestUrlTemplate != "" { - requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", requestUrl) - } else if proxyClient != nil { - client = proxyClient + if widget.RequestUrlTemplate != "" { + requestURL = strings.ReplaceAll(widget.RequestUrlTemplate, "{REQUEST-URL}", requestURL) + } else if widget.Proxy.client != nil { + client = widget.Proxy.client } - request, err := http.NewRequest("GET", requestUrl, nil) + request, err := http.NewRequest("GET", requestURL, nil) if err != nil { return nil, err } // Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests - setBrowserUserAgentHeader(request) + if widget.RedditAppName != "" { + request.Header.Set("User-Agent", fmt.Sprintf("%s/1.0", widget.RedditAppName)) + } else { + setBrowserUserAgentHeader(request) + } + + if widget.redditAccessToken != "" { + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + } + responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request) if err != nil { return nil, err @@ -226,10 +244,10 @@ func fetchSubredditPosts( var commentsUrl string - if commentsUrlTemplate == "" { + if widget.CommentsUrlTemplate == "" { commentsUrl = "https://www.reddit.com" + post.Permalink } else { - commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink) + commentsUrl = templateRedditCommentsURL(widget.CommentsUrlTemplate, widget.Subreddit, post.Id, post.Permalink) } forumPost := forumPost{ @@ -249,7 +267,7 @@ func fetchSubredditPosts( forumPost.TargetUrl = post.Url } - if showFlairs && post.Flair != "" { + if widget.ShowFlairs && post.Flair != "" { forumPost.Tags = append(forumPost.Tags, post.Flair) } @@ -257,11 +275,11 @@ func fetchSubredditPosts( forumPost.IsCrosspost = true forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit - if commentsUrlTemplate == "" { + if widget.CommentsUrlTemplate == "" { forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink } else { forumPost.TargetUrl = templateRedditCommentsURL( - commentsUrlTemplate, + widget.CommentsUrlTemplate, post.ParentList[0].Subreddit, post.ParentList[0].Id, post.ParentList[0].Permalink, @@ -274,3 +292,71 @@ func fetchSubredditPosts( return posts, nil } + +func (widget *redditWidget) queryRedditAPIForAccessToken() (err error) { + if widget.RedditAppName == "" || widget.RedditClientID == "" || widget.RedditClientSecret == "" { + return ErrAccessTokenMissingParams + } + + auth := base64.StdEncoding.EncodeToString([]byte(widget.RedditClientID + ":" + widget.RedditClientSecret)) + + data := url.Values{"grant_type": {"client_credentials"}} + + req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("requesting an access token to the Reddit API: %w", err) + } + + req.Header.Add("Authorization", "Basic "+auth) + req.Header.Add("User-Agent", widget.RedditAppName+"/1.0") + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{ + Timeout: time.Second * 10, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("querying Reddit API: %w", err) + } + + defer func() { + err = errors.Join(err, resp.Body.Close()) + }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp redditTokenResponse + err = json.Unmarshal(body, &tokenResp) + if err != nil { + return fmt.Errorf("unmarshalling Reddit API response: %w", err) + } + + widget.redditAccessToken = tokenResp.AccessToken + widget.redditAccessTokenExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + return +} + +// getRedditAccessToken checks if an unexpired Reddit access token is present, if not, it fetches one and returns it. +func (widget *redditWidget) getRedditAccessToken() (string, error) { + // If parameters to query the Reddit API for an access token are missing, return nothing. + if widget.RedditAppName == "" || widget.RedditClientID == "" || widget.RedditClientSecret == "" { + return "", nil + } + + // Check if the token is still valid in a minute (gives a margin to avoid authentication failure) + if widget.redditAccessToken != "" && time.Now().Add(time.Minute).Before(widget.redditAccessTokenExpiresAt) { + return widget.redditAccessToken, nil + } + + err := widget.queryRedditAPIForAccessToken() + return widget.redditAccessToken, err +}