Skip to content

feat: new optional OAuth2 configuration for the Reddit widget #529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 39 additions & 15 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
200 changes: 143 additions & 57 deletions internal/glance/widget-reddit.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package glance

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"html/template"
"io"
"net/http"
"net/url"
"strings"
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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{
Expand All @@ -249,19 +267,19 @@ func fetchSubredditPosts(
forumPost.TargetUrl = post.Url
}

if showFlairs && post.Flair != "" {
if widget.ShowFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair)
}

if len(post.ParentList) > 0 {
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,
Expand All @@ -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
}