summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/api/client/account/accountupdate.go8
-rw-r--r--internal/api/client/instance/instancepatch_test.go8
-rw-r--r--internal/api/mime.go1
-rw-r--r--internal/api/model/account.go4
-rw-r--r--internal/cache/account.go1
-rw-r--r--internal/db/account.go4
-rw-r--r--internal/db/bundb/account.go18
-rw-r--r--internal/db/bundb/account_test.go8
-rw-r--r--internal/db/bundb/migrations/20221006114842_add_rss_functionality.go46
-rw-r--r--internal/gtsmodel/account.go1
-rw-r--r--internal/processing/account.go5
-rw-r--r--internal/processing/account/account.go3
-rw-r--r--internal/processing/account/getrss.go108
-rw-r--r--internal/processing/account/getrss_test.go61
-rw-r--r--internal/processing/account/update.go4
-rw-r--r--internal/processing/processor.go5
-rw-r--r--internal/router/template.go41
-rw-r--r--internal/text/emojify.go67
-rw-r--r--internal/typeutils/astointernal.go4
-rw-r--r--internal/typeutils/converter.go7
-rw-r--r--internal/typeutils/internaltofrontend.go3
-rw-r--r--internal/typeutils/internaltofrontend_test.go10
-rw-r--r--internal/typeutils/internaltorss.go177
-rw-r--r--internal/typeutils/internaltorss_test.go86
-rw-r--r--internal/web/assets.go86
-rw-r--r--internal/web/assetscache.go138
-rw-r--r--internal/web/customcss.go6
-rw-r--r--internal/web/etag.go61
-rw-r--r--internal/web/profile.go6
-rw-r--r--internal/web/rss.go154
-rw-r--r--internal/web/web.go23
31 files changed, 948 insertions, 206 deletions
diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go
index e2b2a731c..f89259a96 100644
--- a/internal/api/client/account/accountupdate.go
+++ b/internal/api/client/account/accountupdate.go
@@ -110,6 +110,11 @@ import (
// Custom CSS to use when rendering this account's profile or statuses.
// String must be no more than 5,000 characters (~5kb).
// type: string
+// -
+// name: enable_rss
+// in: formData
+// description: Enable RSS feed for this account's Public posts at `/[username]/feed.rss`
+// type: boolean
//
// security:
// - OAuth2 Bearer:
@@ -202,7 +207,8 @@ func parseUpdateAccountForm(c *gin.Context) (*model.UpdateCredentialsRequest, er
form.Source.Language == nil &&
form.Source.StatusFormat == nil &&
form.FieldsAttributes == nil &&
- form.CustomCSS == nil) {
+ form.CustomCSS == nil &&
+ form.EnableRSS == nil) {
return nil, errors.New("empty form submitted")
}
diff --git a/internal/api/client/instance/instancepatch_test.go b/internal/api/client/instance/instancepatch_test.go
index ade5cd406..50b19c079 100644
--- a/internal/api/client/instance/instancepatch_test.go
+++ b/internal/api/client/instance/instancepatch_test.go
@@ -63,7 +63,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() {
b, err := io.ReadAll(result.Body)
suite.NoError(err)
- suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Example Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"someone@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b))
+ suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Example Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"someone@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"max_toot_chars":5000}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch2() {
@@ -93,7 +93,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() {
b, err := io.ReadAll(result.Body)
suite.NoError(err)
- suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Geoff's Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b))
+ suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"Geoff's Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"max_toot_chars":5000}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch3() {
@@ -123,7 +123,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() {
b, err := io.ReadAll(result.Body)
suite.NoError(err)
- suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b))
+ suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is some html, which is \u003cem\u003eallowed\u003c/em\u003e in short descriptions.\u003c/p\u003e","email":"admin@example.org","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"max_toot_chars":5000}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch4() {
@@ -214,7 +214,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() {
b, err := io.ReadAll(result.Body)
suite.NoError(err)
- suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"max_toot_chars":5000}`, string(b))
+ suite.Equal(`{"uri":"http://localhost:8080","account_domain":"localhost:8080","title":"GoToSocial Testrig Instance","description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","short_description":"\u003cp\u003eThis is the GoToSocial testrig. It doesn't federate or anything.\u003c/p\u003e\u003cp\u003eWhen the testrig is shut down, all data on it will be deleted.\u003c/p\u003e\u003cp\u003eDon't use this in production!\u003c/p\u003e","email":"","version":"0.0.0-testrig","registrations":true,"approval_required":true,"invites_enabled":false,"configuration":{"statuses":{"max_characters":5000,"max_media_attachments":6,"characters_reserved_per_url":25},"media_attachments":{"supported_mime_types":["image/jpeg","image/gif","image/png"],"image_size_limit":10485760,"image_matrix_limit":16777216,"video_size_limit":41943040,"video_frame_rate_limit":60,"video_matrix_limit":16777216},"polls":{"max_options":6,"max_characters_per_option":50,"min_expiration":300,"max_expiration":2629746},"accounts":{"allow_custom_css":true},"emojis":{"emoji_size_limit":51200}},"urls":{"streaming_api":"wss://localhost:8080"},"stats":{"domain_count":2,"status_count":16,"user_count":4},"thumbnail":"http://localhost:8080/assets/logo.png","contact_account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"max_toot_chars":5000}`, string(b))
}
func (suite *InstancePatchTestSuite) TestInstancePatch7() {
diff --git a/internal/api/mime.go b/internal/api/mime.go
index b495b059b..d02b64ab0 100644
--- a/internal/api/mime.go
+++ b/internal/api/mime.go
@@ -25,6 +25,7 @@ type MIME string
const (
AppJSON MIME = `application/json`
AppXML MIME = `application/xml`
+ AppRSSXML MIME = `application/rss+xml`
AppActivityJSON MIME = `application/activity+json`
AppActivityLDJSON MIME = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
AppForm MIME = `application/x-www-form-urlencoded`
diff --git a/internal/api/model/account.go b/internal/api/model/account.go
index b085e84e7..f8bb4f4a0 100644
--- a/internal/api/model/account.go
+++ b/internal/api/model/account.go
@@ -92,6 +92,8 @@ type Account struct {
Source *Source `json:"source,omitempty"`
// CustomCSS to include when rendering this account's profile or statuses.
CustomCSS string `json:"custom_css,omitempty"`
+ // Account has enabled RSS feed.
+ EnableRSS bool `json:"enable_rss,omitempty"`
}
// AccountCreateRequest models account creation parameters.
@@ -155,6 +157,8 @@ type UpdateCredentialsRequest struct {
FieldsAttributes *[]UpdateField `form:"fields_attributes" json:"fields_attributes" xml:"fields_attributes"`
// Custom CSS to be included when rendering this account's profile or statuses.
CustomCSS *string `form:"custom_css" json:"custom_css" xml:"custom_css"`
+ // Enable RSS feed of public toots for this account at /@[username]/feed.rss
+ EnableRSS *bool `form:"enable_rss" json:"enable_rss" xml:"enable_rss"`
}
// UpdateSource is to be used specifically in an UpdateCredentialsRequest.
diff --git a/internal/cache/account.go b/internal/cache/account.go
index 12675b6b9..c25db42ce 100644
--- a/internal/cache/account.go
+++ b/internal/cache/account.go
@@ -158,6 +158,7 @@ func copyAccount(account *gtsmodel.Account) *gtsmodel.Account {
SuspendedAt: account.SuspendedAt,
HideCollections: copyBoolPtr(account.HideCollections),
SuspensionOrigin: account.SuspensionOrigin,
+ EnableRSS: copyBoolPtr(account.EnableRSS),
}
}
diff --git a/internal/db/account.go b/internal/db/account.go
index ae5eea7c6..a58aa9dd3 100644
--- a/internal/db/account.go
+++ b/internal/db/account.go
@@ -77,8 +77,10 @@ type Account interface {
// GetAccountLastPosted simply gets the timestamp of the most recent post by the account.
//
+ // If webOnly is true, then the time of the last non-reply, non-boost, public status of the account will be returned.
+ //
// The returned time will be zero if account has never posted anything.
- GetAccountLastPosted(ctx context.Context, accountID string) (time.Time, Error)
+ GetAccountLastPosted(ctx context.Context, accountID string, webOnly bool) (time.Time, Error)
// SetAccountHeaderOrAvatar sets the header or avatar for the given accountID to the given media attachment.
SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) Error
diff --git a/internal/db/bundb/account.go b/internal/db/bundb/account.go
index c04948fee..4813f4e17 100644
--- a/internal/db/bundb/account.go
+++ b/internal/db/bundb/account.go
@@ -253,21 +253,29 @@ func (a *accountDB) GetInstanceAccount(ctx context.Context, domain string) (*gts
return account, nil
}
-func (a *accountDB) GetAccountLastPosted(ctx context.Context, accountID string) (time.Time, db.Error) {
- status := new(gtsmodel.Status)
+func (a *accountDB) GetAccountLastPosted(ctx context.Context, accountID string, webOnly bool) (time.Time, db.Error) {
+ createdAt := time.Time{}
q := a.conn.
NewSelect().
- Model(status).
+ TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status")).
Column("status.created_at").
Where("? = ?", bun.Ident("status.account_id"), accountID).
Order("status.id DESC").
Limit(1)
- if err := q.Scan(ctx); err != nil {
+ if webOnly {
+ q = q.
+ WhereGroup(" AND ", whereEmptyOrNull("status.in_reply_to_uri")).
+ WhereGroup(" AND ", whereEmptyOrNull("status.boost_of_id")).
+ Where("? = ?", bun.Ident("status.visibility"), gtsmodel.VisibilityPublic).
+ Where("? = ?", bun.Ident("status.federated"), true)
+ }
+
+ if err := q.Scan(ctx, &createdAt); err != nil {
return time.Time{}, a.conn.ProcessError(err)
}
- return status.CreatedAt, nil
+ return createdAt, nil
}
func (a *accountDB) SetAccountHeaderOrAvatar(ctx context.Context, mediaAttachment *gtsmodel.MediaAttachment, accountID string) db.Error {
diff --git a/internal/db/bundb/account_test.go b/internal/db/bundb/account_test.go
index 72adba487..29594a740 100644
--- a/internal/db/bundb/account_test.go
+++ b/internal/db/bundb/account_test.go
@@ -152,11 +152,17 @@ func (suite *AccountTestSuite) TestUpdateAccount() {
}
func (suite *AccountTestSuite) TestGetAccountLastPosted() {
- lastPosted, err := suite.db.GetAccountLastPosted(context.Background(), suite.testAccounts["local_account_1"].ID)
+ lastPosted, err := suite.db.GetAccountLastPosted(context.Background(), suite.testAccounts["local_account_1"].ID, false)
suite.NoError(err)
suite.EqualValues(1653046675, lastPosted.Unix())
}
+func (suite *AccountTestSuite) TestGetAccountLastPostedWebOnly() {
+ lastPosted, err := suite.db.GetAccountLastPosted(context.Background(), suite.testAccounts["local_account_1"].ID, true)
+ suite.NoError(err)
+ suite.EqualValues(1634726437, lastPosted.Unix())
+}
+
func (suite *AccountTestSuite) TestInsertAccountWithDefaults() {
key, err := rsa.GenerateKey(rand.Reader, 2048)
suite.NoError(err)
diff --git a/internal/db/bundb/migrations/20221006114842_add_rss_functionality.go b/internal/db/bundb/migrations/20221006114842_add_rss_functionality.go
new file mode 100644
index 000000000..94c21fe58
--- /dev/null
+++ b/internal/db/bundb/migrations/20221006114842_add_rss_functionality.go
@@ -0,0 +1,46 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package migrations
+
+import (
+ "context"
+ "strings"
+
+ "github.com/uptrace/bun"
+)
+
+func init() {
+ up := func(ctx context.Context, db *bun.DB) error {
+ _, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? BOOLEAN DEFAULT false", bun.Ident("accounts"), bun.Ident("enable_rss"))
+ if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
+ return err
+ }
+ return nil
+ }
+
+ down := func(ctx context.Context, db *bun.DB) error {
+ return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
+ return nil
+ })
+ }
+
+ if err := Migrations.Register(up, down); err != nil {
+ panic(err)
+ }
+}
diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go
index ca5c74208..c964b83a9 100644
--- a/internal/gtsmodel/account.go
+++ b/internal/gtsmodel/account.go
@@ -76,6 +76,7 @@ type Account struct {
SuspendedAt time.Time `validate:"-" bun:"type:timestamptz,nullzero"` // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account)
HideCollections *bool `validate:"-" bun:",default:false"` // Hide this account's collections
SuspensionOrigin string `validate:"omitempty,ulid" bun:"type:CHAR(26),nullzero"` // id of the database entry that caused this account to become suspended -- can be an account ID or a domain block ID
+ EnableRSS *bool `validate:"-" bun:",default:false"` // enable RSS feed subscription for this account's public posts at [URL]/feed
}
// AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis.
diff --git a/internal/processing/account.go b/internal/processing/account.go
index ada511133..6cba8b9c4 100644
--- a/internal/processing/account.go
+++ b/internal/processing/account.go
@@ -20,6 +20,7 @@ package processing
import (
"context"
+ "time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
@@ -46,6 +47,10 @@ func (p *processor) AccountGetCustomCSSForUsername(ctx context.Context, username
return p.accountProcessor.GetCustomCSSForUsername(ctx, username)
}
+func (p *processor) AccountGetRSSFeedForUsername(ctx context.Context, username string) (func() (string, gtserror.WithCode), time.Time, gtserror.WithCode) {
+ return p.accountProcessor.GetRSSFeedForUsername(ctx, username)
+}
+
func (p *processor) AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode) {
return p.accountProcessor.Update(ctx, authed.Account, form)
}
diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go
index aca46394a..b18e705ca 100644
--- a/internal/processing/account/account.go
+++ b/internal/processing/account/account.go
@@ -21,6 +21,7 @@ package account
import (
"context"
"mime/multipart"
+ "time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/concurrency"
@@ -53,6 +54,8 @@ type Processor interface {
GetLocalByUsername(ctx context.Context, requestingAccount *gtsmodel.Account, username string) (*apimodel.Account, gtserror.WithCode)
// GetCustomCSSForUsername returns custom css for the given local username.
GetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode)
+ // GetRSSFeedForUsername returns RSS feed for the given local username.
+ GetRSSFeedForUsername(ctx context.Context, username string) (func() (string, gtserror.WithCode), time.Time, gtserror.WithCode)
// Update processes the update of an account with the given form
Update(ctx context.Context, account *gtsmodel.Account, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode)
// StatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
diff --git a/internal/processing/account/getrss.go b/internal/processing/account/getrss.go
new file mode 100644
index 000000000..f07204b56
--- /dev/null
+++ b/internal/processing/account/getrss.go
@@ -0,0 +1,108 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package account
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/gorilla/feeds"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+)
+
+const rssFeedLength = 20
+
+func (p *processor) GetRSSFeedForUsername(ctx context.Context, username string) (func() (string, gtserror.WithCode), time.Time, gtserror.WithCode) {
+ account, err := p.db.GetAccountByUsernameDomain(ctx, username, "")
+ if err != nil {
+ if err == db.ErrNoEntries {
+ return nil, time.Time{}, gtserror.NewErrorNotFound(errors.New("GetRSSFeedForUsername: account not found"))
+ }
+ return nil, time.Time{}, gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
+ }
+
+ if !*account.EnableRSS {
+ return nil, time.Time{}, gtserror.NewErrorNotFound(errors.New("GetRSSFeedForUsername: account RSS feed not enabled"))
+ }
+
+ lastModified, err := p.db.GetAccountLastPosted(ctx, account.ID, true)
+ if err != nil {
+ return nil, time.Time{}, gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
+ }
+
+ return func() (string, gtserror.WithCode) {
+ statuses, err := p.db.GetAccountWebStatuses(ctx, account.ID, rssFeedLength, "")
+ if err != nil && err != db.ErrNoEntries {
+ return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
+ }
+
+ author := "@" + account.Username + "@" + config.GetAccountDomain()
+ title := "Posts from " + author
+ description := "Posts from " + author
+ link := &feeds.Link{Href: account.URL}
+
+ var image *feeds.Image
+ if account.AvatarMediaAttachmentID != "" {
+ if account.AvatarMediaAttachment == nil {
+ avatar, err := p.db.GetAttachmentByID(ctx, account.AvatarMediaAttachmentID)
+ if err != nil {
+ return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error fetching avatar attachment: %s", err))
+ }
+ account.AvatarMediaAttachment = avatar
+ }
+ image = &feeds.Image{
+ Url: account.AvatarMediaAttachment.Thumbnail.URL,
+ Title: "Avatar for " + author,
+ Link: account.URL,
+ }
+ }
+
+ feed := &feeds.Feed{
+ Title: title,
+ Description: description,
+ Link: link,
+ Image: image,
+ }
+
+ for i, s := range statuses {
+ // take the date of the first (ie., latest) status as feed updated value
+ if i == 0 {
+ feed.Updated = s.UpdatedAt
+ }
+
+ item, err := p.tc.StatusToRSSItem(ctx, s)
+ if err != nil {
+ return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: error converting status to feed item: %s", err))
+ }
+
+ feed.Add(item)
+ }
+
+ rss, err := feed.ToRss()
+ if err != nil {
+ return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: error converting feed to rss string: %s", err))
+ }
+
+ return rss, nil
+ }, lastModified, nil
+}
diff --git a/internal/processing/account/getrss_test.go b/internal/processing/account/getrss_test.go
new file mode 100644
index 000000000..dc81434a0
--- /dev/null
+++ b/internal/processing/account/getrss_test.go
@@ -0,0 +1,61 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package account_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type GetRSSTestSuite struct {
+ AccountStandardTestSuite
+}
+
+func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
+ getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "admin")
+ suite.NoError(err)
+ suite.EqualValues(1634733405, lastModified.Unix())
+
+ feed, err := getFeed()
+ suite.NoError(err)
+
+ fmt.Println(feed)
+
+ suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @admin@localhost:8080</title>\n <link>http://localhost:8080/@admin</link>\n <description>Posts from @admin@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 12:36:45 +0000</lastBuildDate>\n <item>\n <title>open to see some puppies</title>\n <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link>\n <description>@admin@localhost:8080 made a new post: &#34;🐕🐕🐕🐕🐕&#34;</description>\n <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <guid>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n <item>\n <title>hello world! #welcome ! first post on the instance :rainbow: !</title>\n <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link>\n <description>@admin@localhost:8080 posted 1 attachment: &#34;hello world! #welcome ! first post on the instance :rainbow: !&#34;</description>\n <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <enclosure url=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg\" length=\"62529\" type=\"image/jpeg\"></enclosure>\n <guid>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid>\n <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
+}
+
+func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
+ getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork")
+ suite.NoError(err)
+ suite.EqualValues(1634726437, lastModified.Unix())
+
+ feed, err := getFeed()
+ suite.NoError(err)
+
+ fmt.Println(feed)
+
+ suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n <description>Posts from @the_mighty_zork@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 10:40:37 +0000</lastBuildDate>\n <image>\n <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg</url>\n <title>Avatar for @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n </image>\n <item>\n <title>introduction post</title>\n <link>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</link>\n <description>@the_mighty_zork@localhost:8080 made a new post: &#34;hello everyone!&#34;</description>\n <content:encoded><![CDATA[hello everyone!]]></content:encoded>\n <author>@the_mighty_zork@localhost:8080</author>\n <guid>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</guid>\n <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate>\n <source>http://localhost:8080/@the_mighty_zork/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
+}
+
+func TestGetRSSTestSuite(t *testing.T) {
+ suite.Run(t, new(GetRSSTestSuite))
+}
diff --git a/internal/processing/account/update.go b/internal/processing/account/update.go
index 94e91ca4c..f39361c06 100644
--- a/internal/processing/account/update.go
+++ b/internal/processing/account/update.go
@@ -160,6 +160,10 @@ func (p *processor) Update(ctx context.Context, account *gtsmodel.Account, form
account.CustomCSS = text.SanitizePlaintext(customCSS)
}
+ if form.EnableRSS != nil {
+ account.EnableRSS = form.EnableRSS
+ }
+
updatedAccount, err := p.db.UpdateAccount(ctx, account)
if err != nil {
return nil, gtserror.NewErrorInternalError(fmt.Errorf("could not update account %s: %s", account.ID, err))
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 81ed3c8e5..09bb579ba 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -22,6 +22,7 @@ import (
"context"
"net/http"
"net/url"
+ "time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/concurrency"
@@ -80,6 +81,10 @@ type Processor interface {
// AccountGet processes the given request for account information.
AccountGetLocalByUsername(ctx context.Context, authed *oauth.Auth, username string) (*apimodel.Account, gtserror.WithCode)
AccountGetCustomCSSForUsername(ctx context.Context, username string) (string, gtserror.WithCode)
+ // AccountGetRSSFeedForUsername returns a function to get the RSS feed of latest posts for given local account username.
+ // This function should only be called if necessary: the given lastModified time can be used to check this.
+ // Will return 404 if an rss feed for that user is not available, or a different error if something else goes wrong.
+ AccountGetRSSFeedForUsername(ctx context.Context, username string) (func() (string, gtserror.WithCode), time.Time, gtserror.WithCode)
// AccountUpdate processes the update of an account with the given form
AccountUpdate(ctx context.Context, authed *oauth.Auth, form *apimodel.UpdateCredentialsRequest) (*apimodel.Account, gtserror.WithCode)
// AccountStatusesGet fetches a number of statuses (in time descending order) from the given account, filtered by visibility for
diff --git a/internal/router/template.go b/internal/router/template.go
index fcdccf783..e87f6b69e 100644
--- a/internal/router/template.go
+++ b/internal/router/template.go
@@ -19,9 +19,7 @@
package router
import (
- "bytes"
"fmt"
- "html"
"html/template"
"os"
"path/filepath"
@@ -31,7 +29,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/log"
- "github.com/superseriousbusiness/gotosocial/internal/regexes"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@@ -152,42 +150,9 @@ func visibilityIcon(visibility model.Visibility) template.HTML {
return template.HTML(fmt.Sprintf(`<i aria-label="Visibility: %v" class="fa fa-%v"></i>`, icon.label, icon.faIcon))
}
-// replaces shortcodes in `text` with the emoji in `emojis`
// text is a template.HTML to affirm that the input of this function is already escaped
-func emojify(emojis []model.Emoji, text template.HTML) template.HTML {
- emojisMap := make(map[string]model.Emoji, len(emojis))
-
- for _, emoji := range emojis {
- shortcode := ":" + emoji.Shortcode + ":"
- emojisMap[shortcode] = emoji
- }
-
- out := regexes.ReplaceAllStringFunc(
- regexes.EmojiFinder,
- string(text),
- func(shortcode string, buf *bytes.Buffer) string {
- // Look for emoji according to this shortcode
- emoji, ok := emojisMap[shortcode]
- if !ok {
- return shortcode
- }
-
- // Escape raw emoji content
- safeURL := html.EscapeString(emoji.URL)
- safeCode := html.EscapeString(emoji.Shortcode)
-
- // Write HTML emoji repr to buffer
- buf.WriteString(`<img src="`)
- buf.WriteString(safeURL)
- buf.WriteString(`" title=":`)
- buf.WriteString(safeCode)
- buf.WriteString(`:" alt=":`)
- buf.WriteString(safeCode)
- buf.WriteString(`:" class="emoji"/>`)
-
- return buf.String()
- },
- )
+func emojify(emojis []model.Emoji, inputText template.HTML) template.HTML {
+ out := text.Emojify(emojis, string(inputText))
/* #nosec G203 */
// (this is escaped above)
diff --git a/internal/text/emojify.go b/internal/text/emojify.go
new file mode 100644
index 000000000..c9e25e5f9
--- /dev/null
+++ b/internal/text/emojify.go
@@ -0,0 +1,67 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package text
+
+import (
+ "bytes"
+ "html"
+
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/regexes"
+)
+
+// Emojify replaces shortcodes in `inputText` with the emoji in `emojis`.
+//
+// Callers should ensure that inputText and resulting text are escaped
+// appropriately depending on what they're used for.
+func Emojify(emojis []model.Emoji, inputText string) string {
+ emojisMap := make(map[string]model.Emoji, len(emojis))
+
+ for _, emoji := range emojis {
+ shortcode := ":" + emoji.Shortcode + ":"
+ emojisMap[shortcode] = emoji
+ }
+
+ return regexes.ReplaceAllStringFunc(
+ regexes.EmojiFinder,
+ inputText,
+ func(shortcode string, buf *bytes.Buffer) string {
+ // Look for emoji according to this shortcode
+ emoji, ok := emojisMap[shortcode]
+ if !ok {
+ return shortcode
+ }
+
+ // Escape raw emoji content
+ safeURL := html.EscapeString(emoji.URL)
+ safeCode := html.EscapeString(emoji.Shortcode)
+
+ // Write HTML emoji repr to buffer
+ buf.WriteString(`<img src="`)
+ buf.WriteString(safeURL)
+ buf.WriteString(`" title=":`)
+ buf.WriteString(safeCode)
+ buf.WriteString(`:" alt=":`)
+ buf.WriteString(safeCode)
+ buf.WriteString(`:" class="emoji"/>`)
+
+ return buf.String()
+ },
+ )
+}
diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go
index 27464809b..c44f4ebe8 100644
--- a/internal/typeutils/astointernal.go
+++ b/internal/typeutils/astointernal.go
@@ -149,6 +149,10 @@ func (c *converter) ASRepresentationToAccount(ctx context.Context, accountable a
acct.Discoverable = &d
}
+ // assume not rss feed
+ enableRSS := false
+ acct.EnableRSS = &enableRSS
+
// url property
url, err := ap.ExtractURL(accountable)
if err == nil {
diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go
index 3effb9388..b1a771458 100644
--- a/internal/typeutils/converter.go
+++ b/internal/typeutils/converter.go
@@ -23,6 +23,7 @@ import (
"net/url"
"sync"
+ "github.com/gorilla/feeds"
"github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/api/model"
@@ -84,6 +85,12 @@ type TypeConverter interface {
DomainBlockToAPIDomainBlock(ctx context.Context, b *gtsmodel.DomainBlock, export bool) (*model.DomainBlock, error)
/*
+ INTERNAL (gts) MODEL TO FRONTEND (rss) MODEL
+ */
+
+ StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*feeds.Item, error)
+
+ /*
FRONTEND (api) MODEL TO INTERNAL (gts) MODEL
*/
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index 778b73dc9..09bd5fc7d 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -105,7 +105,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
// check when the last status was
var lastStatusAt string
- lastPosted, err := c.db.GetAccountLastPosted(ctx, a.ID)
+ lastPosted, err := c.db.GetAccountLastPosted(ctx, a.ID, false)
if err == nil && !lastPosted.IsZero() {
lastStatusAt = util.FormatISO8601(lastPosted)
}
@@ -219,6 +219,7 @@ func (c *converter) AccountToAPIAccountPublic(ctx context.Context, a *gtsmodel.A
Fields: fields,
Suspended: suspended,
CustomCSS: a.CustomCSS,
+ EnableRSS: *a.EnableRSS,
}
c.ensureAvatar(accountFrontend)
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index a13e5255c..9dd8ed4e3 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -40,7 +40,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() {
b, err := json.Marshal(apiAccount)
suite.NoError(err)
- suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[]}`, string(b))
+ suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"enable_rss":true}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() {
@@ -55,7 +55,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct()
b, err := json.Marshal(apiAccount)
suite.NoError(err)
- suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[]}`, string(b))
+ suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[],"enable_rss":true}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() {
@@ -70,7 +70,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() {
b, err := json.Marshal(apiAccount)
suite.NoError(err)
- suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[]}`, string(b))
+ suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"fields":[],"enable_rss":true}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() {
@@ -81,7 +81,7 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() {
b, err := json.Marshal(apiAccount)
suite.NoError(err)
- suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"source":{"privacy":"public","language":"en","status_format":"plain","note":"hey yo this is my profile!","fields":[]}}`, string(b))
+ suite.Equal(`{"id":"01F8MH1H7YV1Z7D2C8K2730QBF","username":"the_mighty_zork","acct":"the_mighty_zork","display_name":"original zork (he/they)","locked":false,"bot":false,"created_at":"2022-05-20T11:09:18.000Z","note":"\u003cp\u003ehey yo this is my profile!\u003c/p\u003e","url":"http://localhost:8080/@the_mighty_zork","avatar":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpeg","avatar_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg","header":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","header_static":"http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.jpeg","followers_count":2,"following_count":2,"statuses_count":5,"last_status_at":"2022-05-20T11:37:55.000Z","emojis":[],"fields":[],"source":{"privacy":"public","language":"en","status_format":"plain","note":"hey yo this is my profile!","fields":[]},"enable_rss":true}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
@@ -93,7 +93,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() {
b, err := json.Marshal(apiStatus)
suite.NoError(err)
- suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[]},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"card":null,"poll":null}`, string(b))
+ suite.Equal(`{"id":"01F8MH75CBF9JFX4ZAD54N0W0R","created_at":"2021-10-20T11:36:45.000Z","in_reply_to_id":null,"in_reply_to_account_id":null,"sensitive":false,"spoiler_text":"","visibility":"public","language":"en","uri":"http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","url":"http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R","replies_count":0,"reblogs_count":0,"favourites_count":1,"favourited":true,"reblogged":false,"muted":false,"bookmarked":false,"pinned":false,"content":"hello world! #welcome ! first post on the instance :rainbow: !","reblog":null,"application":{"name":"superseriousbusiness","website":"https://superserious.business"},"account":{"id":"01F8MH17FWEB39HZJ76B6VXSKF","username":"admin","acct":"admin","display_name":"","locked":false,"bot":false,"created_at":"2022-05-17T13:10:59.000Z","note":"","url":"http://localhost:8080/@admin","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.png","header_static":"http://localhost:8080/assets/default_header.png","followers_count":1,"following_count":1,"statuses_count":4,"last_status_at":"2021-10-20T10:41:37.000Z","emojis":[],"fields":[],"enable_rss":true},"media_attachments":[{"id":"01F8MH6NEM8D7527KZAECTCR76","type":"image","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","text_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg","preview_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.jpeg","remote_url":null,"preview_remote_url":null,"meta":{"original":{"width":1200,"height":630,"size":"1200x630","aspect":1.9047619},"small":{"width":256,"height":134,"size":"256x134","aspect":1.9104477},"focus":{"x":0,"y":0}},"description":"Black and white image of some 50's style text saying: Welcome On Board","blurhash":"LNJRdVM{00Rj%Mayt7j[4nWBofRj"}],"mentions":[],"tags":[{"name":"welcome","url":"http://localhost:8080/tags/welcome"}],"emojis":[{"shortcode":"rainbow","url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","static_url":"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png","visible_in_picker":true}],"card":null,"poll":null,"text":"hello world! #welcome ! first post on the instance :rainbow: !"}`, string(b))
}
func (suite *InternalToFrontendTestSuite) TestInstanceToFrontend() {
diff --git a/internal/typeutils/internaltorss.go b/internal/typeutils/internaltorss.go
new file mode 100644
index 000000000..609725d73
--- /dev/null
+++ b/internal/typeutils/internaltorss.go
@@ -0,0 +1,177 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package typeutils
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/gorilla/feeds"
+ "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+ "github.com/superseriousbusiness/gotosocial/internal/text"
+)
+
+const (
+ rssMaxTitleChars = 128
+ rssDescriptionMaxChars = 256
+)
+
+func (c *converter) StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*feeds.Item, error) {
+ // see https://cyber.harvard.edu/rss/rss.html
+
+ // Title -- The title of the item.
+ // example: Venice Film Festival Tries to Quit Sinking
+ var title string
+ if s.ContentWarning != "" {
+ title = trimTo(s.ContentWarning, rssMaxTitleChars)
+ } else {
+ title = trimTo(s.Text, rssMaxTitleChars)
+ }
+
+ // Link -- The URL of the item.
+ // example: http://nytimes.com/2004/12/07FEST.html
+ link := &feeds.Link{
+ Href: s.URL,
+ }
+
+ // Author -- Email address of the author of the item.
+ // example: oprah\@oxygen.net
+ if s.Account == nil {
+ a, err := c.db.GetAccountByID(ctx, s.AccountID)
+ if err != nil {
+ return nil, fmt.Errorf("error getting status author: %s", err)
+ }
+ s.Account = a
+ }
+ authorName := "@" + s.Account.Username + "@" + config.GetAccountDomain()
+ author := &feeds.Author{
+ Name: authorName,
+ }
+
+ // Source -- The RSS channel that the item came from.
+ source := &feeds.Link{
+ Href: s.Account.URL + "/feed.rss",
+ }
+
+ // Description -- The item synopsis.
+ // example: Some of the most heated chatter at the Venice Film Festival this week was about the way that the arrival of the stars at the Palazzo del Cinema was being staged.
+ descriptionBuilder := strings.Builder{}
+ descriptionBuilder.WriteString(authorName + " ")
+
+ attachmentCount := len(s.Attachments)
+ if len(s.AttachmentIDs) > attachmentCount {
+ attachmentCount = len(s.AttachmentIDs)
+ }
+ switch {
+ case attachmentCount > 1:
+ descriptionBuilder.WriteString(fmt.Sprintf("posted [%d] attachments", attachmentCount))
+ case attachmentCount == 1:
+ descriptionBuilder.WriteString("posted 1 attachment")
+ default:
+ descriptionBuilder.WriteString("made a new post")
+ }
+
+ if s.Text != "" {
+ descriptionBuilder.WriteString(": \"")
+ descriptionBuilder.WriteString(s.Text)
+ descriptionBuilder.WriteString("\"")
+ }
+
+ description := trimTo(descriptionBuilder.String(), rssDescriptionMaxChars)
+
+ // ID -- A string that uniquely identifies the item.
+ // example: http://inessential.com/2002/09/01.php#a2
+ id := s.URL
+
+ // Enclosure -- Describes a media object that is attached to the item.
+ enclosure := &feeds.Enclosure{}
+ // get first attachment if present
+ var attachment *gtsmodel.MediaAttachment
+ if len(s.Attachments) > 0 {
+ attachment = s.Attachments[0]
+ } else if len(s.AttachmentIDs) > 0 {
+ a, err := c.db.GetAttachmentByID(ctx, s.AttachmentIDs[0])
+ if err == nil {
+ attachment = a
+ }
+ }
+ if attachment != nil {
+ enclosure.Type = attachment.File.ContentType
+ enclosure.Length = strconv.Itoa(attachment.File.FileSize)
+ enclosure.Url = attachment.URL
+ }
+
+ // Content
+ apiEmojis := []model.Emoji{}
+ // the status might already have some gts emojis on it if it's not been pulled directly from the database
+ // if so, we can directly convert the gts emojis into api ones
+ if s.Emojis != nil {
+ for _, gtsEmoji := range s.Emojis {
+ apiEmoji, err := c.EmojiToAPIEmoji(ctx, gtsEmoji)
+ if err != nil {
+ log.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err)
+ continue
+ }
+ apiEmojis = append(apiEmojis, apiEmoji)
+ }
+ // the status doesn't have gts emojis on it, but it does have emoji IDs
+ // in this case, we need to pull the gts emojis from the db to convert them into api ones
+ } else {
+ for _, e := range s.EmojiIDs {
+ gtsEmoji := &gtsmodel.Emoji{}
+ if err := c.db.GetByID(ctx, e, gtsEmoji); err != nil {
+ log.Errorf("error getting emoji with id %s: %s", e, err)
+ continue
+ }
+ apiEmoji, err := c.EmojiToAPIEmoji(ctx, gtsEmoji)
+ if err != nil {
+ log.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err)
+ continue
+ }
+ apiEmojis = append(apiEmojis, apiEmoji)
+ }
+ }
+ content := text.Emojify(apiEmojis, s.Content)
+
+ return &feeds.Item{
+ Title: title,
+ Link: link,
+ Author: author,
+ Source: source,
+ Description: description,
+ Id: id,
+ Updated: s.UpdatedAt,
+ Created: s.CreatedAt,
+ Enclosure: enclosure,
+ Content: content,
+ }, nil
+}
+
+func trimTo(in string, to int) string {
+ if len(in) <= to {
+ return in
+ }
+
+ return in[:to-3] + "..."
+}
diff --git a/internal/typeutils/internaltorss_test.go b/internal/typeutils/internaltorss_test.go
new file mode 100644
index 000000000..e30304ee9
--- /dev/null
+++ b/internal/typeutils/internaltorss_test.go
@@ -0,0 +1,86 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package typeutils_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type InternalToRSSTestSuite struct {
+ TypeUtilsTestSuite
+}
+
+func (suite *InternalToRSSTestSuite) TestStatusToRSSItem1() {
+ s := suite.testStatuses["local_account_1_status_1"]
+ item, err := suite.typeconverter.StatusToRSSItem(context.Background(), s)
+ suite.NoError(err)
+
+ suite.Equal("introduction post", item.Title)
+ suite.Equal("http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", item.Link.Href)
+ suite.Equal("", item.Link.Length)
+ suite.Equal("", item.Link.Rel)
+ suite.Equal("", item.Link.Type)
+ suite.Equal("http://localhost:8080/@the_mighty_zork/feed.rss", item.Source.Href)
+ suite.Equal("", item.Source.Length)
+ suite.Equal("", item.Source.Rel)
+ suite.Equal("", item.Source.Type)
+ suite.Equal("", item.Author.Email)
+ suite.Equal("@the_mighty_zork@localhost:8080", item.Author.Name)
+ suite.Equal("@the_mighty_zork@localhost:8080 made a new post: \"hello everyone!\"", item.Description)
+ suite.Equal("http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", item.Id)
+ suite.EqualValues(1634726437, item.Updated.Unix())
+ suite.EqualValues(1634726437, item.Created.Unix())
+ suite.Equal("", item.Enclosure.Length)
+ suite.Equal("", item.Enclosure.Type)
+ suite.Equal("", item.Enclosure.Url)
+ suite.Equal("hello everyone!", item.Content)
+}
+
+func (suite *InternalToRSSTestSuite) TestStatusToRSSItem2() {
+ s := suite.testStatuses["admin_account_status_1"]
+ item, err := suite.typeconverter.StatusToRSSItem(context.Background(), s)
+ suite.NoError(err)
+
+ suite.Equal("hello world! #welcome ! first post on the instance :rainbow: !", item.Title)
+ suite.Equal("http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", item.Link.Href)
+ suite.Equal("", item.Link.Length)
+ suite.Equal("", item.Link.Rel)
+ suite.Equal("", item.Link.Type)
+ suite.Equal("http://localhost:8080/@admin/feed.rss", item.Source.Href)
+ suite.Equal("", item.Source.Length)
+ suite.Equal("", item.Source.Rel)
+ suite.Equal("", item.Source.Type)
+ suite.Equal("", item.Author.Email)
+ suite.Equal("@admin@localhost:8080", item.Author.Name)
+ suite.Equal("@admin@localhost:8080 posted 1 attachment: \"hello world! #welcome ! first post on the instance :rainbow: !\"", item.Description)
+ suite.Equal("http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", item.Id)
+ suite.EqualValues(1634729805, item.Updated.Unix())
+ suite.EqualValues(1634729805, item.Created.Unix())
+ suite.Equal("62529", item.Enclosure.Length)
+ suite.Equal("image/jpeg", item.Enclosure.Type)
+ suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", item.Enclosure.Url)
+ suite.Equal("hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !", item.Content)
+}
+
+func TestInternalToRSSTestSuite(t *testing.T) {
+ suite.Run(t, new(InternalToRSSTestSuite))
+}
diff --git a/internal/web/assets.go b/internal/web/assets.go
index 397870862..aab4346eb 100644
--- a/internal/web/assets.go
+++ b/internal/web/assets.go
@@ -19,7 +19,9 @@
package web
import (
+ "fmt"
"net/http"
+ "path"
"path/filepath"
"strings"
@@ -60,9 +62,91 @@ func (m *Module) mountAssetsFilesystem(group *gin.RouterGroup) {
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
// use the cache middleware on all handlers in this group
- group.Use(m.cacheControlMiddleware(fs))
+ group.Use(m.assetsCacheControlMiddleware(fs))
// serve static file system in the root of this group,
// will end up being something like "/assets/"
group.StaticFS("/", fs)
}
+
+// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
+// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
+// to generate a new ETag to go in the cache, which it then returns.
+func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) {
+ file, err := fs.Open(filePath)
+ if err != nil {
+ return "", fmt.Errorf("error opening %s: %s", filePath, err)
+ }
+ defer file.Close()
+
+ fileInfo, err := file.Stat()
+ if err != nil {
+ return "", fmt.Errorf("error statting %s: %s", filePath, err)
+ }
+
+ fileLastModified := fileInfo.ModTime()
+
+ if cachedETag, ok := m.eTagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
+ // only return our cached etag if the file wasn't
+ // modified since last time, otherwise generate a
+ // new one; eat fresh!
+ return cachedETag.eTag, nil
+ }
+
+ eTag, err := generateEtag(file)
+ if err != nil {
+ return "", fmt.Errorf("error generating etag: %s", err)
+ }
+
+ // put new entry in cache before we return
+ m.eTagCache.Set(filePath, eTagCacheEntry{
+ eTag: eTag,
+ lastModified: fileLastModified,
+ })
+
+ return eTag, nil
+}
+
+// assetsCacheControlMiddleware implements Cache-Control header setting, and checks
+// for files inside the given http.FileSystem.
+//
+// The middleware checks if the file has been modified using If-None-Match etag,
+// if present. If the file hasn't been modified, the middleware returns 304.
+//
+// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
+// and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // set this Cache-Control header to instruct clients to validate the response with us
+ // before each reuse (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
+ c.Header(cacheControlHeader, cacheControlNoCache)
+
+ ifNoneMatch := c.Request.Header.Get(ifNoneMatchHeader)
+
+ // derive the path of the requested asset inside the provided filesystem
+ upath := c.Request.URL.Path
+ if !strings.HasPrefix(upath, "/") {
+ upath = "/" + upath
+ }
+ assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
+
+ // either fetch etag from ttlcache or generate it
+ eTag, err := m.getAssetETag(assetFilePath, fs)
+ if err != nil {
+ log.Errorf("error getting ETag for %s: %s", assetFilePath, err)
+ return
+ }
+
+ // Regardless of what happens further down, set the etag header
+ // so that the client has the up-to-date version.
+ c.Header(eTagHeader, eTag)
+
+ // If client already has latest version of the asset, 304 + bail.
+ if ifNoneMatch == eTag {
+ c.AbortWithStatus(http.StatusNotModified)
+ return
+ }
+
+ // else let the rest of the request be processed normally
+ }
+}
diff --git a/internal/web/assetscache.go b/internal/web/assetscache.go
deleted file mode 100644
index fccc95993..000000000
--- a/internal/web/assetscache.go
+++ /dev/null
@@ -1,138 +0,0 @@
-/*
- GoToSocial
- Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
-
- 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/>.
-*/
-
-package web
-
-import (
- // nolint:gosec
- "crypto/sha1"
- "encoding/hex"
- "fmt"
- "io"
- "net/http"
- "path"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/superseriousbusiness/gotosocial/internal/log"
-)
-
-type eTagCacheEntry struct {
- eTag string
- fileLastModified time.Time
-}
-
-// generateEtag generates a strong (byte-for-byte) etag using
-// the entirety of the provided reader.
-func generateEtag(r io.Reader) (string, error) {
- // nolint:gosec
- hash := sha1.New()
-
- if _, err := io.Copy(hash, r); err != nil {
- return "", err
- }
-
- b := make([]byte, 0, sha1.Size)
- b = hash.Sum(b)
-
- return `"` + hex.EncodeToString(b) + `"`, nil
-}
-
-// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
-// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
-// to generate a new ETag to go in the cache, which it then returns.
-func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) {
- file, err := fs.Open(filePath)
- if err != nil {
- return "", fmt.Errorf("error opening %s: %s", filePath, err)
- }
- defer file.Close()
-
- fileInfo, err := file.Stat()
- if err != nil {
- return "", fmt.Errorf("error statting %s: %s", filePath, err)
- }
-
- fileLastModified := fileInfo.ModTime()
-
- if cachedETag, ok := m.assetsETagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.fileLastModified) {
- // only return our cached etag if the file wasn't
- // modified since last time, otherwise generate a
- // new one; eat fresh!
- return cachedETag.eTag, nil
- }
-
- eTag, err := generateEtag(file)
- if err != nil {
- return "", fmt.Errorf("error generating etag: %s", err)
- }
-
- // put new entry in cache before we return
- m.assetsETagCache.Set(filePath, eTagCacheEntry{
- eTag: eTag,
- fileLastModified: fileLastModified,
- })
-
- return eTag, nil
-}
-
-// cacheControlMiddleware implements Cache-Control header setting, and checks for
-// files inside the given http.FileSystem.
-//
-// The middleware checks if the file has been modified using If-None-Match etag,
-// if present. If the file hasn't been modified, the middleware returns 304.
-//
-// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
-// and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
-func (m *Module) cacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc {
- return func(c *gin.Context) {
- // no-cache prevents clients using default caching or heuristic caching,
- // and also ensures that clients will validate their cached version against
- // the version stored on the server to keep up to date.
- c.Header("Cache-Control", "no-cache")
-
- ifNoneMatch := c.Request.Header.Get("If-None-Match")
-
- // derive the path of the requested asset inside the provided filesystem
- upath := c.Request.URL.Path
- if !strings.HasPrefix(upath, "/") {
- upath = "/" + upath
- }
- assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
-
- // either fetch etag from ttlcache or generate it
- eTag, err := m.getAssetETag(assetFilePath, fs)
- if err != nil {
- log.Errorf("error getting ETag for %s: %s", assetFilePath, err)
- return
- }
-
- // Regardless of what happens further down, set the etag header
- // so that the client has the up-to-date version.
- c.Header("Etag", eTag)
-
- // If client already has latest version of the asset, 304 + bail.
- if ifNoneMatch == eTag {
- c.AbortWithStatus(http.StatusNotModified)
- return
- }
-
- // else let the rest of the request be processed normally
- }
-}
diff --git a/internal/web/customcss.go b/internal/web/customcss.go
index 34e15844c..48f8c0f71 100644
--- a/internal/web/customcss.go
+++ b/internal/web/customcss.go
@@ -29,6 +29,8 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
)
+const textCSSUTF8 = string(api.TextCSS + "; charset=utf-8")
+
func (m *Module) customCSSGETHandler(c *gin.Context) {
if !config.GetAccountsAllowCustomCSS() {
err := errors.New("accounts-allow-custom-css is not enabled on this instance")
@@ -55,6 +57,6 @@ func (m *Module) customCSSGETHandler(c *gin.Context) {
return
}
- c.Header("Cache-Control", "no-cache")
- c.Data(http.StatusOK, "text/css; charset=utf-8", []byte(customCSS))
+ c.Header(cacheControlHeader, cacheControlNoCache)
+ c.Data(http.StatusOK, textCSSUTF8, []byte(customCSS))
}
diff --git a/internal/web/etag.go b/internal/web/etag.go
new file mode 100644
index 000000000..37c1cb423
--- /dev/null
+++ b/internal/web/etag.go
@@ -0,0 +1,61 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package web
+
+import (
+ // nolint:gosec
+ "crypto/sha1"
+ "encoding/hex"
+ "io"
+ "time"
+
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+
+ "codeberg.org/gruf/go-cache/v2"
+)
+
+func newETagCache() cache.Cache[string, eTagCacheEntry] {
+ eTagCache := cache.New[string, eTagCacheEntry]()
+ eTagCache.SetTTL(time.Hour, false)
+ if !eTagCache.Start(time.Minute) {
+ log.Panic("could not start eTagCache")
+ }
+ return eTagCache
+}
+
+type eTagCacheEntry struct {
+ eTag string
+ lastModified time.Time
+}
+
+// generateEtag generates a strong (byte-for-byte) etag using
+// the entirety of the provided reader.
+func generateEtag(r io.Reader) (string, error) {
+ // nolint:gosec
+ hash := sha1.New()
+
+ if _, err := io.Copy(hash, r); err != nil {
+ return "", err
+ }
+
+ b := make([]byte, 0, sha1.Size)
+ b = hash.Sum(b)
+
+ return `"` + hex.EncodeToString(b) + `"`, nil
+}
diff --git a/internal/web/profile.go b/internal/web/profile.go
index a1518b517..27de99e13 100644
--- a/internal/web/profile.go
+++ b/internal/web/profile.go
@@ -82,6 +82,11 @@ func (m *Module) profileGETHandler(c *gin.Context) {
return
}
+ var rssFeed string
+ if account.EnableRSS {
+ rssFeed = "/@" + account.Username + "/feed.rss"
+ }
+
// only allow search engines / robots to view this page if account is discoverable
var robotsMeta string
if account.Discoverable {
@@ -118,6 +123,7 @@ func (m *Module) profileGETHandler(c *gin.Context) {
"instance": instance,
"account": account,
"ogMeta": ogBase(instance).withAccount(account),
+ "rssFeed": rssFeed,
"robotsMeta": robotsMeta,
"statuses": statusResp.Items,
"statuses_next": statusResp.NextLink,
diff --git a/internal/web/rss.go b/internal/web/rss.go
new file mode 100644
index 000000000..64be7685c
--- /dev/null
+++ b/internal/web/rss.go
@@ -0,0 +1,154 @@
+/*
+ GoToSocial
+ Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
+
+ 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/>.
+*/
+
+package web
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/superseriousbusiness/gotosocial/internal/api"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/log"
+)
+
+const appRSSUTF8 = string(api.AppRSSXML + "; charset=utf-8")
+
+func (m *Module) GetRSSETag(urlPath string, lastModified time.Time, getRSSFeed func() (string, gtserror.WithCode)) (string, error) {
+ if cachedETag, ok := m.eTagCache.Get(urlPath); ok && !lastModified.After(cachedETag.lastModified) {
+ // only return our cached etag if the file wasn't
+ // modified since last time, otherwise generate a
+ // new one; eat fresh!
+ return cachedETag.eTag, nil
+ }
+
+ rssFeed, errWithCode := getRSSFeed()
+ if errWithCode != nil {
+ return "", fmt.Errorf("error getting rss feed: %s", errWithCode)
+ }
+
+ eTag, err := generateEtag(bytes.NewReader([]byte(rssFeed)))
+ if err != nil {
+ return "", fmt.Errorf("error generating etag: %s", err)
+ }
+
+ // put new entry in cache before we return
+ m.eTagCache.Set(urlPath, eTagCacheEntry{
+ eTag: eTag,
+ lastModified: lastModified,
+ })
+
+ return eTag, nil
+}
+
+func extractIfModifiedSince(header string) time.Time {
+ if header == "" {
+ return time.Time{}
+ }
+
+ t, err := http.ParseTime(header)
+ if err != nil {
+ log.Errorf("couldn't parse if-modified-since %s: %s", header, err)
+ return time.Time{}
+ }
+
+ return t
+}
+
+func (m *Module) rssFeedGETHandler(c *gin.Context) {
+ // set this Cache-Control header to instruct clients to validate the response with us
+ // before each reuse (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
+ c.Header(cacheControlHeader, cacheControlNoCache)
+ ctx := c.Request.Context()
+
+ if _, err := api.NegotiateAccept(c, api.AppRSSXML); err != nil {
+ api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ // usernames on our instance will always be lowercase
+ username := strings.ToLower(c.Param(usernameKey))
+ if username == "" {
+ err := errors.New("no account username specified")
+ api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
+ return
+ }
+
+ ifNoneMatch := c.Request.Header.Get(ifNoneMatchHeader)
+ ifModifiedSince := extractIfModifiedSince(c.Request.Header.Get(ifModifiedSinceHeader))
+
+ getRssFeed, accountLastPostedPublic, errWithCode := m.processor.AccountGetRSSFeedForUsername(ctx, username)
+ if errWithCode != nil {
+ api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ var rssFeed string
+ cacheKey := c.Request.URL.Path
+ cacheEntry, ok := m.eTagCache.Get(cacheKey)
+
+ if !ok || cacheEntry.lastModified.Before(accountLastPostedPublic) {
+ // we either have no cache entry for this, or we have an expired cache entry; generate a new one
+ rssFeed, errWithCode = getRssFeed()
+ if errWithCode != nil {
+ api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+
+ eTag, err := generateEtag(bytes.NewBufferString(rssFeed))
+ if err != nil {
+ api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet)
+ return
+ }
+
+ cacheEntry.lastModified = accountLastPostedPublic
+ cacheEntry.eTag = eTag
+ m.eTagCache.Put(cacheKey, cacheEntry)
+ }
+
+ c.Header(eTagHeader, cacheEntry.eTag)
+ c.Header(lastModifiedHeader, accountLastPostedPublic.Format(http.TimeFormat))
+
+ if ifNoneMatch == cacheEntry.eTag {
+ c.AbortWithStatus(http.StatusNotModified)
+ return
+ }
+
+ lmUnix := cacheEntry.lastModified.Unix()
+ imsUnix := ifModifiedSince.Unix()
+ if lmUnix <= imsUnix {
+ c.AbortWithStatus(http.StatusNotModified)
+ return
+ }
+
+ if rssFeed == "" {
+ // we had a cache entry already so we didn't call to get the rss feed yet
+ rssFeed, errWithCode = getRssFeed()
+ if errWithCode != nil {
+ api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
+ return
+ }
+ }
+
+ c.Data(http.StatusOK, appRSSUTF8, []byte(rssFeed))
+}
diff --git a/internal/web/web.go b/internal/web/web.go
index a816f3f08..cdcf7422f 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -21,7 +21,6 @@ package web
import (
"errors"
"net/http"
- "time"
"codeberg.org/gruf/go-cache/v2"
"github.com/gin-gonic/gin"
@@ -36,6 +35,7 @@ const (
confirmEmailPath = "/" + uris.ConfirmEmailPath
profilePath = "/@:" + usernameKey
customCSSPath = profilePath + "/custom.css"
+ rssFeedPath = profilePath + "/feed.rss"
statusPath = profilePath + "/statuses/:" + statusIDKey
assetsPathPrefix = "/assets"
userPanelPath = "/settings/user"
@@ -44,23 +44,26 @@ const (
tokenParam = "token"
usernameKey = "username"
statusIDKey = "status"
+
+ cacheControlHeader = "Cache-Control" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
+ cacheControlNoCache = "no-cache" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#response_directives
+ ifModifiedSinceHeader = "If-Modified-Since" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
+ ifNoneMatchHeader = "If-None-Match" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
+ eTagHeader = "ETag" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
+ lastModifiedHeader = "Last-Modified" // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
)
// Module implements the api.ClientModule interface for web pages.
type Module struct {
- processor processing.Processor
- assetsETagCache cache.Cache[string, eTagCacheEntry]
+ processor processing.Processor
+ eTagCache cache.Cache[string, eTagCacheEntry]
}
// New returns a new api.ClientModule for web pages.
func New(processor processing.Processor) api.ClientModule {
- assetsETagCache := cache.New[string, eTagCacheEntry]()
- assetsETagCache.SetTTL(time.Hour, false)
- assetsETagCache.Start(time.Minute)
-
return &Module{
- processor: processor,
- assetsETagCache: assetsETagCache,
+ processor: processor,
+ eTagCache: newETagCache(),
}
}
@@ -99,6 +102,8 @@ func (m *Module) Route(s router.Router) error {
// serve custom css at /@username/custom.css
s.AttachHandler(http.MethodGet, customCSSPath, m.customCSSGETHandler)
+ s.AttachHandler(http.MethodGet, rssFeedPath, m.rssFeedGETHandler)
+
// serve statuses
s.AttachHandler(http.MethodGet, statusPath, m.threadGETHandler)