From 80663061d8f361ae4bcea1307a10a40c41174ebe Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Sat, 8 Oct 2022 14:00:39 +0200 Subject: [PATCH] [feature] Add opt-in RSS feed for account's latest Public posts (#897) * start adding rss functionality * add gorilla/feeds dependency * first bash at building rss feed still needs work, this is an interim commit * tidy up a bit * add publicOnly option to GetAccountLastPosted * implement rss endpoint * fix test * add initial user docs for rss * update rss logo * docs update * add rssFeed to frontend * feed -> feed.rss * enableRSS * increase rss logo size a lil bit * add rss toggle * move emojify to text package * fiddle with rss feed formatting * add Text field to test statuses * move status to rss item to typeconverter * update bun schema for enablerss * simplify 304 checking * assume account not rss * update tests * update swagger docs * allow more characters in title, trim nicer * update last posted to be more consistent --- README.md | 1 + docs/api/swagger.yaml | 8 + docs/assets/rss.svg | 18 ++ docs/user_guide/rss.md | 15 ++ go.mod | 3 +- go.sum | 2 + internal/api/client/account/accountupdate.go | 8 +- .../api/client/instance/instancepatch_test.go | 8 +- internal/api/mime.go | 1 + internal/api/model/account.go | 4 + internal/cache/account.go | 1 + internal/db/account.go | 4 +- internal/db/bundb/account.go | 18 +- internal/db/bundb/account_test.go | 8 +- .../20221006114842_add_rss_functionality.go | 46 +++++ internal/gtsmodel/account.go | 1 + internal/processing/account.go | 5 + internal/processing/account/account.go | 3 + internal/processing/account/getrss.go | 108 ++++++++++ internal/processing/account/getrss_test.go | 61 ++++++ internal/processing/account/update.go | 4 + internal/processing/processor.go | 5 + internal/router/template.go | 41 +--- internal/text/emojify.go | 67 +++++++ internal/typeutils/astointernal.go | 4 + internal/typeutils/converter.go | 7 + internal/typeutils/internaltofrontend.go | 3 +- internal/typeutils/internaltofrontend_test.go | 10 +- internal/typeutils/internaltorss.go | 177 +++++++++++++++++ internal/typeutils/internaltorss_test.go | 86 ++++++++ internal/web/assets.go | 86 +++++++- internal/web/assetscache.go | 138 ------------- internal/web/customcss.go | 6 +- internal/web/etag.go | 61 ++++++ internal/web/profile.go | 6 + internal/web/rss.go | 154 +++++++++++++++ internal/web/web.go | 23 ++- mkdocs.yml | 1 + testrig/testmodels.go | 18 ++ vendor/github.com/gorilla/feeds/.travis.yml | 16 ++ vendor/github.com/gorilla/feeds/AUTHORS | 29 +++ vendor/github.com/gorilla/feeds/LICENSE | 22 +++ vendor/github.com/gorilla/feeds/README.md | 185 ++++++++++++++++++ vendor/github.com/gorilla/feeds/atom.go | 169 ++++++++++++++++ vendor/github.com/gorilla/feeds/doc.go | 73 +++++++ vendor/github.com/gorilla/feeds/feed.go | 145 ++++++++++++++ vendor/github.com/gorilla/feeds/json.go | 183 +++++++++++++++++ vendor/github.com/gorilla/feeds/rss.go | 168 ++++++++++++++++ vendor/github.com/gorilla/feeds/test.atom | 92 +++++++++ vendor/github.com/gorilla/feeds/test.rss | 96 +++++++++ .../github.com/gorilla/feeds/to-implement.md | 20 ++ vendor/github.com/gorilla/feeds/uuid.go | 27 +++ vendor/modules.txt | 3 + web/assets/rss.svg | 18 ++ web/source/css/profile.css | 12 +- web/source/settings/lib/api/user.js | 2 +- web/source/settings/user/profile.js | 6 +- web/template/profile.tmpl | 7 +- 58 files changed, 2282 insertions(+), 211 deletions(-) create mode 100644 docs/assets/rss.svg create mode 100644 docs/user_guide/rss.md create mode 100644 internal/db/bundb/migrations/20221006114842_add_rss_functionality.go create mode 100644 internal/processing/account/getrss.go create mode 100644 internal/processing/account/getrss_test.go create mode 100644 internal/text/emojify.go create mode 100644 internal/typeutils/internaltorss.go create mode 100644 internal/typeutils/internaltorss_test.go delete mode 100644 internal/web/assetscache.go create mode 100644 internal/web/etag.go create mode 100644 internal/web/rss.go create mode 100644 vendor/github.com/gorilla/feeds/.travis.yml create mode 100644 vendor/github.com/gorilla/feeds/AUTHORS create mode 100644 vendor/github.com/gorilla/feeds/LICENSE create mode 100644 vendor/github.com/gorilla/feeds/README.md create mode 100644 vendor/github.com/gorilla/feeds/atom.go create mode 100644 vendor/github.com/gorilla/feeds/doc.go create mode 100644 vendor/github.com/gorilla/feeds/feed.go create mode 100644 vendor/github.com/gorilla/feeds/json.go create mode 100644 vendor/github.com/gorilla/feeds/rss.go create mode 100644 vendor/github.com/gorilla/feeds/test.atom create mode 100644 vendor/github.com/gorilla/feeds/test.rss create mode 100644 vendor/github.com/gorilla/feeds/to-implement.md create mode 100644 vendor/github.com/gorilla/feeds/uuid.go create mode 100644 web/assets/rss.svg diff --git a/README.md b/README.md index 37ff31f2..180d5d8a 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,7 @@ The following libraries and frameworks are used by GoToSocial, with gratitude - [google/uuid](https://github.com/google/uuid); UUID generation. [BSD-3-Clause License](https://spdx.org/licenses/BSD-3-Clause.html). - [google/wuffs](https://github.com/google/wuffs); png-stripping code. [Apache-2.0 License](https://spdx.org/licenses/Apache-2.0.html). - [go-playground/validator](https://github.com/go-playground/validator); struct validation. [MIT License](https://spdx.org/licenses/MIT.html). +- [gorilla/feeds](https://github.com/gorilla/feeds); RSS + Atom feed generation. [BSD-2-Clause License](https://spdx.org/licenses/BSD-2-Clause.html). - [gorilla/websocket](https://github.com/gorilla/websocket); Websocket connectivity. [BSD-2-Clause License](https://spdx.org/licenses/BSD-2-Clause.html). - [gruf/go-debug](https://codeberg.org/gruf/go-debug); profiling support in debug builds. [MIT License](https://spdx.org/licenses/MIT.html). - [gruf/go-bytesize](https://codeberg.org/gruf/go-bytesize); byte size parsing / formatting. [MIT License](https://spdx.org/licenses/MIT.html). diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 78c4f055..e09d07f1 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -268,6 +268,10 @@ definitions: $ref: '#/definitions/emoji' type: array x-go-name: Emojis + enable_rss: + description: Account has enabled RSS feed. + type: boolean + x-go-name: EnableRSS fields: description: Additional metadata attached to this account's profile. items: @@ -2576,6 +2580,10 @@ paths: in: formData name: custom_css type: string + - description: Enable RSS feed for this account's Public posts at `/[username]/feed.rss` + in: formData + name: enable_rss + type: boolean produces: - application/json responses: diff --git a/docs/assets/rss.svg b/docs/assets/rss.svg new file mode 100644 index 00000000..11fd98a5 --- /dev/null +++ b/docs/assets/rss.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/user_guide/rss.md b/docs/user_guide/rss.md new file mode 100644 index 00000000..560868c0 --- /dev/null +++ b/docs/user_guide/rss.md @@ -0,0 +1,15 @@ +# RSS + +RSS stands for [Really Simple Syndication](https://en.wikipedia.org/wiki/RSS). It's a very well established standard for sharing content on the web. You might recognize the jolly orange RSS logo from your favorite news websites and blogs: + +![The orange RSS icon](../assets/rss.svg) + +If you like, you can configure your GoToSocial account to expose an RSS feed of your posts to the web. This allows people to get regular updates about your posts even when they don't have a Fediverse account. This is great when you're using GoToSocial to create longer-form, blog style posts, and you want anyone to be able to read them easily. + +The RSS feed for GoToSocial profiles is turned off by default. You can enable it via the [User Settings Panel](./user_panel.md) at `https://[your-instance-domain]/settings`. + +When enabled, the RSS feed for your account will be available at `https://[your-instance-domain]/@[your_username]/feed.rss`. If you use an RSS reader, you can point it at this address to check that RSS is working. + +## Which posts are shared via RSS? + +Only your latest 20 Public posts are shared via RSS. Replies and reblogs/boosts are not included. Unlisted posts are not included. In other words, the only posts visible via RSS will be the same ones that are visible when you open your profile in a browser. diff --git a/go.mod b/go.mod index 1b68e9d0..666496fd 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,6 @@ module github.com/superseriousbusiness/gotosocial go 1.19 require ( - codeberg.org/gruf/go-atomics v1.1.0 codeberg.org/gruf/go-bytesize v1.0.0 codeberg.org/gruf/go-byteutil v1.0.2 codeberg.org/gruf/go-cache/v2 v2.1.4 @@ -24,6 +23,7 @@ require ( github.com/go-fed/httpsig v1.1.0 github.com/go-playground/validator/v10 v10.11.0 github.com/google/uuid v1.3.0 + github.com/gorilla/feeds v1.1.1 github.com/gorilla/websocket v1.5.0 github.com/h2non/filetype v1.1.3 github.com/jackc/pgconn v1.13.0 @@ -58,6 +58,7 @@ require ( ) require ( + codeberg.org/gruf/go-atomics v1.1.0 // indirect codeberg.org/gruf/go-bitutil v1.0.1 // indirect codeberg.org/gruf/go-bytes v1.0.2 // indirect codeberg.org/gruf/go-fastcopy v1.1.1 // indirect diff --git a/go.sum b/go.sum index 4ec6e919..c5fcd2e6 100644 --- a/go.sum +++ b/go.sum @@ -333,6 +333,8 @@ github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8 github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= +github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= diff --git a/internal/api/client/account/accountupdate.go b/internal/api/client/account/accountupdate.go index e2b2a731..f89259a9 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 ade5cd40..50b19c07 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 b495b059..d02b64ab 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 b085e84e..f8bb4f4a 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 12675b6b..c25db42c 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 ae5eea7c..a58aa9dd 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 c04948fe..4813f4e1 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 72adba48..29594a74 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 00000000..94c21fe5 --- /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 . +*/ + +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 ca5c7420..c964b83a 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 ada51113..6cba8b9c 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 aca46394..b18e705c 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 00000000..f07204b5 --- /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 . +*/ + +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 00000000..dc81434a --- /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 . +*/ + +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("\n \n Posts from @admin@localhost:8080\n http://localhost:8080/@admin\n Posts from @admin@localhost:8080\n Wed, 20 Oct 2021 12:36:45 +0000\n Wed, 20 Oct 2021 12:36:45 +0000\n \n open to see some puppies\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n @admin@localhost:8080 made a new post: "πŸ•πŸ•πŸ•πŸ•πŸ•"\n \n @admin@localhost:8080\n http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37\n Wed, 20 Oct 2021 12:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n hello world! #welcome ! first post on the instance :rainbow: !\n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n @admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"\n !]]>\n @admin@localhost:8080\n \n http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R\n Wed, 20 Oct 2021 11:36:45 +0000\n http://localhost:8080/@admin/feed.rss\n \n \n", 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("\n \n Posts from @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork\n Posts from @the_mighty_zork@localhost:8080\n Wed, 20 Oct 2021 10:40:37 +0000\n Wed, 20 Oct 2021 10:40:37 +0000\n \n http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg\n Avatar for @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork\n \n \n introduction post\n http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY\n @the_mighty_zork@localhost:8080 made a new post: "hello everyone!"\n \n @the_mighty_zork@localhost:8080\n http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY\n Wed, 20 Oct 2021 10:40:37 +0000\n http://localhost:8080/@the_mighty_zork/feed.rss\n \n \n", 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 94e91ca4..f39361c0 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 81ed3c8e..09bb579b 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 fcdccf78..e87f6b69 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(``, 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(`:`)
-			buf.WriteString(safeCode)
-			buf.WriteString(`:`) - - 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 00000000..c9e25e5f --- /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 . +*/ + +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(`:`)
+			buf.WriteString(safeCode)
+			buf.WriteString(`:`) + + return buf.String() + }, + ) +} diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index 27464809..c44f4ebe 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 3effb938..b1a77145 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" @@ -83,6 +84,12 @@ type TypeConverter interface { // DomainBlockToAPIDomainBlock converts a gts model domin block into a api domain block, for serving at /api/v1/admin/domain_blocks 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 778b73dc..09bd5fc7 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 a13e5255..9dd8ed4e 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 00000000..609725d7 --- /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 . +*/ + +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 := >smodel.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 00000000..e30304ee --- /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 . +*/ + +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 \":rainbow:\" !", 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 39787086..aab4346e 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 fccc9599..00000000 --- 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 . -*/ - -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 34e15844..48f8c0f7 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 00000000..37c1cb42 --- /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 . +*/ + +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 a1518b51..27de99e1 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 00000000..64be7685 --- /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 . +*/ + +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 a816f3f0..cdcf7422 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) diff --git a/mkdocs.yml b/mkdocs.yml index 49fd45fe..372edfeb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ nav: - "user_guide/user_panel.md" - "user_guide/custom_css.md" - "user_guide/password_management.md" + - "user_guide/rss.md" - "Installation Guide": - "installation_guide/index.md" - "installation_guide/binary.md" diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 054f1432..fa2eabf0 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -380,6 +380,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { SuspendedAt: time.Time{}, HideCollections: FalseBool(), SuspensionOrigin: "", + EnableRSS: TrueBool(), }, "local_account_1": { ID: "01F8MH1H7YV1Z7D2C8K2730QBF", @@ -419,6 +420,7 @@ func NewTestAccounts() map[string]*gtsmodel.Account { SuspendedAt: time.Time{}, HideCollections: FalseBool(), SuspensionOrigin: "", + EnableRSS: TrueBool(), }, "local_account_2": { ID: "01F8MH5NBDF2MV7CTC4Q5128HF", @@ -1092,6 +1094,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", URL: "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", Content: "hello world! #welcome ! first post on the instance :rainbow: !", + Text: "hello world! #welcome ! first post on the instance :rainbow: !", AttachmentIDs: []string{"01F8MH6NEM8D7527KZAECTCR76"}, TagIDs: []string{"01F8MHA1A2NF9MJ3WCCQ3K8BSZ"}, MentionIDs: []string{}, @@ -1120,6 +1123,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/admin/statuses/01F8MHAAY43M6RJ473VQFCVH37", URL: "http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37", Content: "πŸ•πŸ•πŸ•πŸ•πŸ•", + Text: "πŸ•πŸ•πŸ•πŸ•πŸ•", CreatedAt: TimeMustParse("2021-10-20T12:36:45Z"), UpdatedAt: TimeMustParse("2021-10-20T12:36:45Z"), Local: TrueBool(), @@ -1144,6 +1148,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0", URL: "http://localhost:8080/@admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0", Content: "hi @the_mighty_zork welcome to the instance!", + Text: "hi @the_mighty_zork welcome to the instance!", CreatedAt: TimeMustParse("2021-11-20T13:32:16Z"), UpdatedAt: TimeMustParse("2021-11-20T13:32:16Z"), Local: TrueBool(), @@ -1170,6 +1175,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/admin/statuses/01G36SF3V6Y6V5BF9P4R7PQG7G", URL: "http://localhost:8080/@admin/statuses/01G36SF3V6Y6V5BF9P4R7PQG7G", Content: "hello everyone!", + Text: "hello everyone!", CreatedAt: TimeMustParse("2021-10-20T12:41:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:41:37+02:00"), Local: TrueBool(), @@ -1197,6 +1203,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", Content: "hello everyone!", + Text: "hello everyone!", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1221,6 +1228,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHAYFKS4KMXF8K5Y1C0KRN", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAYFKS4KMXF8K5Y1C0KRN", Content: "this is an unlocked local-only post that shouldn't federate, but it's still boostable, replyable, and likeable", + Text: "this is an unlocked local-only post that shouldn't federate, but it's still boostable, replyable, and likeable", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1245,6 +1253,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MHBBN8120SYH7D5S050MGK", Content: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", + Text: "this is a very personal post that I don't want anyone to interact with at all, and i only want mutuals to see it", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1269,6 +1278,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/the_mighty_zork/statuses/01F8MH82FYRXD2RC6108DAJ5HB", URL: "http://localhost:8080/@the_mighty_zork/statuses/01F8MH82FYRXD2RC6108DAJ5HB", Content: "here's a little gif of trent", + Text: "here's a little gif of trent", AttachmentIDs: []string{"01F8MH7TDVANYKWVE8VVKFPJTJ"}, CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), @@ -1294,6 +1304,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/the_mighty_zork/statuses/01FCTA44PW9H1TB328S9AQXKDS", URL: "http://localhost:8080/@the_mighty_zork/statuses/01FCTA44PW9H1TB328S9AQXKDS", Content: "hi!", + Text: "hi!", AttachmentIDs: []string{}, CreatedAt: TimeMustParse("2022-05-20T11:37:55Z"), UpdatedAt: TimeMustParse("2022-05-20T11:37:55Z"), @@ -1319,6 +1330,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01F8MHBQCBTDKN6X5VHGMMN4MA", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHBQCBTDKN6X5VHGMMN4MA", Content: "🐒 hi everyone i post about turtles 🐒", + Text: "🐒 hi everyone i post about turtles 🐒", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1343,6 +1355,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01F8MHC0H0A7XHTVH5F596ZKBM", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHC0H0A7XHTVH5F596ZKBM", Content: "🐒 this one is federated, likeable, and boostable but not replyable 🐒", + Text: "🐒 this one is federated, likeable, and boostable but not replyable 🐒", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1367,6 +1380,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHC8VWDRBQR0N1BATDDEM5", Content: "🐒 i don't mind people sharing this one but I don't want likes or replies to it because cba🐒", + Text: "🐒 i don't mind people sharing this one but I don't want likes or replies to it because cba🐒", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1391,6 +1405,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01F8MHCP5P2NWYQ416SBA0XSEV", URL: "http://localhost:8080/@1happyturtle/statuses/01F8MHCP5P2NWYQ416SBA0XSEV", Content: "🐒 this is a public status but I want it local only and not boostable 🐒", + Text: "🐒 this is a public status but I want it local only and not boostable 🐒", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1416,6 +1431,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01FCQSQ667XHJ9AV9T27SJJSX5", URL: "http://localhost:8080/@1happyturtle/statuses/01FCQSQ667XHJ9AV9T27SJJSX5", Content: "🐒 @the_mighty_zork hi zork! 🐒", + Text: "🐒 @the_mighty_zork hi zork! 🐒", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1443,6 +1459,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01FN3VJGFH10KR7S2PB0GFJZYG", URL: "http://localhost:8080/@1happyturtle/statuses/01FN3VJGFH10KR7S2PB0GFJZYG", Content: "🐒 @the_mighty_zork hi zork, this is a direct message, shhhhhh! 🐒", + Text: "🐒 @the_mighty_zork hi zork, this is a direct message, shhhhhh! 🐒", CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), Local: TrueBool(), @@ -1470,6 +1487,7 @@ func NewTestStatuses() map[string]*gtsmodel.Status { URI: "http://localhost:8080/users/1happyturtle/statuses/01G20ZM733MGN8J344T4ZDDFY1", URL: "http://localhost:8080/@1happyturtle/statuses/01G20ZM733MGN8J344T4ZDDFY1", Content: "🐒 hi followers! did u know i'm a turtle? 🐒", + Text: "🐒 hi followers! did u know i'm a turtle? 🐒", AttachmentIDs: []string{}, CreatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), UpdatedAt: TimeMustParse("2021-10-20T12:40:37+02:00"), diff --git a/vendor/github.com/gorilla/feeds/.travis.yml b/vendor/github.com/gorilla/feeds/.travis.yml new file mode 100644 index 00000000..7939a218 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/.travis.yml @@ -0,0 +1,16 @@ +language: go +sudo: false +matrix: + include: + - go: 1.8 + - go: 1.9 + - go: "1.10" + - go: 1.x + - go: tip + allow_failures: + - go: tip +script: + - go get -t -v ./... + - diff -u <(echo -n) <(gofmt -d -s .) + - go vet . + - go test -v -race ./... diff --git a/vendor/github.com/gorilla/feeds/AUTHORS b/vendor/github.com/gorilla/feeds/AUTHORS new file mode 100644 index 00000000..2c28cf94 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/AUTHORS @@ -0,0 +1,29 @@ +# This is the official list of gorilla/feeds authors for copyright purposes. +# Please keep the list sorted. + +Dmitry Chestnykh +Eddie Scholtz +Gabriel Simmer +Google LLC (https://opensource.google.com/) +honky +James Gregory +Jason Hall +Jason Moiron +Kamil Kisiel +Kevin Stock +Markus Zimmermann +Matt Silverlock +Matthew Dawson +Milan Aleksic +Milan AleksiΔ‡ +nlimpid +Paul Petring +Sean Enck +Sue Spence +Supermighty +Toru Fukui +Vabd +Volker +ZhiFeng Hu +weberc2 + diff --git a/vendor/github.com/gorilla/feeds/LICENSE b/vendor/github.com/gorilla/feeds/LICENSE new file mode 100644 index 00000000..e24412d5 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2013-2018 The Gorilla Feeds Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/gorilla/feeds/README.md b/vendor/github.com/gorilla/feeds/README.md new file mode 100644 index 00000000..4d733cf5 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/README.md @@ -0,0 +1,185 @@ +## gorilla/feeds +[![GoDoc](https://godoc.org/github.com/gorilla/feeds?status.svg)](https://godoc.org/github.com/gorilla/feeds) +[![Build Status](https://travis-ci.org/gorilla/feeds.svg?branch=master)](https://travis-ci.org/gorilla/feeds) + +feeds is a web feed generator library for generating RSS, Atom and JSON feeds from Go +applications. + +### Goals + + * Provide a simple interface to create both Atom & RSS 2.0 feeds + * Full support for [Atom][atom], [RSS 2.0][rss], and [JSON Feed Version 1][jsonfeed] spec elements + * Ability to modify particulars for each spec + +[atom]: https://tools.ietf.org/html/rfc4287 +[rss]: http://www.rssboard.org/rss-specification +[jsonfeed]: https://jsonfeed.org/version/1 + +### Usage + +```go +package main + +import ( + "fmt" + "log" + "time" + "github.com/gorilla/feeds" +) + +func main() { + now := time.Now() + feed := &feeds.Feed{ + Title: "jmoiron.net blog", + Link: &feeds.Link{Href: "http://jmoiron.net/blog"}, + Description: "discussion about tech, footie, photos", + Author: &feeds.Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + } + + feed.Items = []*feeds.Item{ + &feeds.Item{ + Title: "Limiting Concurrency in Go", + Link: &feeds.Link{Href: "http://jmoiron.net/blog/limiting-concurrency-in-go/"}, + Description: "A discussion on controlled parallelism in golang", + Author: &feeds.Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + }, + &feeds.Item{ + Title: "Logic-less Template Redux", + Link: &feeds.Link{Href: "http://jmoiron.net/blog/logicless-template-redux/"}, + Description: "More thoughts on logicless templates", + Created: now, + }, + &feeds.Item{ + Title: "Idiomatic Code Reuse in Go", + Link: &feeds.Link{Href: "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"}, + Description: "How to use interfaces effectively", + Created: now, + }, + } + + atom, err := feed.ToAtom() + if err != nil { + log.Fatal(err) + } + + rss, err := feed.ToRss() + if err != nil { + log.Fatal(err) + } + + json, err := feed.ToJSON() + if err != nil { + log.Fatal(err) + } + + fmt.Println(atom, "\n", rss, "\n", json) +} +``` + +Outputs: + +```xml + + + jmoiron.net blog + + http://jmoiron.net/blog + 2013-01-16T03:26:01-05:00 + discussion about tech, footie, photos + + Limiting Concurrency in Go + + 2013-01-16T03:26:01-05:00 + tag:jmoiron.net,2013-01-16:/blog/limiting-concurrency-in-go/ + A discussion on controlled parallelism in golang + + Jason Moiron + jmoiron@jmoiron.net + + + + Logic-less Template Redux + + 2013-01-16T03:26:01-05:00 + tag:jmoiron.net,2013-01-16:/blog/logicless-template-redux/ + More thoughts on logicless templates + + + + Idiomatic Code Reuse in Go + + 2013-01-16T03:26:01-05:00 + tag:jmoiron.net,2013-01-16:/blog/idiomatic-code-reuse-in-go/ + How to use interfaces <em>effectively</em> + + + + + + + + jmoiron.net blog + http://jmoiron.net/blog + discussion about tech, footie, photos + jmoiron@jmoiron.net (Jason Moiron) + 2013-01-16T03:22:24-05:00 + + Limiting Concurrency in Go + http://jmoiron.net/blog/limiting-concurrency-in-go/ + A discussion on controlled parallelism in golang + 2013-01-16T03:22:24-05:00 + + + Logic-less Template Redux + http://jmoiron.net/blog/logicless-template-redux/ + More thoughts on logicless templates + 2013-01-16T03:22:24-05:00 + + + Idiomatic Code Reuse in Go + http://jmoiron.net/blog/idiomatic-code-reuse-in-go/ + How to use interfaces <em>effectively</em> + 2013-01-16T03:22:24-05:00 + + + + +{ + "version": "https://jsonfeed.org/version/1", + "title": "jmoiron.net blog", + "home_page_url": "http://jmoiron.net/blog", + "description": "discussion about tech, footie, photos", + "author": { + "name": "Jason Moiron" + }, + "items": [ + { + "id": "", + "url": "http://jmoiron.net/blog/limiting-concurrency-in-go/", + "title": "Limiting Concurrency in Go", + "summary": "A discussion on controlled parallelism in golang", + "date_published": "2013-01-16T03:22:24.530817846-05:00", + "author": { + "name": "Jason Moiron" + } + }, + { + "id": "", + "url": "http://jmoiron.net/blog/logicless-template-redux/", + "title": "Logic-less Template Redux", + "summary": "More thoughts on logicless templates", + "date_published": "2013-01-16T03:22:24.530817846-05:00" + }, + { + "id": "", + "url": "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/", + "title": "Idiomatic Code Reuse in Go", + "summary": "How to use interfaces \u003cem\u003eeffectively\u003c/em\u003e", + "date_published": "2013-01-16T03:22:24.530817846-05:00" + } + ] +} +``` + diff --git a/vendor/github.com/gorilla/feeds/atom.go b/vendor/github.com/gorilla/feeds/atom.go new file mode 100644 index 00000000..7196f478 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/atom.go @@ -0,0 +1,169 @@ +package feeds + +import ( + "encoding/xml" + "fmt" + "net/url" + "time" +) + +// Generates Atom feed as XML + +const ns = "http://www.w3.org/2005/Atom" + +type AtomPerson struct { + Name string `xml:"name,omitempty"` + Uri string `xml:"uri,omitempty"` + Email string `xml:"email,omitempty"` +} + +type AtomSummary struct { + XMLName xml.Name `xml:"summary"` + Content string `xml:",chardata"` + Type string `xml:"type,attr"` +} + +type AtomContent struct { + XMLName xml.Name `xml:"content"` + Content string `xml:",chardata"` + Type string `xml:"type,attr"` +} + +type AtomAuthor struct { + XMLName xml.Name `xml:"author"` + AtomPerson +} + +type AtomContributor struct { + XMLName xml.Name `xml:"contributor"` + AtomPerson +} + +type AtomEntry struct { + XMLName xml.Name `xml:"entry"` + Xmlns string `xml:"xmlns,attr,omitempty"` + Title string `xml:"title"` // required + Updated string `xml:"updated"` // required + Id string `xml:"id"` // required + Category string `xml:"category,omitempty"` + Content *AtomContent + Rights string `xml:"rights,omitempty"` + Source string `xml:"source,omitempty"` + Published string `xml:"published,omitempty"` + Contributor *AtomContributor + Links []AtomLink // required if no child 'content' elements + Summary *AtomSummary // required if content has src or content is base64 + Author *AtomAuthor // required if feed lacks an author +} + +// Multiple links with different rel can coexist +type AtomLink struct { + //Atom 1.0 + XMLName xml.Name `xml:"link"` + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Length string `xml:"length,attr,omitempty"` +} + +type AtomFeed struct { + XMLName xml.Name `xml:"feed"` + Xmlns string `xml:"xmlns,attr"` + Title string `xml:"title"` // required + Id string `xml:"id"` // required + Updated string `xml:"updated"` // required + Category string `xml:"category,omitempty"` + Icon string `xml:"icon,omitempty"` + Logo string `xml:"logo,omitempty"` + Rights string `xml:"rights,omitempty"` // copyright used + Subtitle string `xml:"subtitle,omitempty"` + Link *AtomLink + Author *AtomAuthor `xml:"author,omitempty"` + Contributor *AtomContributor + Entries []*AtomEntry `xml:"entry"` +} + +type Atom struct { + *Feed +} + +func newAtomEntry(i *Item) *AtomEntry { + id := i.Id + // assume the description is html + s := &AtomSummary{Content: i.Description, Type: "html"} + + if len(id) == 0 { + // if there's no id set, try to create one, either from data or just a uuid + if len(i.Link.Href) > 0 && (!i.Created.IsZero() || !i.Updated.IsZero()) { + dateStr := anyTimeFormat("2006-01-02", i.Updated, i.Created) + host, path := i.Link.Href, "/invalid.html" + if url, err := url.Parse(i.Link.Href); err == nil { + host, path = url.Host, url.Path + } + id = fmt.Sprintf("tag:%s,%s:%s", host, dateStr, path) + } else { + id = "urn:uuid:" + NewUUID().String() + } + } + var name, email string + if i.Author != nil { + name, email = i.Author.Name, i.Author.Email + } + + link_rel := i.Link.Rel + if link_rel == "" { + link_rel = "alternate" + } + x := &AtomEntry{ + Title: i.Title, + Links: []AtomLink{{Href: i.Link.Href, Rel: link_rel, Type: i.Link.Type}}, + Id: id, + Updated: anyTimeFormat(time.RFC3339, i.Updated, i.Created), + Summary: s, + } + + // if there's a content, assume it's html + if len(i.Content) > 0 { + x.Content = &AtomContent{Content: i.Content, Type: "html"} + } + + if i.Enclosure != nil && link_rel != "enclosure" { + x.Links = append(x.Links, AtomLink{Href: i.Enclosure.Url, Rel: "enclosure", Type: i.Enclosure.Type, Length: i.Enclosure.Length}) + } + + if len(name) > 0 || len(email) > 0 { + x.Author = &AtomAuthor{AtomPerson: AtomPerson{Name: name, Email: email}} + } + return x +} + +// create a new AtomFeed with a generic Feed struct's data +func (a *Atom) AtomFeed() *AtomFeed { + updated := anyTimeFormat(time.RFC3339, a.Updated, a.Created) + feed := &AtomFeed{ + Xmlns: ns, + Title: a.Title, + Link: &AtomLink{Href: a.Link.Href, Rel: a.Link.Rel}, + Subtitle: a.Description, + Id: a.Link.Href, + Updated: updated, + Rights: a.Copyright, + } + if a.Author != nil { + feed.Author = &AtomAuthor{AtomPerson: AtomPerson{Name: a.Author.Name, Email: a.Author.Email}} + } + for _, e := range a.Items { + feed.Entries = append(feed.Entries, newAtomEntry(e)) + } + return feed +} + +// FeedXml returns an XML-Ready object for an Atom object +func (a *Atom) FeedXml() interface{} { + return a.AtomFeed() +} + +// FeedXml returns an XML-ready object for an AtomFeed object +func (a *AtomFeed) FeedXml() interface{} { + return a +} diff --git a/vendor/github.com/gorilla/feeds/doc.go b/vendor/github.com/gorilla/feeds/doc.go new file mode 100644 index 00000000..4e0759cc --- /dev/null +++ b/vendor/github.com/gorilla/feeds/doc.go @@ -0,0 +1,73 @@ +/* +Syndication (feed) generator library for golang. + +Installing + + go get github.com/gorilla/feeds + +Feeds provides a simple, generic Feed interface with a generic Item object as well as RSS, Atom and JSON Feed specific RssFeed, AtomFeed and JSONFeed objects which allow access to all of each spec's defined elements. + +Examples + +Create a Feed and some Items in that feed using the generic interfaces: + + import ( + "time" + . "github.com/gorilla/feeds" + ) + + now = time.Now() + + feed := &Feed{ + Title: "jmoiron.net blog", + Link: &Link{Href: "http://jmoiron.net/blog"}, + Description: "discussion about tech, footie, photos", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + Copyright: "This work is copyright Β© Benjamin Button", + } + + feed.Items = []*Item{ + &Item{ + Title: "Limiting Concurrency in Go", + Link: &Link{Href: "http://jmoiron.net/blog/limiting-concurrency-in-go/"}, + Description: "A discussion on controlled parallelism in golang", + Author: &Author{Name: "Jason Moiron", Email: "jmoiron@jmoiron.net"}, + Created: now, + }, + &Item{ + Title: "Logic-less Template Redux", + Link: &Link{Href: "http://jmoiron.net/blog/logicless-template-redux/"}, + Description: "More thoughts on logicless templates", + Created: now, + }, + &Item{ + Title: "Idiomatic Code Reuse in Go", + Link: &Link{Href: "http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"}, + Description: "How to use interfaces effectively", + Created: now, + }, + } + +From here, you can output Atom, RSS, or JSON Feed versions of this feed easily + + atom, err := feed.ToAtom() + rss, err := feed.ToRss() + json, err := feed.ToJSON() + +You can also get access to the underlying objects that feeds uses to export its XML + + atomFeed := (&Atom{Feed: feed}).AtomFeed() + rssFeed := (&Rss{Feed: feed}).RssFeed() + jsonFeed := (&JSON{Feed: feed}).JSONFeed() + +From here, you can modify or add each syndication's specific fields before outputting + + atomFeed.Subtitle = "plays the blues" + atom, err := ToXML(atomFeed) + rssFeed.Generator = "gorilla/feeds v1.0 (github.com/gorilla/feeds)" + rss, err := ToXML(rssFeed) + jsonFeed.NextUrl = "https://www.example.com/feed.json?page=2" + json, err := jsonFeed.ToJSON() +*/ +package feeds diff --git a/vendor/github.com/gorilla/feeds/feed.go b/vendor/github.com/gorilla/feeds/feed.go new file mode 100644 index 00000000..790a1b6c --- /dev/null +++ b/vendor/github.com/gorilla/feeds/feed.go @@ -0,0 +1,145 @@ +package feeds + +import ( + "encoding/json" + "encoding/xml" + "io" + "sort" + "time" +) + +type Link struct { + Href, Rel, Type, Length string +} + +type Author struct { + Name, Email string +} + +type Image struct { + Url, Title, Link string + Width, Height int +} + +type Enclosure struct { + Url, Length, Type string +} + +type Item struct { + Title string + Link *Link + Source *Link + Author *Author + Description string // used as description in rss, summary in atom + Id string // used as guid in rss, id in atom + Updated time.Time + Created time.Time + Enclosure *Enclosure + Content string +} + +type Feed struct { + Title string + Link *Link + Description string + Author *Author + Updated time.Time + Created time.Time + Id string + Subtitle string + Items []*Item + Copyright string + Image *Image +} + +// add a new Item to a Feed +func (f *Feed) Add(item *Item) { + f.Items = append(f.Items, item) +} + +// returns the first non-zero time formatted as a string or "" +func anyTimeFormat(format string, times ...time.Time) string { + for _, t := range times { + if !t.IsZero() { + return t.Format(format) + } + } + return "" +} + +// interface used by ToXML to get a object suitable for exporting XML. +type XmlFeed interface { + FeedXml() interface{} +} + +// turn a feed object (either a Feed, AtomFeed, or RssFeed) into xml +// returns an error if xml marshaling fails +func ToXML(feed XmlFeed) (string, error) { + x := feed.FeedXml() + data, err := xml.MarshalIndent(x, "", " ") + if err != nil { + return "", err + } + // strip empty line from default xml header + s := xml.Header[:len(xml.Header)-1] + string(data) + return s, nil +} + +// WriteXML writes a feed object (either a Feed, AtomFeed, or RssFeed) as XML into +// the writer. Returns an error if XML marshaling fails. +func WriteXML(feed XmlFeed, w io.Writer) error { + x := feed.FeedXml() + // write default xml header, without the newline + if _, err := w.Write([]byte(xml.Header[:len(xml.Header)-1])); err != nil { + return err + } + e := xml.NewEncoder(w) + e.Indent("", " ") + return e.Encode(x) +} + +// creates an Atom representation of this feed +func (f *Feed) ToAtom() (string, error) { + a := &Atom{f} + return ToXML(a) +} + +// WriteAtom writes an Atom representation of this feed to the writer. +func (f *Feed) WriteAtom(w io.Writer) error { + return WriteXML(&Atom{f}, w) +} + +// creates an Rss representation of this feed +func (f *Feed) ToRss() (string, error) { + r := &Rss{f} + return ToXML(r) +} + +// WriteRss writes an RSS representation of this feed to the writer. +func (f *Feed) WriteRss(w io.Writer) error { + return WriteXML(&Rss{f}, w) +} + +// ToJSON creates a JSON Feed representation of this feed +func (f *Feed) ToJSON() (string, error) { + j := &JSON{f} + return j.ToJSON() +} + +// WriteJSON writes an JSON representation of this feed to the writer. +func (f *Feed) WriteJSON(w io.Writer) error { + j := &JSON{f} + feed := j.JSONFeed() + + e := json.NewEncoder(w) + e.SetIndent("", " ") + return e.Encode(feed) +} + +// Sort sorts the Items in the feed with the given less function. +func (f *Feed) Sort(less func(a, b *Item) bool) { + lessFunc := func(i, j int) bool { + return less(f.Items[i], f.Items[j]) + } + sort.SliceStable(f.Items, lessFunc) +} diff --git a/vendor/github.com/gorilla/feeds/json.go b/vendor/github.com/gorilla/feeds/json.go new file mode 100644 index 00000000..75a82fd6 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/json.go @@ -0,0 +1,183 @@ +package feeds + +import ( + "encoding/json" + "strings" + "time" +) + +const jsonFeedVersion = "https://jsonfeed.org/version/1" + +// JSONAuthor represents the author of the feed or of an individual item +// in the feed +type JSONAuthor struct { + Name string `json:"name,omitempty"` + Url string `json:"url,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +// JSONAttachment represents a related resource. Podcasts, for instance, would +// include an attachment that’s an audio or video file. +type JSONAttachment struct { + Url string `json:"url,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + Title string `json:"title,omitempty"` + Size int32 `json:"size,omitempty"` + Duration time.Duration `json:"duration_in_seconds,omitempty"` +} + +// MarshalJSON implements the json.Marshaler interface. +// The Duration field is marshaled in seconds, all other fields are marshaled +// based upon the definitions in struct tags. +func (a *JSONAttachment) MarshalJSON() ([]byte, error) { + type EmbeddedJSONAttachment JSONAttachment + return json.Marshal(&struct { + Duration float64 `json:"duration_in_seconds,omitempty"` + *EmbeddedJSONAttachment + }{ + EmbeddedJSONAttachment: (*EmbeddedJSONAttachment)(a), + Duration: a.Duration.Seconds(), + }) +} + +// UnmarshalJSON implements the json.Unmarshaler interface. +// The Duration field is expected to be in seconds, all other field types +// match the struct definition. +func (a *JSONAttachment) UnmarshalJSON(data []byte) error { + type EmbeddedJSONAttachment JSONAttachment + var raw struct { + Duration float64 `json:"duration_in_seconds,omitempty"` + *EmbeddedJSONAttachment + } + raw.EmbeddedJSONAttachment = (*EmbeddedJSONAttachment)(a) + + err := json.Unmarshal(data, &raw) + if err != nil { + return err + } + + if raw.Duration > 0 { + nsec := int64(raw.Duration * float64(time.Second)) + raw.EmbeddedJSONAttachment.Duration = time.Duration(nsec) + } + + return nil +} + +// JSONItem represents a single entry/post for the feed. +type JSONItem struct { + Id string `json:"id"` + Url string `json:"url,omitempty"` + ExternalUrl string `json:"external_url,omitempty"` + Title string `json:"title,omitempty"` + ContentHTML string `json:"content_html,omitempty"` + ContentText string `json:"content_text,omitempty"` + Summary string `json:"summary,omitempty"` + Image string `json:"image,omitempty"` + BannerImage string `json:"banner_,omitempty"` + PublishedDate *time.Time `json:"date_published,omitempty"` + ModifiedDate *time.Time `json:"date_modified,omitempty"` + Author *JSONAuthor `json:"author,omitempty"` + Tags []string `json:"tags,omitempty"` + Attachments []JSONAttachment `json:"attachments,omitempty"` +} + +// JSONHub describes an endpoint that can be used to subscribe to real-time +// notifications from the publisher of this feed. +type JSONHub struct { + Type string `json:"type"` + Url string `json:"url"` +} + +// JSONFeed represents a syndication feed in the JSON Feed Version 1 format. +// Matching the specification found here: https://jsonfeed.org/version/1. +type JSONFeed struct { + Version string `json:"version"` + Title string `json:"title"` + HomePageUrl string `json:"home_page_url,omitempty"` + FeedUrl string `json:"feed_url,omitempty"` + Description string `json:"description,omitempty"` + UserComment string `json:"user_comment,omitempty"` + NextUrl string `json:"next_url,omitempty"` + Icon string `json:"icon,omitempty"` + Favicon string `json:"favicon,omitempty"` + Author *JSONAuthor `json:"author,omitempty"` + Expired *bool `json:"expired,omitempty"` + Hubs []*JSONItem `json:"hubs,omitempty"` + Items []*JSONItem `json:"items,omitempty"` +} + +// JSON is used to convert a generic Feed to a JSONFeed. +type JSON struct { + *Feed +} + +// ToJSON encodes f into a JSON string. Returns an error if marshalling fails. +func (f *JSON) ToJSON() (string, error) { + return f.JSONFeed().ToJSON() +} + +// ToJSON encodes f into a JSON string. Returns an error if marshalling fails. +func (f *JSONFeed) ToJSON() (string, error) { + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return "", err + } + + return string(data), nil +} + +// JSONFeed creates a new JSONFeed with a generic Feed struct's data. +func (f *JSON) JSONFeed() *JSONFeed { + feed := &JSONFeed{ + Version: jsonFeedVersion, + Title: f.Title, + Description: f.Description, + } + + if f.Link != nil { + feed.HomePageUrl = f.Link.Href + } + if f.Author != nil { + feed.Author = &JSONAuthor{ + Name: f.Author.Name, + } + } + for _, e := range f.Items { + feed.Items = append(feed.Items, newJSONItem(e)) + } + return feed +} + +func newJSONItem(i *Item) *JSONItem { + item := &JSONItem{ + Id: i.Id, + Title: i.Title, + Summary: i.Description, + + ContentHTML: i.Content, + } + + if i.Link != nil { + item.Url = i.Link.Href + } + if i.Source != nil { + item.ExternalUrl = i.Source.Href + } + if i.Author != nil { + item.Author = &JSONAuthor{ + Name: i.Author.Name, + } + } + if !i.Created.IsZero() { + item.PublishedDate = &i.Created + } + if !i.Updated.IsZero() { + item.ModifiedDate = &i.Updated + } + if i.Enclosure != nil && strings.HasPrefix(i.Enclosure.Type, "image/") { + item.Image = i.Enclosure.Url + } + + return item +} diff --git a/vendor/github.com/gorilla/feeds/rss.go b/vendor/github.com/gorilla/feeds/rss.go new file mode 100644 index 00000000..09179dfb --- /dev/null +++ b/vendor/github.com/gorilla/feeds/rss.go @@ -0,0 +1,168 @@ +package feeds + +// rss support +// validation done according to spec here: +// http://cyber.law.harvard.edu/rss/rss.html + +import ( + "encoding/xml" + "fmt" + "time" +) + +// private wrapper around the RssFeed which gives us the .. xml +type RssFeedXml struct { + XMLName xml.Name `xml:"rss"` + Version string `xml:"version,attr"` + ContentNamespace string `xml:"xmlns:content,attr"` + Channel *RssFeed +} + +type RssContent struct { + XMLName xml.Name `xml:"content:encoded"` + Content string `xml:",cdata"` +} + +type RssImage struct { + XMLName xml.Name `xml:"image"` + Url string `xml:"url"` + Title string `xml:"title"` + Link string `xml:"link"` + Width int `xml:"width,omitempty"` + Height int `xml:"height,omitempty"` +} + +type RssTextInput struct { + XMLName xml.Name `xml:"textInput"` + Title string `xml:"title"` + Description string `xml:"description"` + Name string `xml:"name"` + Link string `xml:"link"` +} + +type RssFeed struct { + XMLName xml.Name `xml:"channel"` + Title string `xml:"title"` // required + Link string `xml:"link"` // required + Description string `xml:"description"` // required + Language string `xml:"language,omitempty"` + Copyright string `xml:"copyright,omitempty"` + ManagingEditor string `xml:"managingEditor,omitempty"` // Author used + WebMaster string `xml:"webMaster,omitempty"` + PubDate string `xml:"pubDate,omitempty"` // created or updated + LastBuildDate string `xml:"lastBuildDate,omitempty"` // updated used + Category string `xml:"category,omitempty"` + Generator string `xml:"generator,omitempty"` + Docs string `xml:"docs,omitempty"` + Cloud string `xml:"cloud,omitempty"` + Ttl int `xml:"ttl,omitempty"` + Rating string `xml:"rating,omitempty"` + SkipHours string `xml:"skipHours,omitempty"` + SkipDays string `xml:"skipDays,omitempty"` + Image *RssImage + TextInput *RssTextInput + Items []*RssItem `xml:"item"` +} + +type RssItem struct { + XMLName xml.Name `xml:"item"` + Title string `xml:"title"` // required + Link string `xml:"link"` // required + Description string `xml:"description"` // required + Content *RssContent + Author string `xml:"author,omitempty"` + Category string `xml:"category,omitempty"` + Comments string `xml:"comments,omitempty"` + Enclosure *RssEnclosure + Guid string `xml:"guid,omitempty"` // Id used + PubDate string `xml:"pubDate,omitempty"` // created or updated + Source string `xml:"source,omitempty"` +} + +type RssEnclosure struct { + //RSS 2.0 + XMLName xml.Name `xml:"enclosure"` + Url string `xml:"url,attr"` + Length string `xml:"length,attr"` + Type string `xml:"type,attr"` +} + +type Rss struct { + *Feed +} + +// create a new RssItem with a generic Item struct's data +func newRssItem(i *Item) *RssItem { + item := &RssItem{ + Title: i.Title, + Link: i.Link.Href, + Description: i.Description, + Guid: i.Id, + PubDate: anyTimeFormat(time.RFC1123Z, i.Created, i.Updated), + } + if len(i.Content) > 0 { + item.Content = &RssContent{Content: i.Content} + } + if i.Source != nil { + item.Source = i.Source.Href + } + + // Define a closure + if i.Enclosure != nil && i.Enclosure.Type != "" && i.Enclosure.Length != "" { + item.Enclosure = &RssEnclosure{Url: i.Enclosure.Url, Type: i.Enclosure.Type, Length: i.Enclosure.Length} + } + + if i.Author != nil { + item.Author = i.Author.Name + } + return item +} + +// create a new RssFeed with a generic Feed struct's data +func (r *Rss) RssFeed() *RssFeed { + pub := anyTimeFormat(time.RFC1123Z, r.Created, r.Updated) + build := anyTimeFormat(time.RFC1123Z, r.Updated) + author := "" + if r.Author != nil { + author = r.Author.Email + if len(r.Author.Name) > 0 { + author = fmt.Sprintf("%s (%s)", r.Author.Email, r.Author.Name) + } + } + + var image *RssImage + if r.Image != nil { + image = &RssImage{Url: r.Image.Url, Title: r.Image.Title, Link: r.Image.Link, Width: r.Image.Width, Height: r.Image.Height} + } + + channel := &RssFeed{ + Title: r.Title, + Link: r.Link.Href, + Description: r.Description, + ManagingEditor: author, + PubDate: pub, + LastBuildDate: build, + Copyright: r.Copyright, + Image: image, + } + for _, i := range r.Items { + channel.Items = append(channel.Items, newRssItem(i)) + } + return channel +} + +// FeedXml returns an XML-Ready object for an Rss object +func (r *Rss) FeedXml() interface{} { + // only generate version 2.0 feeds for now + return r.RssFeed().FeedXml() + +} + +// FeedXml returns an XML-ready object for an RssFeed object +func (r *RssFeed) FeedXml() interface{} { + return &RssFeedXml{ + Version: "2.0", + Channel: r, + ContentNamespace: "http://purl.org/rss/1.0/modules/content/", + } +} diff --git a/vendor/github.com/gorilla/feeds/test.atom b/vendor/github.com/gorilla/feeds/test.atom new file mode 100644 index 00000000..aa152148 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/test.atom @@ -0,0 +1,92 @@ + + + <![CDATA[Lorem ipsum feed for an interval of 1 minutes]]> + + http://example.com/ + RSS for Node + Tue, 30 Oct 2018 23:22:37 GMT + + Tue, 30 Oct 2018 23:22:00 GMT + + 60 + + <![CDATA[Lorem ipsum 2018-10-30T23:22:00+00:00]]> + + http://example.com/test/1540941720 + http://example.com/test/1540941720 + + Tue, 30 Oct 2018 23:22:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:21:00+00:00]]> + + http://example.com/test/1540941660 + http://example.com/test/1540941660 + + Tue, 30 Oct 2018 23:21:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:20:00+00:00]]> + + http://example.com/test/1540941600 + http://example.com/test/1540941600 + + Tue, 30 Oct 2018 23:20:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:19:00+00:00]]> + + http://example.com/test/1540941540 + http://example.com/test/1540941540 + + Tue, 30 Oct 2018 23:19:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:18:00+00:00]]> + + http://example.com/test/1540941480 + http://example.com/test/1540941480 + + Tue, 30 Oct 2018 23:18:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:17:00+00:00]]> + + http://example.com/test/1540941420 + http://example.com/test/1540941420 + + Tue, 30 Oct 2018 23:17:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:16:00+00:00]]> + + http://example.com/test/1540941360 + http://example.com/test/1540941360 + + Tue, 30 Oct 2018 23:16:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:15:00+00:00]]> + + http://example.com/test/1540941300 + http://example.com/test/1540941300 + + Tue, 30 Oct 2018 23:15:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:14:00+00:00]]> + + http://example.com/test/1540941240 + http://example.com/test/1540941240 + + Tue, 30 Oct 2018 23:14:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:13:00+00:00]]> + + http://example.com/test/1540941180 + http://example.com/test/1540941180 + + Tue, 30 Oct 2018 23:13:00 GMT + + \ No newline at end of file diff --git a/vendor/github.com/gorilla/feeds/test.rss b/vendor/github.com/gorilla/feeds/test.rss new file mode 100644 index 00000000..8d912aba --- /dev/null +++ b/vendor/github.com/gorilla/feeds/test.rss @@ -0,0 +1,96 @@ + + + + <![CDATA[Lorem ipsum feed for an interval of 1 minutes]]> + + http://example.com/ + RSS for Node + Tue, 30 Oct 2018 23:22:37 GMT + + Tue, 30 Oct 2018 23:22:00 GMT + + 60 + + <![CDATA[Lorem ipsum 2018-10-30T23:22:00+00:00]]> + + http://example.com/test/1540941720 + http://example.com/test/1540941720 + + Tue, 30 Oct 2018 23:22:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:21:00+00:00]]> + + http://example.com/test/1540941660 + http://example.com/test/1540941660 + + Tue, 30 Oct 2018 23:21:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:20:00+00:00]]> + + http://example.com/test/1540941600 + http://example.com/test/1540941600 + + Tue, 30 Oct 2018 23:20:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:19:00+00:00]]> + + http://example.com/test/1540941540 + http://example.com/test/1540941540 + + Tue, 30 Oct 2018 23:19:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:18:00+00:00]]> + + http://example.com/test/1540941480 + http://example.com/test/1540941480 + + Tue, 30 Oct 2018 23:18:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:17:00+00:00]]> + + http://example.com/test/1540941420 + http://example.com/test/1540941420 + + Tue, 30 Oct 2018 23:17:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:16:00+00:00]]> + + http://example.com/test/1540941360 + http://example.com/test/1540941360 + + Tue, 30 Oct 2018 23:16:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:15:00+00:00]]> + + http://example.com/test/1540941300 + http://example.com/test/1540941300 + + Tue, 30 Oct 2018 23:15:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:14:00+00:00]]> + + http://example.com/test/1540941240 + http://example.com/test/1540941240 + + Tue, 30 Oct 2018 23:14:00 GMT + + + <![CDATA[Lorem ipsum 2018-10-30T23:13:00+00:00]]> + + http://example.com/test/1540941180 + http://example.com/test/1540941180 + + Tue, 30 Oct 2018 23:13:00 GMT + + + \ No newline at end of file diff --git a/vendor/github.com/gorilla/feeds/to-implement.md b/vendor/github.com/gorilla/feeds/to-implement.md new file mode 100644 index 00000000..45fd1e75 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/to-implement.md @@ -0,0 +1,20 @@ +[Full iTunes list](https://help.apple.com/itc/podcasts_connect/#/itcb54353390) + +[Example of ideal iTunes RSS feed](https://help.apple.com/itc/podcasts_connect/#/itcbaf351599) + +``` + + + + + + + + + + + + + + +``` \ No newline at end of file diff --git a/vendor/github.com/gorilla/feeds/uuid.go b/vendor/github.com/gorilla/feeds/uuid.go new file mode 100644 index 00000000..51bbafe1 --- /dev/null +++ b/vendor/github.com/gorilla/feeds/uuid.go @@ -0,0 +1,27 @@ +package feeds + +// relevant bits from https://github.com/abneptis/GoUUID/blob/master/uuid.go + +import ( + "crypto/rand" + "fmt" +) + +type UUID [16]byte + +// create a new uuid v4 +func NewUUID() *UUID { + u := &UUID{} + _, err := rand.Read(u[:16]) + if err != nil { + panic(err) + } + + u[8] = (u[8] | 0x80) & 0xBf + u[6] = (u[6] | 0x40) & 0x4f + return u +} + +func (u *UUID) String() string { + return fmt.Sprintf("%x-%x-%x-%x-%x", u[:4], u[4:6], u[6:8], u[8:10], u[10:]) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 704d97e3..d4e095f9 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -174,6 +174,9 @@ github.com/gorilla/context # github.com/gorilla/css v1.0.0 ## explicit github.com/gorilla/css/scanner +# github.com/gorilla/feeds v1.1.1 +## explicit +github.com/gorilla/feeds # github.com/gorilla/securecookie v1.1.1 ## explicit github.com/gorilla/securecookie diff --git a/web/assets/rss.svg b/web/assets/rss.svg new file mode 100644 index 00000000..11fd98a5 --- /dev/null +++ b/web/assets/rss.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/source/css/profile.css b/web/source/css/profile.css index 8b142e5c..03f65d1d 100644 --- a/web/source/css/profile.css +++ b/web/source/css/profile.css @@ -220,5 +220,15 @@ main { } #recent { - margin-left: 1rem; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin: 1rem; + .rsslogo { + width: 1.45em; + height: 1.45em; + object-fit: contain; + vertical-align: middle; + } } diff --git a/web/source/settings/lib/api/user.js b/web/source/settings/lib/api/user.js index 18b54bd7..e9f73158 100644 --- a/web/source/settings/lib/api/user.js +++ b/web/source/settings/lib/api/user.js @@ -49,7 +49,7 @@ module.exports = function ({ apiCall, getChanges }) { }, updateProfile: function updateProfile() { - const formKeys = ["display_name", "locked", "source", "custom_css", "source.note"]; + const formKeys = ["display_name", "locked", "source", "custom_css", "source.note", "enable_rss"]; const renamedKeys = { "source.note": "note" }; diff --git a/web/source/settings/user/profile.js b/web/source/settings/user/profile.js index 7cf3a7b5..3162fa0d 100644 --- a/web/source/settings/user/profile.js +++ b/web/source/settings/user/profile.js @@ -96,7 +96,11 @@ module.exports = function UserProfile() { /> + { !allowCustomCSS ? null :