diff --git a/docs/configuration.md b/docs/configuration.md index cb7b835..2208891 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -843,7 +843,10 @@ 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 `app-auth` property), use a proxy +> (see `proxy` property) or route the traffic from Glance through a VPN. Example: @@ -868,6 +871,7 @@ Example: | top-period | string | no | day | | search | string | no | | | extra-sort-by | string | no | | +| app-auth | object | no | | ##### `subreddit` 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. +##### `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 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 86832b5..7e8c160 100644 --- a/internal/glance/widget-reddit.go +++ b/internal/glance/widget-reddit.go @@ -29,10 +29,20 @@ type redditWidget struct { TopPeriod string `yaml:"top-period"` Search string `yaml:"search"` ExtraSortBy string `yaml:"extra-sort-by"` - CommentsUrlTemplate string `yaml:"comments-url-template"` + CommentsURLTemplate string `yaml:"comments-url-template"` Limit int `yaml:"limit"` 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 { @@ -48,20 +58,30 @@ func (widget *redditWidget) initialize() error { widget.CollapseAfter = 5 } - if !isValidRedditSortType(widget.SortBy) { + s := widget.SortBy + if s != "hot" && s != "new" && s != "top" && s != "rising" { 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" } - if widget.RequestUrlTemplate != "" { - if !strings.Contains(widget.RequestUrlTemplate, "{REQUEST-URL}") { + if widget.RequestURLTemplate != "" { + if !strings.Contains(widget.RequestURLTemplate, "{REQUEST-URL}") { 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. withTitle("r/" + widget.Subreddit). withTitleURL("https://www.reddit.com/r/" + widget.Subreddit + "/"). @@ -70,35 +90,8 @@ func (widget *redditWidget) initialize() error { 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) { - // 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 } @@ -155,57 +148,65 @@ type subredditResponseJson struct { } `json:"data"` } -func templateRedditCommentsURL(template, subreddit, postId, postPath string) string { - template = strings.ReplaceAll(template, "{SUBREDDIT}", subreddit) +func (widget *redditWidget) parseCustomCommentsURL(subreddit, postId, postPath string) string { + template := strings.ReplaceAll(widget.CommentsURLTemplate, "{SUBREDDIT}", subreddit) template = strings.ReplaceAll(template, "{POST-ID}", postId) template = strings.ReplaceAll(template, "{POST-PATH}", strings.TrimLeft(postPath, "/")) return template } -func fetchSubredditPosts( - 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()) - } - +func (widget *redditWidget) fetchSubredditPosts() (forumPostList, error) { var client requestDoer = defaultHTTPClient + var baseURL string + var requestURL string + var headers http.Header + query := url.Values{} + app := &widget.AppAuth - if requestUrlTemplate != "" { - requestUrl = strings.ReplaceAll(requestUrlTemplate, "{REQUEST-URL}", url.QueryEscape(requestUrl)) - } else if proxyClient != nil { - client = proxyClient + if !app.enabled { + baseURL = "https://www.reddit.com" + headers = http.Header{ + "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 { 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) if err != nil { return nil, err @@ -226,10 +227,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 = widget.parseCustomCommentsURL(widget.Subreddit, post.Id, post.Permalink) } forumPost := forumPost{ @@ -249,7 +250,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 +258,10 @@ 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, + forumPost.TargetUrl = widget.parseCustomCommentsURL( post.ParentList[0].Subreddit, post.ParentList[0].Id, post.ParentList[0].Permalink, @@ -274,3 +274,32 @@ func fetchSubredditPosts( 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 +} diff --git a/internal/glance/widget-utils.go b/internal/glance/widget-utils.go index c6b7745..b756d63 100644 --- a/internal/glance/widget-utils.go +++ b/internal/glance/widget-utils.go @@ -40,13 +40,17 @@ type requestDoer interface { var userAgentPersistentVersion atomic.Int32 -func setBrowserUserAgentHeader(request *http.Request) { +func getBrowserUserAgentHeader() string { if rand.IntN(2000) == 0 { userAgentPersistentVersion.Store(rand.Int32N(5)) } 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) {