Add reddit app auth #529

Co-authored-by: s0ders <39492740+s0ders@users.noreply.github.com>
This commit is contained in:
Svilen Markov
2025-04-28 18:37:14 +01:00
parent 65adf9b9c3
commit d7a17aab01
3 changed files with 128 additions and 80 deletions

View File

@@ -843,7 +843,10 @@ Display a list of posts from a specific subreddit.
> [!WARNING] > [!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 `app-auth` property), use a proxy
> (see `proxy` property) or route the traffic from Glance through a VPN.
Example: Example:
@@ -868,6 +871,7 @@ Example:
| top-period | string | no | day | | top-period | string | no | day |
| search | string | no | | | search | string | no | |
| extra-sort-by | string | no | | | extra-sort-by | string | no | |
| app-auth | object | no | |
##### `subreddit` ##### `subreddit`
The subreddit for which to fetch the posts from. 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
The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts. The `engagement` sort tries to place the posts with the most points and comments on top, also prioritizing recent over old posts.
##### `app-auth`
```yaml
widgets:
- type: reddit
subreddit: technology
app-auth:
name: ${REDDIT_APP_NAME}
id: ${REDDIT_APP_CLIENT_ID}
secret: ${REDDIT_APP_SECRET}
```
### Search Widget ### Search Widget
Display a search bar that can be used to search for specific terms on various search engines. Display a search bar that can be used to search for specific terms on various search engines.

View File

@@ -29,10 +29,20 @@ type redditWidget struct {
TopPeriod string `yaml:"top-period"` TopPeriod string `yaml:"top-period"`
Search string `yaml:"search"` Search string `yaml:"search"`
ExtraSortBy string `yaml:"extra-sort-by"` ExtraSortBy string `yaml:"extra-sort-by"`
CommentsUrlTemplate string `yaml:"comments-url-template"` CommentsURLTemplate string `yaml:"comments-url-template"`
Limit int `yaml:"limit"` Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
RequestUrlTemplate string `yaml:"request-url-template"` RequestURLTemplate string `yaml:"request-url-template"`
AppAuth struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Secret string `yaml:"secret"`
enabled bool
accessToken string
tokenExpiresAt time.Time
} `yaml:"app-auth"`
} }
func (widget *redditWidget) initialize() error { func (widget *redditWidget) initialize() error {
@@ -48,20 +58,30 @@ func (widget *redditWidget) initialize() error {
widget.CollapseAfter = 5 widget.CollapseAfter = 5
} }
if !isValidRedditSortType(widget.SortBy) { s := widget.SortBy
if s != "hot" && s != "new" && s != "top" && s != "rising" {
widget.SortBy = "hot" widget.SortBy = "hot"
} }
if !isValidRedditTopPeriod(widget.TopPeriod) { p := widget.TopPeriod
if p != "hour" && p != "day" && p != "week" && p != "month" && p != "year" && p != "all" {
widget.TopPeriod = "day" widget.TopPeriod = "day"
} }
if widget.RequestUrlTemplate != "" { if widget.RequestURLTemplate != "" {
if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") {
return errors.New("no `{REQUEST-URL}` placeholder specified") return errors.New("no `{REQUEST-URL}` placeholder specified")
} }
} }
a := &widget.AppAuth
if a.Name != "" || a.ID != "" || a.Secret != "" {
if a.Name == "" || a.ID == "" || a.Secret == "" {
return errors.New("application name, client ID and client secret are required")
}
a.enabled = true
}
widget. widget.
withTitle("r/" + widget.Subreddit). withTitle("r/" + widget.Subreddit).
withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/").
@@ -70,35 +90,8 @@ func (widget *redditWidget) initialize() error {
return nil return nil
} }
func isValidRedditSortType(sortBy string) bool {
return sortBy == "hot" ||
sortBy == "new" ||
sortBy == "top" ||
sortBy == "rising"
}
func isValidRedditTopPeriod(period string) bool {
return period == "hour" ||
period == "day" ||
period == "week" ||
period == "month" ||
period == "year" ||
period == "all"
}
func (widget *redditWidget) update(ctx context.Context) { func (widget *redditWidget) update(ctx context.Context) {
// TODO: refactor, use a struct to pass all of these posts, err := widget.fetchSubredditPosts()
posts, err := fetchSubredditPosts(
widget.Subreddit,
widget.SortBy,
widget.TopPeriod,
widget.Search,
widget.CommentsUrlTemplate,
widget.RequestUrlTemplate,
widget.Proxy.client,
widget.ShowFlairs,
)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
} }
@@ -155,57 +148,65 @@ type subredditResponseJson struct {
} `json:"data"` } `json:"data"`
} }
func templateRedditCommentsURL(template, subreddit, postId, postPath string) string { func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string {
template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit) template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit)
template = strings.ReplaceAll(template, "{POST-ID}", postId) template = strings.ReplaceAll(template, "{POST-ID}", postId)
template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/")) template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/"))
return template return template
} }
func fetchSubredditPosts( func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) {
subreddit,
sort,
topPeriod,
search,
commentsUrlTemplate,
requestUrlTemplate string,
proxyClient *http.Client,
showFlairs bool,
) (forumPostList, error) {
query := url.Values{}
var requestUrl string
if search != "" {
query.Set("q", search+" subreddit:"+subreddit)
query.Set("sort", sort)
}
if sort == "top" {
query.Set("t", topPeriod)
}
if search != "" {
requestUrl = fmt.Sprintf("https://www.reddit.com/search.json?%s", query.Encode())
} else {
requestUrl = fmt.Sprintf("https://www.reddit.com/r/%s/%s.json?%s", subreddit, sort, query.Encode())
}
var client requestDoer = defaultHTTPClient var client requestDoer = defaultHTTPClient
var baseURL string
var requestURL string
var headers http.Header
query := url.Values{}
app := &widget.AppAuth
if requestUrlTemplate != "" { if !app.enabled {
requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", url.QueryEscape(requestUrl)) baseURL = "https://www.reddit.com"
} else if proxyClient != nil { headers = http.Header{
client = proxyClient "User-Agent": []string{getBrowserUserAgentHeader()},
}
} else {
baseURL = "https://oauth.reddit.com"
if app.accessToken == "" || time.Now().Add(time.Minute).After(app.tokenExpiresAt) {
if err := widget.fetchNewAppAccessToken(); err != nil {
return nil, fmt.Errorf("fetching new app access token: %v", err)
}
}
headers = http.Header{
"Authorization": []string{"Bearer " + app.accessToken},
"User-Agent": []string{app.Name + "/1.0"},
}
} }
request, err := http.NewRequest("GET", requestUrl, nil) 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 {
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())
}
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)
if err != nil { if err != nil {
return nil, err return nil, err
} }
request.Header = headers
// Required to increase rate limit, otherwise Reddit randomly returns 429 even after just 2 requests
setBrowserUserAgentHeader(request)
responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request) responseJson, err := decodeJsonFromRequest[subredditResponseJson](client, request)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -226,10 +227,10 @@ func fetchSubredditPosts(
var commentsUrl string var commentsUrl string
if commentsUrlTemplate == "" { if widget.CommentsURLTemplate == "" {
commentsUrl = "https://www.reddit.com" + post.Permalink commentsUrl = "https://www.reddit.com" + post.Permalink
} else { } else {
commentsUrl = templateRedditCommentsURL(commentsUrlTemplate, subreddit, post.Id, post.Permalink) commentsUrl = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink)
} }
forumPost := forumPost{ forumPost := forumPost{
@@ -249,7 +250,7 @@ func fetchSubredditPosts(
forumPost.TargetUrl = post.Url forumPost.TargetUrl = post.Url
} }
if showFlairs && post.Flair != "" { if widget.ShowFlairs && post.Flair != "" {
forumPost.Tags = append(forumPost.Tags, post.Flair) forumPost.Tags = append(forumPost.Tags, post.Flair)
} }
@@ -257,11 +258,10 @@ func fetchSubredditPosts(
forumPost.IsCrosspost = true forumPost.IsCrosspost = true
forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit forumPost.TargetUrlDomain = "r/" + post.ParentList[0].Subreddit
if commentsUrlTemplate == "" { if widget.CommentsURLTemplate == "" {
forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink forumPost.TargetUrl = "https://www.reddit.com" + post.ParentList[0].Permalink
} else { } else {
forumPost.TargetUrl = templateRedditCommentsURL( forumPost.TargetUrl = widget.parseCustomCommentsURL(
commentsUrlTemplate,
post.ParentList[0].Subreddit, post.ParentList[0].Subreddit,
post.ParentList[0].Id, post.ParentList[0].Id,
post.ParentList[0].Permalink, post.ParentList[0].Permalink,
@@ -274,3 +274,32 @@ func fetchSubredditPosts(
return posts, nil return posts, nil
} }
func (widget *redditWidget) fetchNewAppAccessToken() (err error) {
body := strings.NewReader("grant_type=client_credentials")
req, err := http.NewRequest("POST", "https://www.reddit.com/api/v1/access_token", body)
if err != nil {
return fmt.Errorf("creating request for app access token: %v", err)
}
app := &widget.AppAuth
req.SetBasicAuth(app.ID, app.Secret)
req.Header.Add("User-Agent", app.Name+"/1.0")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
client := ternary(widget.Proxy.client != nil, widget.Proxy.client, defaultHTTPClient)
response, err := decodeJsonFromRequest[tokenResponse](client, req)
if err != nil {
return fmt.Errorf("decoding Reddit API response: %v", err)
}
app.accessToken = response.AccessToken
app.tokenExpiresAt = time.Now().Add(time.Duration(response.ExpiresIn) * time.Second)
return nil
}

View File

@@ -40,13 +40,17 @@ type requestDoer interface {
var userAgentPersistentVersion atomic.Int32 var userAgentPersistentVersion atomic.Int32
func setBrowserUserAgentHeader(request *http.Request) { func getBrowserUserAgentHeader() string {
if rand.IntN(2000) == 0 { if rand.IntN(2000) == 0 {
userAgentPersistentVersion.Store(rand.Int32N(5)) userAgentPersistentVersion.Store(rand.Int32N(5))
} }
version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load())) version := strconv.Itoa(130 + int(userAgentPersistentVersion.Load()))
request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:"+version+".0) Gecko/20100101 Firefox/"+version+".0") return "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:" + version + ".0) Gecko/20100101 Firefox/" + version + ".0"
}
func setBrowserUserAgentHeader(request *http.Request) {
request.Header.Set("User-Agent", getBrowserUserAgentHeader())
} }
func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) { func decodeJsonFromRequest[T any](client requestDoer, request *http.Request) (T, error) {