rss.go (4834B)
1 // GoToSocial 2 // Copyright (C) GoToSocial Authors admin@gotosocial.org 3 // SPDX-License-Identifier: AGPL-3.0-or-later 4 // 5 // This program is free software: you can redistribute it and/or modify 6 // it under the terms of the GNU Affero General Public License as published by 7 // the Free Software Foundation, either version 3 of the License, or 8 // (at your option) any later version. 9 // 10 // This program is distributed in the hope that it will be useful, 11 // but WITHOUT ANY WARRANTY; without even the implied warranty of 12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 // GNU Affero General Public License for more details. 14 // 15 // You should have received a copy of the GNU Affero General Public License 16 // along with this program. If not, see <http://www.gnu.org/licenses/>. 17 18 package web 19 20 import ( 21 "bytes" 22 "errors" 23 "fmt" 24 "net/http" 25 "strings" 26 "time" 27 28 "github.com/gin-gonic/gin" 29 apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" 30 "github.com/superseriousbusiness/gotosocial/internal/gtserror" 31 "github.com/superseriousbusiness/gotosocial/internal/log" 32 ) 33 34 const appRSSUTF8 = string(apiutil.AppRSSXML + "; charset=utf-8") 35 36 func (m *Module) GetRSSETag(urlPath string, lastModified time.Time, getRSSFeed func() (string, gtserror.WithCode)) (string, error) { 37 if cachedETag, ok := m.eTagCache.Get(urlPath); ok && !lastModified.After(cachedETag.lastModified) { 38 // only return our cached etag if the file wasn't 39 // modified since last time, otherwise generate a 40 // new one; eat fresh! 41 return cachedETag.eTag, nil 42 } 43 44 rssFeed, errWithCode := getRSSFeed() 45 if errWithCode != nil { 46 return "", fmt.Errorf("error getting rss feed: %s", errWithCode) 47 } 48 49 eTag, err := generateEtag(bytes.NewReader([]byte(rssFeed))) 50 if err != nil { 51 return "", fmt.Errorf("error generating etag: %s", err) 52 } 53 54 // put new entry in cache before we return 55 m.eTagCache.Set(urlPath, eTagCacheEntry{ 56 eTag: eTag, 57 lastModified: lastModified, 58 }) 59 60 return eTag, nil 61 } 62 63 func extractIfModifiedSince(r *http.Request) time.Time { 64 hdr := r.Header.Get(ifModifiedSinceHeader) 65 66 if hdr == "" { 67 return time.Time{} 68 } 69 70 t, err := http.ParseTime(hdr) 71 if err != nil { 72 log.Errorf(r.Context(), "couldn't parse if-modified-since %s: %s", hdr, err) 73 return time.Time{} 74 } 75 76 return t 77 } 78 79 func (m *Module) rssFeedGETHandler(c *gin.Context) { 80 // set this Cache-Control header to instruct clients to validate the response with us 81 // before each reuse (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) 82 c.Header(cacheControlHeader, cacheControlNoCache) 83 ctx := c.Request.Context() 84 85 if _, err := apiutil.NegotiateAccept(c, apiutil.AppRSSXML); err != nil { 86 apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) 87 return 88 } 89 90 // usernames on our instance will always be lowercase 91 username := strings.ToLower(c.Param(usernameKey)) 92 if username == "" { 93 err := errors.New("no account username specified") 94 apiutil.WebErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) 95 return 96 } 97 98 ifNoneMatch := c.Request.Header.Get(ifNoneMatchHeader) 99 ifModifiedSince := extractIfModifiedSince(c.Request) 100 101 getRssFeed, accountLastPostedPublic, errWithCode := m.processor.Account().GetRSSFeedForUsername(ctx, username) 102 if errWithCode != nil { 103 apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) 104 return 105 } 106 107 var rssFeed string 108 cacheKey := c.Request.URL.Path 109 cacheEntry, ok := m.eTagCache.Get(cacheKey) 110 111 if !ok || cacheEntry.lastModified.Before(accountLastPostedPublic) { 112 // we either have no cache entry for this, or we have an expired cache entry; generate a new one 113 rssFeed, errWithCode = getRssFeed() 114 if errWithCode != nil { 115 apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) 116 return 117 } 118 119 eTag, err := generateEtag(bytes.NewBufferString(rssFeed)) 120 if err != nil { 121 apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) 122 return 123 } 124 125 cacheEntry.lastModified = accountLastPostedPublic 126 cacheEntry.eTag = eTag 127 m.eTagCache.Set(cacheKey, cacheEntry) 128 } 129 130 c.Header(eTagHeader, cacheEntry.eTag) 131 c.Header(lastModifiedHeader, accountLastPostedPublic.Format(http.TimeFormat)) 132 133 if ifNoneMatch == cacheEntry.eTag { 134 c.AbortWithStatus(http.StatusNotModified) 135 return 136 } 137 138 lmUnix := cacheEntry.lastModified.Unix() 139 imsUnix := ifModifiedSince.Unix() 140 if lmUnix <= imsUnix { 141 c.AbortWithStatus(http.StatusNotModified) 142 return 143 } 144 145 if rssFeed == "" { 146 // we had a cache entry already so we didn't call to get the rss feed yet 147 rssFeed, errWithCode = getRssFeed() 148 if errWithCode != nil { 149 apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) 150 return 151 } 152 } 153 154 c.Data(http.StatusOK, appRSSUTF8, []byte(rssFeed)) 155 }