mirror of
https://github.com/Xevion/glance.git
synced 2025-12-06 03:15:13 -06:00
Add reddit app auth #529
Co-authored-by: s0ders <39492740+s0ders@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user