| 
									
										
										
										
											2023-03-12 16:00:57 +01:00
										 |  |  | // GoToSocial | 
					
						
							|  |  |  | // Copyright (C) GoToSocial Authors admin@gotosocial.org | 
					
						
							|  |  |  | // SPDX-License-Identifier: AGPL-3.0-or-later | 
					
						
							|  |  |  | // | 
					
						
							|  |  |  | // This program is free software: you can redistribute it and/or modify | 
					
						
							|  |  |  | // it under the terms of the GNU Affero General Public License as published by | 
					
						
							|  |  |  | // the Free Software Foundation, either version 3 of the License, or | 
					
						
							|  |  |  | // (at your option) any later version. | 
					
						
							|  |  |  | // | 
					
						
							|  |  |  | // This program is distributed in the hope that it will be useful, | 
					
						
							|  |  |  | // but WITHOUT ANY WARRANTY; without even the implied warranty of | 
					
						
							|  |  |  | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | 
					
						
							|  |  |  | // GNU Affero General Public License for more details. | 
					
						
							|  |  |  | // | 
					
						
							|  |  |  | // You should have received a copy of the GNU Affero General Public License | 
					
						
							|  |  |  | // along with this program.  If not, see <http://www.gnu.org/licenses/>. | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | package web | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | import ( | 
					
						
							|  |  |  | 	"bytes" | 
					
						
							|  |  |  | 	"net/http" | 
					
						
							|  |  |  | 	"strings" | 
					
						
							|  |  |  | 	"time" | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-04-26 15:34:10 +02:00
										 |  |  | 	apiutil "code.superseriousbusiness.org/gotosocial/internal/api/util" | 
					
						
							|  |  |  | 	"code.superseriousbusiness.org/gotosocial/internal/gtserror" | 
					
						
							|  |  |  | 	"code.superseriousbusiness.org/gotosocial/internal/log" | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 	"github.com/gin-gonic/gin" | 
					
						
							|  |  |  | ) | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | const appRSSUTF8 = string(apiutil.AppRSSXML) + "; charset=utf-8" | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | func (m *Module) rssFeedGETHandler(c *gin.Context) { | 
					
						
							| 
									
										
										
										
											2023-01-02 13:10:50 +01:00
										 |  |  | 	if _, err := apiutil.NegotiateAccept(c, apiutil.AppRSSXML); err != nil { | 
					
						
							| 
									
										
										
										
											2023-05-12 08:16:41 +00:00
										 |  |  | 		apiutil.WebErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 		return | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// Fetch + normalize username from URL. | 
					
						
							| 
									
										
										
										
											2024-04-13 13:25:10 +02:00
										 |  |  | 	username, errWithCode := apiutil.ParseUsername(c.Param(apiutil.UsernameKey)) | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	if errWithCode != nil { | 
					
						
							|  |  |  | 		apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 		return | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// Usernames on our instance will always be lowercase. | 
					
						
							|  |  |  | 	// | 
					
						
							| 
									
										
										
										
											2025-04-27 13:36:27 +02:00
										 |  |  | 	// todo: https://codeberg.org/superseriousbusiness/gotosocial/issues/1813 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	username = strings.ToLower(username) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// Retrieve the getRSSFeed function from the processor. | 
					
						
							|  |  |  | 	// We'll only call the function if we need to, to save db calls. | 
					
						
							|  |  |  | 	// lastPostAt may be a zero time if account has never posted. | 
					
						
							|  |  |  | 	getRSSFeed, lastPostAt, errWithCode := m.processor.Account().GetRSSFeedForUsername(c.Request.Context(), username) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 	if errWithCode != nil { | 
					
						
							| 
									
										
										
										
											2023-05-12 08:16:41 +00:00
										 |  |  | 		apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 		return | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	var ( | 
					
						
							|  |  |  | 		rssFeed string // Stringified rss feed. | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		cacheKey              = c.Request.URL.Path | 
					
						
							|  |  |  | 		cacheEntry, wasCached = m.eTagCache.Get(cacheKey) | 
					
						
							|  |  |  | 	) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	if !wasCached || unixAfter(lastPostAt, cacheEntry.lastModified) { | 
					
						
							|  |  |  | 		// We either have no ETag cache entry for this account's feed, | 
					
						
							|  |  |  | 		// or we have an expired cache entry (account has posted since | 
					
						
							|  |  |  | 		// the cache entry was last generated). | 
					
						
							|  |  |  | 		// | 
					
						
							|  |  |  | 		// As such, we need to generate a new ETag, and for that we need | 
					
						
							|  |  |  | 		// the string representation of the RSS feed. | 
					
						
							|  |  |  | 		rssFeed, errWithCode = getRSSFeed() | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 		if errWithCode != nil { | 
					
						
							| 
									
										
										
										
											2023-05-12 08:16:41 +00:00
										 |  |  | 			apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 			return | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		eTag, err := generateEtag(bytes.NewBufferString(rssFeed)) | 
					
						
							|  |  |  | 		if err != nil { | 
					
						
							| 
									
										
										
										
											2023-05-12 08:16:41 +00:00
										 |  |  | 			apiutil.WebErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 			return | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 		// We never want lastModified to be zero, so if account | 
					
						
							|  |  |  | 		// has never actually posted anything, just use Now as | 
					
						
							|  |  |  | 		// the lastModified time instead for cache control. | 
					
						
							|  |  |  | 		var lastModified time.Time | 
					
						
							|  |  |  | 		if lastPostAt.IsZero() { | 
					
						
							|  |  |  | 			lastModified = time.Now() | 
					
						
							|  |  |  | 		} else { | 
					
						
							|  |  |  | 			lastModified = lastPostAt | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 		// Store the new cache entry. | 
					
						
							|  |  |  | 		cacheEntry = eTagCacheEntry{ | 
					
						
							|  |  |  | 			eTag:         eTag, | 
					
						
							|  |  |  | 			lastModified: lastModified, | 
					
						
							|  |  |  | 		} | 
					
						
							| 
									
										
										
										
											2022-11-15 18:45:15 +00:00
										 |  |  | 		m.eTagCache.Set(cacheKey, cacheEntry) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// Set 'ETag' and 'Last-Modified' headers no matter what; | 
					
						
							|  |  |  | 	// even if we return 304 in the next checks, caller may | 
					
						
							|  |  |  | 	// want to cache these header values. | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 	c.Header(eTagHeader, cacheEntry.eTag) | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	c.Header(lastModifiedHeader, cacheEntry.lastModified.Format(http.TimeFormat)) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	// Instruct caller to validate the response with us before | 
					
						
							|  |  |  | 	// each reuse, so that the 'ETag' and 'Last-Modified' headers | 
					
						
							|  |  |  | 	// actually take effect. | 
					
						
							|  |  |  | 	// | 
					
						
							|  |  |  | 	// "The no-cache response directive indicates that the response | 
					
						
							|  |  |  | 	// can be stored in caches, but the response must be validated | 
					
						
							|  |  |  | 	// with the origin server before each reuse, even when the cache | 
					
						
							|  |  |  | 	// is disconnected from the origin server." | 
					
						
							|  |  |  | 	// | 
					
						
							|  |  |  | 	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control | 
					
						
							|  |  |  | 	c.Header(cacheControlHeader, cacheControlNoCache) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// Check if caller submitted an ETag via 'If-None-Match'. | 
					
						
							|  |  |  | 	// If they did + it matches what we have, that means they've | 
					
						
							|  |  |  | 	// already seen the latest version of this feed, so just bail. | 
					
						
							|  |  |  | 	ifNoneMatch := c.Request.Header.Get(ifNoneMatchHeader) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 	if ifNoneMatch == cacheEntry.eTag { | 
					
						
							|  |  |  | 		c.AbortWithStatus(http.StatusNotModified) | 
					
						
							|  |  |  | 		return | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// Check if the caller submitted a time via 'If-Modified-Since'. | 
					
						
							|  |  |  | 	// If they did, and our cached ETag entry is not newer than the | 
					
						
							|  |  |  | 	// given time, this means the caller has already seen the latest | 
					
						
							|  |  |  | 	// version of this feed, so just bail. | 
					
						
							|  |  |  | 	ifModifiedSince := extractIfModifiedSince(c.Request) | 
					
						
							|  |  |  | 	if !ifModifiedSince.IsZero() && | 
					
						
							|  |  |  | 		!unixAfter(cacheEntry.lastModified, ifModifiedSince) { | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 		c.AbortWithStatus(http.StatusNotModified) | 
					
						
							|  |  |  | 		return | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 	// At this point we know that the client wants the newest | 
					
						
							|  |  |  | 	// representation of the RSS feed, either because they didn't | 
					
						
							|  |  |  | 	// submit any 'If-None-Match' / 'If-Modified-Since' cache headers, | 
					
						
							|  |  |  | 	// or because they did but the account has posted more recently | 
					
						
							|  |  |  | 	// than the values of the submitted headers would suggest. | 
					
						
							|  |  |  | 	// | 
					
						
							|  |  |  | 	// If we had a cache hit earlier, we may not have called the | 
					
						
							|  |  |  | 	// getRSSFeed function yet; if that's the case then do call it | 
					
						
							|  |  |  | 	// now because we definitely need it. | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 	if rssFeed == "" { | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 		rssFeed, errWithCode = getRSSFeed() | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 		if errWithCode != nil { | 
					
						
							| 
									
										
										
										
											2023-05-12 08:16:41 +00:00
										 |  |  | 			apiutil.WebErrorHandler(c, errWithCode, m.processor.InstanceGetV1) | 
					
						
							| 
									
										
										
										
											2022-10-08 14:00:39 +02:00
										 |  |  | 			return | 
					
						
							|  |  |  | 		} | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	c.Data(http.StatusOK, appRSSUTF8, []byte(rssFeed)) | 
					
						
							|  |  |  | } | 
					
						
							| 
									
										
										
										
											2023-07-10 17:05:59 +02:00
										 |  |  | 
 | 
					
						
							|  |  |  | // unixAfter returns true if the unix value of t1 | 
					
						
							|  |  |  | // is greater than (ie., after) the unix value of t2. | 
					
						
							|  |  |  | func unixAfter(t1 time.Time, t2 time.Time) bool { | 
					
						
							|  |  |  | 	if t1.IsZero() { | 
					
						
							|  |  |  | 		// if t1 is zero then it cannot | 
					
						
							|  |  |  | 		// possibly be greater than t2. | 
					
						
							|  |  |  | 		return false | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	if t2.IsZero() { | 
					
						
							|  |  |  | 		// t1 is not zero but t2 is, | 
					
						
							|  |  |  | 		// so t1 is necessarily greater. | 
					
						
							|  |  |  | 		return true | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	return t1.Unix() > t2.Unix() | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // extractIfModifiedSince parses a time.Time from the | 
					
						
							|  |  |  | // 'If-Modified-Since' header of the given request. | 
					
						
							|  |  |  | // | 
					
						
							|  |  |  | // If no time was provided, or the provided time was | 
					
						
							|  |  |  | // not parseable, it will return a zero time. | 
					
						
							|  |  |  | func extractIfModifiedSince(r *http.Request) time.Time { | 
					
						
							|  |  |  | 	imsStr := r.Header.Get(ifModifiedSinceHeader) | 
					
						
							|  |  |  | 	if imsStr == "" { | 
					
						
							|  |  |  | 		return time.Time{} // Nothing set. | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	ifModifiedSince, err := http.ParseTime(imsStr) | 
					
						
							|  |  |  | 	if err != nil { | 
					
						
							|  |  |  | 		log.Errorf(r.Context(), "couldn't parse %s value '%s' as time: %q", ifModifiedSinceHeader, imsStr, err) | 
					
						
							|  |  |  | 		return time.Time{} | 
					
						
							|  |  |  | 	} | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 	return ifModifiedSince | 
					
						
							|  |  |  | } |