[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
This commit is contained in:
parent
aa07750bdb
commit
80663061d8
|
@ -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).
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="128px" height="128px" id="RSSicon" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient x1="0.085" y1="0.085" x2="0.915" y2="0.915" id="RSSg">
|
||||
<stop offset="0.0" stop-color="#E3702D"/><stop offset="0.1071" stop-color="#EA7D31"/>
|
||||
<stop offset="0.3503" stop-color="#F69537"/><stop offset="0.5" stop-color="#FB9E3A"/>
|
||||
<stop offset="0.7016" stop-color="#EA7C31"/><stop offset="0.8866" stop-color="#DE642B"/>
|
||||
<stop offset="1.0" stop-color="#D95B29"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="55" ry="55" x="0" y="0" fill="#CC5D15"/>
|
||||
<rect width="246" height="246" rx="50" ry="50" x="5" y="5" fill="#F49C52"/>
|
||||
<rect width="236" height="236" rx="47" ry="47" x="10" y="10" fill="url(#RSSg)"/>
|
||||
<circle cx="68" cy="189" r="24" fill="#FFF"/>
|
||||
<path d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z" fill="#FFF"/>
|
||||
<path d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z" fill="#FFF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -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.
|
3
go.mod
3
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
|
||||
|
|
2
go.sum
2
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=
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
func init() {
|
||||
up := func(ctx context.Context, db *bun.DB) error {
|
||||
_, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? BOOLEAN DEFAULT false", bun.Ident("accounts"), bun.Ident("enable_rss"))
|
||||
if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
down := func(ctx context.Context, db *bun.DB) error {
|
||||
return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := Migrations.Register(up, down); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
)
|
||||
|
||||
const rssFeedLength = 20
|
||||
|
||||
func (p *processor) GetRSSFeedForUsername(ctx context.Context, username string) (func() (string, gtserror.WithCode), time.Time, gtserror.WithCode) {
|
||||
account, err := p.db.GetAccountByUsernameDomain(ctx, username, "")
|
||||
if err != nil {
|
||||
if err == db.ErrNoEntries {
|
||||
return nil, time.Time{}, gtserror.NewErrorNotFound(errors.New("GetRSSFeedForUsername: account not found"))
|
||||
}
|
||||
return nil, time.Time{}, gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
|
||||
}
|
||||
|
||||
if !*account.EnableRSS {
|
||||
return nil, time.Time{}, gtserror.NewErrorNotFound(errors.New("GetRSSFeedForUsername: account RSS feed not enabled"))
|
||||
}
|
||||
|
||||
lastModified, err := p.db.GetAccountLastPosted(ctx, account.ID, true)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
|
||||
}
|
||||
|
||||
return func() (string, gtserror.WithCode) {
|
||||
statuses, err := p.db.GetAccountWebStatuses(ctx, account.ID, rssFeedLength, "")
|
||||
if err != nil && err != db.ErrNoEntries {
|
||||
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error: %s", err))
|
||||
}
|
||||
|
||||
author := "@" + account.Username + "@" + config.GetAccountDomain()
|
||||
title := "Posts from " + author
|
||||
description := "Posts from " + author
|
||||
link := &feeds.Link{Href: account.URL}
|
||||
|
||||
var image *feeds.Image
|
||||
if account.AvatarMediaAttachmentID != "" {
|
||||
if account.AvatarMediaAttachment == nil {
|
||||
avatar, err := p.db.GetAttachmentByID(ctx, account.AvatarMediaAttachmentID)
|
||||
if err != nil {
|
||||
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: db error fetching avatar attachment: %s", err))
|
||||
}
|
||||
account.AvatarMediaAttachment = avatar
|
||||
}
|
||||
image = &feeds.Image{
|
||||
Url: account.AvatarMediaAttachment.Thumbnail.URL,
|
||||
Title: "Avatar for " + author,
|
||||
Link: account.URL,
|
||||
}
|
||||
}
|
||||
|
||||
feed := &feeds.Feed{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Link: link,
|
||||
Image: image,
|
||||
}
|
||||
|
||||
for i, s := range statuses {
|
||||
// take the date of the first (ie., latest) status as feed updated value
|
||||
if i == 0 {
|
||||
feed.Updated = s.UpdatedAt
|
||||
}
|
||||
|
||||
item, err := p.tc.StatusToRSSItem(ctx, s)
|
||||
if err != nil {
|
||||
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: error converting status to feed item: %s", err))
|
||||
}
|
||||
|
||||
feed.Add(item)
|
||||
}
|
||||
|
||||
rss, err := feed.ToRss()
|
||||
if err != nil {
|
||||
return "", gtserror.NewErrorInternalError(fmt.Errorf("GetRSSFeedForUsername: error converting feed to rss string: %s", err))
|
||||
}
|
||||
|
||||
return rss, nil
|
||||
}, lastModified, nil
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package account_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type GetRSSTestSuite struct {
|
||||
AccountStandardTestSuite
|
||||
}
|
||||
|
||||
func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
|
||||
getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "admin")
|
||||
suite.NoError(err)
|
||||
suite.EqualValues(1634733405, lastModified.Unix())
|
||||
|
||||
feed, err := getFeed()
|
||||
suite.NoError(err)
|
||||
|
||||
fmt.Println(feed)
|
||||
|
||||
suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @admin@localhost:8080</title>\n <link>http://localhost:8080/@admin</link>\n <description>Posts from @admin@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 12:36:45 +0000</lastBuildDate>\n <item>\n <title>open to see some puppies</title>\n <link>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</link>\n <description>@admin@localhost:8080 made a new post: "🐕🐕🐕🐕🐕"</description>\n <content:encoded><![CDATA[🐕🐕🐕🐕🐕]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <guid>http://localhost:8080/@admin/statuses/01F8MHAAY43M6RJ473VQFCVH37</guid>\n <pubDate>Wed, 20 Oct 2021 12:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n <item>\n <title>hello world! #welcome ! first post on the instance :rainbow: !</title>\n <link>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</link>\n <description>@admin@localhost:8080 posted 1 attachment: "hello world! #welcome ! first post on the instance :rainbow: !"</description>\n <content:encoded><![CDATA[hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !]]></content:encoded>\n <author>@admin@localhost:8080</author>\n <enclosure url=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg\" length=\"62529\" type=\"image/jpeg\"></enclosure>\n <guid>http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R</guid>\n <pubDate>Wed, 20 Oct 2021 11:36:45 +0000</pubDate>\n <source>http://localhost:8080/@admin/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
|
||||
}
|
||||
|
||||
func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
|
||||
getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork")
|
||||
suite.NoError(err)
|
||||
suite.EqualValues(1634726437, lastModified.Unix())
|
||||
|
||||
feed, err := getFeed()
|
||||
suite.NoError(err)
|
||||
|
||||
fmt.Println(feed)
|
||||
|
||||
suite.Equal("<?xml version=\"1.0\" encoding=\"UTF-8\"?><rss version=\"2.0\" xmlns:content=\"http://purl.org/rss/1.0/modules/content/\">\n <channel>\n <title>Posts from @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n <description>Posts from @the_mighty_zork@localhost:8080</description>\n <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate>\n <lastBuildDate>Wed, 20 Oct 2021 10:40:37 +0000</lastBuildDate>\n <image>\n <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.jpeg</url>\n <title>Avatar for @the_mighty_zork@localhost:8080</title>\n <link>http://localhost:8080/@the_mighty_zork</link>\n </image>\n <item>\n <title>introduction post</title>\n <link>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</link>\n <description>@the_mighty_zork@localhost:8080 made a new post: "hello everyone!"</description>\n <content:encoded><![CDATA[hello everyone!]]></content:encoded>\n <author>@the_mighty_zork@localhost:8080</author>\n <guid>http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY</guid>\n <pubDate>Wed, 20 Oct 2021 10:40:37 +0000</pubDate>\n <source>http://localhost:8080/@the_mighty_zork/feed.rss</source>\n </item>\n </channel>\n</rss>", feed)
|
||||
}
|
||||
|
||||
func TestGetRSSTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(GetRSSTestSuite))
|
||||
}
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -19,9 +19,7 @@
|
|||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
@ -31,7 +29,7 @@ import (
|
|||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/regexes"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||
)
|
||||
|
||||
|
@ -152,42 +150,9 @@ func visibilityIcon(visibility model.Visibility) template.HTML {
|
|||
return template.HTML(fmt.Sprintf(`<i aria-label="Visibility: %v" class="fa fa-%v"></i>`, icon.label, icon.faIcon))
|
||||
}
|
||||
|
||||
// replaces shortcodes in `text` with the emoji in `emojis`
|
||||
// text is a template.HTML to affirm that the input of this function is already escaped
|
||||
func emojify(emojis []model.Emoji, text template.HTML) template.HTML {
|
||||
emojisMap := make(map[string]model.Emoji, len(emojis))
|
||||
|
||||
for _, emoji := range emojis {
|
||||
shortcode := ":" + emoji.Shortcode + ":"
|
||||
emojisMap[shortcode] = emoji
|
||||
}
|
||||
|
||||
out := regexes.ReplaceAllStringFunc(
|
||||
regexes.EmojiFinder,
|
||||
string(text),
|
||||
func(shortcode string, buf *bytes.Buffer) string {
|
||||
// Look for emoji according to this shortcode
|
||||
emoji, ok := emojisMap[shortcode]
|
||||
if !ok {
|
||||
return shortcode
|
||||
}
|
||||
|
||||
// Escape raw emoji content
|
||||
safeURL := html.EscapeString(emoji.URL)
|
||||
safeCode := html.EscapeString(emoji.Shortcode)
|
||||
|
||||
// Write HTML emoji repr to buffer
|
||||
buf.WriteString(`<img src="`)
|
||||
buf.WriteString(safeURL)
|
||||
buf.WriteString(`" title=":`)
|
||||
buf.WriteString(safeCode)
|
||||
buf.WriteString(`:" alt=":`)
|
||||
buf.WriteString(safeCode)
|
||||
buf.WriteString(`:" class="emoji"/>`)
|
||||
|
||||
return buf.String()
|
||||
},
|
||||
)
|
||||
func emojify(emojis []model.Emoji, inputText template.HTML) template.HTML {
|
||||
out := text.Emojify(emojis, string(inputText))
|
||||
|
||||
/* #nosec G203 */
|
||||
// (this is escaped above)
|
||||
|
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package text
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/regexes"
|
||||
)
|
||||
|
||||
// Emojify replaces shortcodes in `inputText` with the emoji in `emojis`.
|
||||
//
|
||||
// Callers should ensure that inputText and resulting text are escaped
|
||||
// appropriately depending on what they're used for.
|
||||
func Emojify(emojis []model.Emoji, inputText string) string {
|
||||
emojisMap := make(map[string]model.Emoji, len(emojis))
|
||||
|
||||
for _, emoji := range emojis {
|
||||
shortcode := ":" + emoji.Shortcode + ":"
|
||||
emojisMap[shortcode] = emoji
|
||||
}
|
||||
|
||||
return regexes.ReplaceAllStringFunc(
|
||||
regexes.EmojiFinder,
|
||||
inputText,
|
||||
func(shortcode string, buf *bytes.Buffer) string {
|
||||
// Look for emoji according to this shortcode
|
||||
emoji, ok := emojisMap[shortcode]
|
||||
if !ok {
|
||||
return shortcode
|
||||
}
|
||||
|
||||
// Escape raw emoji content
|
||||
safeURL := html.EscapeString(emoji.URL)
|
||||
safeCode := html.EscapeString(emoji.Shortcode)
|
||||
|
||||
// Write HTML emoji repr to buffer
|
||||
buf.WriteString(`<img src="`)
|
||||
buf.WriteString(safeURL)
|
||||
buf.WriteString(`" title=":`)
|
||||
buf.WriteString(safeCode)
|
||||
buf.WriteString(`:" alt=":`)
|
||||
buf.WriteString(safeCode)
|
||||
buf.WriteString(`:" class="emoji"/>`)
|
||||
|
||||
return buf.String()
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package typeutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/feeds"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/text"
|
||||
)
|
||||
|
||||
const (
|
||||
rssMaxTitleChars = 128
|
||||
rssDescriptionMaxChars = 256
|
||||
)
|
||||
|
||||
func (c *converter) StatusToRSSItem(ctx context.Context, s *gtsmodel.Status) (*feeds.Item, error) {
|
||||
// see https://cyber.harvard.edu/rss/rss.html
|
||||
|
||||
// Title -- The title of the item.
|
||||
// example: Venice Film Festival Tries to Quit Sinking
|
||||
var title string
|
||||
if s.ContentWarning != "" {
|
||||
title = trimTo(s.ContentWarning, rssMaxTitleChars)
|
||||
} else {
|
||||
title = trimTo(s.Text, rssMaxTitleChars)
|
||||
}
|
||||
|
||||
// Link -- The URL of the item.
|
||||
// example: http://nytimes.com/2004/12/07FEST.html
|
||||
link := &feeds.Link{
|
||||
Href: s.URL,
|
||||
}
|
||||
|
||||
// Author -- Email address of the author of the item.
|
||||
// example: oprah\@oxygen.net
|
||||
if s.Account == nil {
|
||||
a, err := c.db.GetAccountByID(ctx, s.AccountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting status author: %s", err)
|
||||
}
|
||||
s.Account = a
|
||||
}
|
||||
authorName := "@" + s.Account.Username + "@" + config.GetAccountDomain()
|
||||
author := &feeds.Author{
|
||||
Name: authorName,
|
||||
}
|
||||
|
||||
// Source -- The RSS channel that the item came from.
|
||||
source := &feeds.Link{
|
||||
Href: s.Account.URL + "/feed.rss",
|
||||
}
|
||||
|
||||
// Description -- The item synopsis.
|
||||
// example: Some of the most heated chatter at the Venice Film Festival this week was about the way that the arrival of the stars at the Palazzo del Cinema was being staged.
|
||||
descriptionBuilder := strings.Builder{}
|
||||
descriptionBuilder.WriteString(authorName + " ")
|
||||
|
||||
attachmentCount := len(s.Attachments)
|
||||
if len(s.AttachmentIDs) > attachmentCount {
|
||||
attachmentCount = len(s.AttachmentIDs)
|
||||
}
|
||||
switch {
|
||||
case attachmentCount > 1:
|
||||
descriptionBuilder.WriteString(fmt.Sprintf("posted [%d] attachments", attachmentCount))
|
||||
case attachmentCount == 1:
|
||||
descriptionBuilder.WriteString("posted 1 attachment")
|
||||
default:
|
||||
descriptionBuilder.WriteString("made a new post")
|
||||
}
|
||||
|
||||
if s.Text != "" {
|
||||
descriptionBuilder.WriteString(": \"")
|
||||
descriptionBuilder.WriteString(s.Text)
|
||||
descriptionBuilder.WriteString("\"")
|
||||
}
|
||||
|
||||
description := trimTo(descriptionBuilder.String(), rssDescriptionMaxChars)
|
||||
|
||||
// ID -- A string that uniquely identifies the item.
|
||||
// example: http://inessential.com/2002/09/01.php#a2
|
||||
id := s.URL
|
||||
|
||||
// Enclosure -- Describes a media object that is attached to the item.
|
||||
enclosure := &feeds.Enclosure{}
|
||||
// get first attachment if present
|
||||
var attachment *gtsmodel.MediaAttachment
|
||||
if len(s.Attachments) > 0 {
|
||||
attachment = s.Attachments[0]
|
||||
} else if len(s.AttachmentIDs) > 0 {
|
||||
a, err := c.db.GetAttachmentByID(ctx, s.AttachmentIDs[0])
|
||||
if err == nil {
|
||||
attachment = a
|
||||
}
|
||||
}
|
||||
if attachment != nil {
|
||||
enclosure.Type = attachment.File.ContentType
|
||||
enclosure.Length = strconv.Itoa(attachment.File.FileSize)
|
||||
enclosure.Url = attachment.URL
|
||||
}
|
||||
|
||||
// Content
|
||||
apiEmojis := []model.Emoji{}
|
||||
// the status might already have some gts emojis on it if it's not been pulled directly from the database
|
||||
// if so, we can directly convert the gts emojis into api ones
|
||||
if s.Emojis != nil {
|
||||
for _, gtsEmoji := range s.Emojis {
|
||||
apiEmoji, err := c.EmojiToAPIEmoji(ctx, gtsEmoji)
|
||||
if err != nil {
|
||||
log.Errorf("error converting emoji with id %s: %s", gtsEmoji.ID, err)
|
||||
continue
|
||||
}
|
||||
apiEmojis = append(apiEmojis, apiEmoji)
|
||||
}
|
||||
// the status doesn't have gts emojis on it, but it does have emoji IDs
|
||||
// in this case, we need to pull the gts emojis from the db to convert them into api ones
|
||||
} else {
|
||||
for _, e := range s.EmojiIDs {
|
||||
gtsEmoji := >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] + "..."
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package typeutils_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type InternalToRSSTestSuite struct {
|
||||
TypeUtilsTestSuite
|
||||
}
|
||||
|
||||
func (suite *InternalToRSSTestSuite) TestStatusToRSSItem1() {
|
||||
s := suite.testStatuses["local_account_1_status_1"]
|
||||
item, err := suite.typeconverter.StatusToRSSItem(context.Background(), s)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal("introduction post", item.Title)
|
||||
suite.Equal("http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", item.Link.Href)
|
||||
suite.Equal("", item.Link.Length)
|
||||
suite.Equal("", item.Link.Rel)
|
||||
suite.Equal("", item.Link.Type)
|
||||
suite.Equal("http://localhost:8080/@the_mighty_zork/feed.rss", item.Source.Href)
|
||||
suite.Equal("", item.Source.Length)
|
||||
suite.Equal("", item.Source.Rel)
|
||||
suite.Equal("", item.Source.Type)
|
||||
suite.Equal("", item.Author.Email)
|
||||
suite.Equal("@the_mighty_zork@localhost:8080", item.Author.Name)
|
||||
suite.Equal("@the_mighty_zork@localhost:8080 made a new post: \"hello everyone!\"", item.Description)
|
||||
suite.Equal("http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY", item.Id)
|
||||
suite.EqualValues(1634726437, item.Updated.Unix())
|
||||
suite.EqualValues(1634726437, item.Created.Unix())
|
||||
suite.Equal("", item.Enclosure.Length)
|
||||
suite.Equal("", item.Enclosure.Type)
|
||||
suite.Equal("", item.Enclosure.Url)
|
||||
suite.Equal("hello everyone!", item.Content)
|
||||
}
|
||||
|
||||
func (suite *InternalToRSSTestSuite) TestStatusToRSSItem2() {
|
||||
s := suite.testStatuses["admin_account_status_1"]
|
||||
item, err := suite.typeconverter.StatusToRSSItem(context.Background(), s)
|
||||
suite.NoError(err)
|
||||
|
||||
suite.Equal("hello world! #welcome ! first post on the instance :rainbow: !", item.Title)
|
||||
suite.Equal("http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", item.Link.Href)
|
||||
suite.Equal("", item.Link.Length)
|
||||
suite.Equal("", item.Link.Rel)
|
||||
suite.Equal("", item.Link.Type)
|
||||
suite.Equal("http://localhost:8080/@admin/feed.rss", item.Source.Href)
|
||||
suite.Equal("", item.Source.Length)
|
||||
suite.Equal("", item.Source.Rel)
|
||||
suite.Equal("", item.Source.Type)
|
||||
suite.Equal("", item.Author.Email)
|
||||
suite.Equal("@admin@localhost:8080", item.Author.Name)
|
||||
suite.Equal("@admin@localhost:8080 posted 1 attachment: \"hello world! #welcome ! first post on the instance :rainbow: !\"", item.Description)
|
||||
suite.Equal("http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R", item.Id)
|
||||
suite.EqualValues(1634729805, item.Updated.Unix())
|
||||
suite.EqualValues(1634729805, item.Created.Unix())
|
||||
suite.Equal("62529", item.Enclosure.Length)
|
||||
suite.Equal("image/jpeg", item.Enclosure.Type)
|
||||
suite.Equal("http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpeg", item.Enclosure.Url)
|
||||
suite.Equal("hello world! #welcome ! first post on the instance <img src=\"http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png\" title=\":rainbow:\" alt=\":rainbow:\" class=\"emoji\"/> !", item.Content)
|
||||
}
|
||||
|
||||
func TestInternalToRSSTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(InternalToRSSTestSuite))
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,138 +0,0 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
// nolint:gosec
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
type eTagCacheEntry struct {
|
||||
eTag string
|
||||
fileLastModified time.Time
|
||||
}
|
||||
|
||||
// generateEtag generates a strong (byte-for-byte) etag using
|
||||
// the entirety of the provided reader.
|
||||
func generateEtag(r io.Reader) (string, error) {
|
||||
// nolint:gosec
|
||||
hash := sha1.New()
|
||||
|
||||
if _, err := io.Copy(hash, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
b := make([]byte, 0, sha1.Size)
|
||||
b = hash.Sum(b)
|
||||
|
||||
return `"` + hex.EncodeToString(b) + `"`, nil
|
||||
}
|
||||
|
||||
// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
|
||||
// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
|
||||
// to generate a new ETag to go in the cache, which it then returns.
|
||||
func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) {
|
||||
file, err := fs.Open(filePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error opening %s: %s", filePath, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error statting %s: %s", filePath, err)
|
||||
}
|
||||
|
||||
fileLastModified := fileInfo.ModTime()
|
||||
|
||||
if cachedETag, ok := m.assetsETagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.fileLastModified) {
|
||||
// only return our cached etag if the file wasn't
|
||||
// modified since last time, otherwise generate a
|
||||
// new one; eat fresh!
|
||||
return cachedETag.eTag, nil
|
||||
}
|
||||
|
||||
eTag, err := generateEtag(file)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating etag: %s", err)
|
||||
}
|
||||
|
||||
// put new entry in cache before we return
|
||||
m.assetsETagCache.Set(filePath, eTagCacheEntry{
|
||||
eTag: eTag,
|
||||
fileLastModified: fileLastModified,
|
||||
})
|
||||
|
||||
return eTag, nil
|
||||
}
|
||||
|
||||
// cacheControlMiddleware implements Cache-Control header setting, and checks for
|
||||
// files inside the given http.FileSystem.
|
||||
//
|
||||
// The middleware checks if the file has been modified using If-None-Match etag,
|
||||
// if present. If the file hasn't been modified, the middleware returns 304.
|
||||
//
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
|
||||
// and: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
||||
func (m *Module) cacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// no-cache prevents clients using default caching or heuristic caching,
|
||||
// and also ensures that clients will validate their cached version against
|
||||
// the version stored on the server to keep up to date.
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
|
||||
ifNoneMatch := c.Request.Header.Get("If-None-Match")
|
||||
|
||||
// derive the path of the requested asset inside the provided filesystem
|
||||
upath := c.Request.URL.Path
|
||||
if !strings.HasPrefix(upath, "/") {
|
||||
upath = "/" + upath
|
||||
}
|
||||
assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
|
||||
|
||||
// either fetch etag from ttlcache or generate it
|
||||
eTag, err := m.getAssetETag(assetFilePath, fs)
|
||||
if err != nil {
|
||||
log.Errorf("error getting ETag for %s: %s", assetFilePath, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Regardless of what happens further down, set the etag header
|
||||
// so that the client has the up-to-date version.
|
||||
c.Header("Etag", eTag)
|
||||
|
||||
// If client already has latest version of the asset, 304 + bail.
|
||||
if ifNoneMatch == eTag {
|
||||
c.AbortWithStatus(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
// else let the rest of the request be processed normally
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
// nolint:gosec
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
|
||||
"codeberg.org/gruf/go-cache/v2"
|
||||
)
|
||||
|
||||
func newETagCache() cache.Cache[string, eTagCacheEntry] {
|
||||
eTagCache := cache.New[string, eTagCacheEntry]()
|
||||
eTagCache.SetTTL(time.Hour, false)
|
||||
if !eTagCache.Start(time.Minute) {
|
||||
log.Panic("could not start eTagCache")
|
||||
}
|
||||
return eTagCache
|
||||
}
|
||||
|
||||
type eTagCacheEntry struct {
|
||||
eTag string
|
||||
lastModified time.Time
|
||||
}
|
||||
|
||||
// generateEtag generates a strong (byte-for-byte) etag using
|
||||
// the entirety of the provided reader.
|
||||
func generateEtag(r io.Reader) (string, error) {
|
||||
// nolint:gosec
|
||||
hash := sha1.New()
|
||||
|
||||
if _, err := io.Copy(hash, r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
b := make([]byte, 0, sha1.Size)
|
||||
b = hash.Sum(b)
|
||||
|
||||
return `"` + hex.EncodeToString(b) + `"`, nil
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2022 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/api"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
)
|
||||
|
||||
const appRSSUTF8 = string(api.AppRSSXML + "; charset=utf-8")
|
||||
|
||||
func (m *Module) GetRSSETag(urlPath string, lastModified time.Time, getRSSFeed func() (string, gtserror.WithCode)) (string, error) {
|
||||
if cachedETag, ok := m.eTagCache.Get(urlPath); ok && !lastModified.After(cachedETag.lastModified) {
|
||||
// only return our cached etag if the file wasn't
|
||||
// modified since last time, otherwise generate a
|
||||
// new one; eat fresh!
|
||||
return cachedETag.eTag, nil
|
||||
}
|
||||
|
||||
rssFeed, errWithCode := getRSSFeed()
|
||||
if errWithCode != nil {
|
||||
return "", fmt.Errorf("error getting rss feed: %s", errWithCode)
|
||||
}
|
||||
|
||||
eTag, err := generateEtag(bytes.NewReader([]byte(rssFeed)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating etag: %s", err)
|
||||
}
|
||||
|
||||
// put new entry in cache before we return
|
||||
m.eTagCache.Set(urlPath, eTagCacheEntry{
|
||||
eTag: eTag,
|
||||
lastModified: lastModified,
|
||||
})
|
||||
|
||||
return eTag, nil
|
||||
}
|
||||
|
||||
func extractIfModifiedSince(header string) time.Time {
|
||||
if header == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
t, err := http.ParseTime(header)
|
||||
if err != nil {
|
||||
log.Errorf("couldn't parse if-modified-since %s: %s", header, err)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (m *Module) rssFeedGETHandler(c *gin.Context) {
|
||||
// set this Cache-Control header to instruct clients to validate the response with us
|
||||
// before each reuse (https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control)
|
||||
c.Header(cacheControlHeader, cacheControlNoCache)
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if _, err := api.NegotiateAccept(c, api.AppRSSXML); err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
// usernames on our instance will always be lowercase
|
||||
username := strings.ToLower(c.Param(usernameKey))
|
||||
if username == "" {
|
||||
err := errors.New("no account username specified")
|
||||
api.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
ifNoneMatch := c.Request.Header.Get(ifNoneMatchHeader)
|
||||
ifModifiedSince := extractIfModifiedSince(c.Request.Header.Get(ifModifiedSinceHeader))
|
||||
|
||||
getRssFeed, accountLastPostedPublic, errWithCode := m.processor.AccountGetRSSFeedForUsername(ctx, username)
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
var rssFeed string
|
||||
cacheKey := c.Request.URL.Path
|
||||
cacheEntry, ok := m.eTagCache.Get(cacheKey)
|
||||
|
||||
if !ok || cacheEntry.lastModified.Before(accountLastPostedPublic) {
|
||||
// we either have no cache entry for this, or we have an expired cache entry; generate a new one
|
||||
rssFeed, errWithCode = getRssFeed()
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
eTag, err := generateEtag(bytes.NewBufferString(rssFeed))
|
||||
if err != nil {
|
||||
api.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
|
||||
cacheEntry.lastModified = accountLastPostedPublic
|
||||
cacheEntry.eTag = eTag
|
||||
m.eTagCache.Put(cacheKey, cacheEntry)
|
||||
}
|
||||
|
||||
c.Header(eTagHeader, cacheEntry.eTag)
|
||||
c.Header(lastModifiedHeader, accountLastPostedPublic.Format(http.TimeFormat))
|
||||
|
||||
if ifNoneMatch == cacheEntry.eTag {
|
||||
c.AbortWithStatus(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
lmUnix := cacheEntry.lastModified.Unix()
|
||||
imsUnix := ifModifiedSince.Unix()
|
||||
if lmUnix <= imsUnix {
|
||||
c.AbortWithStatus(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
if rssFeed == "" {
|
||||
// we had a cache entry already so we didn't call to get the rss feed yet
|
||||
rssFeed, errWithCode = getRssFeed()
|
||||
if errWithCode != nil {
|
||||
api.ErrorHandler(c, errWithCode, m.processor.InstanceGet)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, appRSSUTF8, []byte(rssFeed))
|
||||
}
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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 ./...
|
|
@ -0,0 +1,29 @@
|
|||
# This is the official list of gorilla/feeds authors for copyright purposes.
|
||||
# Please keep the list sorted.
|
||||
|
||||
Dmitry Chestnykh <dmitry@codingrobots.com>
|
||||
Eddie Scholtz <eascholtz@gmail.com>
|
||||
Gabriel Simmer <bladesimmer@gmail.com>
|
||||
Google LLC (https://opensource.google.com/)
|
||||
honky <honky@defendtheplanet.net>
|
||||
James Gregory <james@jagregory.com>
|
||||
Jason Hall <imjasonh@gmail.com>
|
||||
Jason Moiron <jmoiron@jmoiron.net>
|
||||
Kamil Kisiel <kamil@kamilkisiel.net>
|
||||
Kevin Stock <kevinstock@tantalic.com>
|
||||
Markus Zimmermann <markus.zimmermann@nethead.at>
|
||||
Matt Silverlock <matt@eatsleeprepeat.net>
|
||||
Matthew Dawson <matthew@mjdsystems.ca>
|
||||
Milan Aleksic <milanaleksic@gmail.com>
|
||||
Milan Aleksić <milanaleksic@gmail.com>
|
||||
nlimpid <jshuangzl@gmail.com>
|
||||
Paul Petring <paul@defendtheplanet.net>
|
||||
Sean Enck <enckse@users.noreply.github.com>
|
||||
Sue Spence <virtuallysue@gmail.com>
|
||||
Supermighty <ukiah@faction.com>
|
||||
Toru Fukui <fukuimone@gmail.com>
|
||||
Vabd <vabd@anon.acme>
|
||||
Volker <lists.volker@gmail.com>
|
||||
ZhiFeng Hu <hufeng1987@gmail.com>
|
||||
weberc2 <weberc2@gmail.com>
|
||||
|
|
@ -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.
|
|
@ -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 <em>effectively</em>",
|
||||
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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>jmoiron.net blog</title>
|
||||
<link href="http://jmoiron.net/blog"></link>
|
||||
<id>http://jmoiron.net/blog</id>
|
||||
<updated>2013-01-16T03:26:01-05:00</updated>
|
||||
<summary>discussion about tech, footie, photos</summary>
|
||||
<entry>
|
||||
<title>Limiting Concurrency in Go</title>
|
||||
<link href="http://jmoiron.net/blog/limiting-concurrency-in-go/"></link>
|
||||
<updated>2013-01-16T03:26:01-05:00</updated>
|
||||
<id>tag:jmoiron.net,2013-01-16:/blog/limiting-concurrency-in-go/</id>
|
||||
<summary type="html">A discussion on controlled parallelism in golang</summary>
|
||||
<author>
|
||||
<name>Jason Moiron</name>
|
||||
<email>jmoiron@jmoiron.net</email>
|
||||
</author>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Logic-less Template Redux</title>
|
||||
<link href="http://jmoiron.net/blog/logicless-template-redux/"></link>
|
||||
<updated>2013-01-16T03:26:01-05:00</updated>
|
||||
<id>tag:jmoiron.net,2013-01-16:/blog/logicless-template-redux/</id>
|
||||
<summary type="html">More thoughts on logicless templates</summary>
|
||||
<author></author>
|
||||
</entry>
|
||||
<entry>
|
||||
<title>Idiomatic Code Reuse in Go</title>
|
||||
<link href="http://jmoiron.net/blog/idiomatic-code-reuse-in-go/"></link>
|
||||
<updated>2013-01-16T03:26:01-05:00</updated>
|
||||
<id>tag:jmoiron.net,2013-01-16:/blog/idiomatic-code-reuse-in-go/</id>
|
||||
<summary type="html">How to use interfaces <em>effectively</em></summary>
|
||||
<author></author>
|
||||
</entry>
|
||||
</feed>
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>jmoiron.net blog</title>
|
||||
<link>http://jmoiron.net/blog</link>
|
||||
<description>discussion about tech, footie, photos</description>
|
||||
<managingEditor>jmoiron@jmoiron.net (Jason Moiron)</managingEditor>
|
||||
<pubDate>2013-01-16T03:22:24-05:00</pubDate>
|
||||
<item>
|
||||
<title>Limiting Concurrency in Go</title>
|
||||
<link>http://jmoiron.net/blog/limiting-concurrency-in-go/</link>
|
||||
<description>A discussion on controlled parallelism in golang</description>
|
||||
<pubDate>2013-01-16T03:22:24-05:00</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Logic-less Template Redux</title>
|
||||
<link>http://jmoiron.net/blog/logicless-template-redux/</link>
|
||||
<description>More thoughts on logicless templates</description>
|
||||
<pubDate>2013-01-16T03:22:24-05:00</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Idiomatic Code Reuse in Go</title>
|
||||
<link>http://jmoiron.net/blog/idiomatic-code-reuse-in-go/</link>
|
||||
<description>How to use interfaces <em>effectively</em></description>
|
||||
<pubDate>2013-01-16T03:22:24-05:00</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -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 <link rel="enclosure" type="audio/mpeg" title="MP3" href="http://www.example.org/myaudiofile.mp3" length="1234" />
|
||||
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
|
||||
}
|
|
@ -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 <em>effectively</em>",
|
||||
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
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 <rss>..</rss> 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 <enclosure url="http://example.com/file.mp3" length="123456789" type="audio/mpeg" />
|
||||
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/",
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<title><![CDATA[Lorem ipsum feed for an interval of 1 minutes]]></title>
|
||||
<description><![CDATA[This is a constantly updating lorem ipsum feed]]></description>
|
||||
<link>http://example.com/</link>
|
||||
<generator>RSS for Node</generator>
|
||||
<lastBuildDate>Tue, 30 Oct 2018 23:22:37 GMT</lastBuildDate>
|
||||
<author><![CDATA[John Smith]]></author>
|
||||
<pubDate>Tue, 30 Oct 2018 23:22:00 GMT</pubDate>
|
||||
<copyright><![CDATA[Michael Bertolacci, licensed under a Creative Commons Attribution 3.0 Unported License.]]></copyright>
|
||||
<ttl>60</ttl>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:22:00+00:00]]></title>
|
||||
<description><![CDATA[Exercitation ut Lorem sint proident.]]></description>
|
||||
<link>http://example.com/test/1540941720</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941720</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:22:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:21:00+00:00]]></title>
|
||||
<description><![CDATA[Ea est do quis fugiat exercitation.]]></description>
|
||||
<link>http://example.com/test/1540941660</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941660</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:21:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:20:00+00:00]]></title>
|
||||
<description><![CDATA[Ipsum velit cillum ad laborum sit nulla exercitation consequat sint veniam culpa veniam voluptate incididunt.]]></description>
|
||||
<link>http://example.com/test/1540941600</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941600</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:20:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:19:00+00:00]]></title>
|
||||
<description><![CDATA[Ullamco pariatur aliqua consequat ea veniam id qui incididunt laborum.]]></description>
|
||||
<link>http://example.com/test/1540941540</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941540</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:19:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:18:00+00:00]]></title>
|
||||
<description><![CDATA[Velit proident aliquip aliquip anim mollit voluptate laboris voluptate et occaecat occaecat laboris ea nulla.]]></description>
|
||||
<link>http://example.com/test/1540941480</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941480</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:18:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:17:00+00:00]]></title>
|
||||
<description><![CDATA[Do in quis mollit consequat id in minim laborum sint exercitation laborum elit officia.]]></description>
|
||||
<link>http://example.com/test/1540941420</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941420</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:17:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:16:00+00:00]]></title>
|
||||
<description><![CDATA[Irure id sint ullamco Lorem magna consectetur officia adipisicing duis incididunt.]]></description>
|
||||
<link>http://example.com/test/1540941360</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941360</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:16:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:15:00+00:00]]></title>
|
||||
<description><![CDATA[Sunt anim excepteur esse nisi commodo culpa laborum exercitation ad anim ex elit.]]></description>
|
||||
<link>http://example.com/test/1540941300</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941300</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:15:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:14:00+00:00]]></title>
|
||||
<description><![CDATA[Excepteur aliquip fugiat ex labore nisi.]]></description>
|
||||
<link>http://example.com/test/1540941240</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941240</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:14:00 GMT</pubDate>
|
||||
</entry>
|
||||
<entry>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:13:00+00:00]]></title>
|
||||
<description><![CDATA[Id proident adipisicing proident pariatur aute pariatur pariatur dolor dolor in voluptate dolor.]]></description>
|
||||
<link>http://example.com/test/1540941180</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941180</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:13:00 GMT</pubDate>
|
||||
</entry>
|
||||
</feed>
|
|
@ -0,0 +1,96 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
|
||||
<channel>
|
||||
<title><![CDATA[Lorem ipsum feed for an interval of 1 minutes]]></title>
|
||||
<description><![CDATA[This is a constantly updating lorem ipsum feed]]></description>
|
||||
<link>http://example.com/</link>
|
||||
<generator>RSS for Node</generator>
|
||||
<lastBuildDate>Tue, 30 Oct 2018 23:22:37 GMT</lastBuildDate>
|
||||
<author><![CDATA[John Smith]]></author>
|
||||
<pubDate>Tue, 30 Oct 2018 23:22:00 GMT</pubDate>
|
||||
<copyright><![CDATA[Michael Bertolacci, licensed under a Creative Commons Attribution 3.0 Unported License.]]></copyright>
|
||||
<ttl>60</ttl>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:22:00+00:00]]></title>
|
||||
<description><![CDATA[Exercitation ut Lorem sint proident.]]></description>
|
||||
<link>http://example.com/test/1540941720</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941720</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:22:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:21:00+00:00]]></title>
|
||||
<description><![CDATA[Ea est do quis fugiat exercitation.]]></description>
|
||||
<link>http://example.com/test/1540941660</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941660</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:21:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:20:00+00:00]]></title>
|
||||
<description><![CDATA[Ipsum velit cillum ad laborum sit nulla exercitation consequat sint veniam culpa veniam voluptate incididunt.]]></description>
|
||||
<link>http://example.com/test/1540941600</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941600</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:20:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:19:00+00:00]]></title>
|
||||
<description><![CDATA[Ullamco pariatur aliqua consequat ea veniam id qui incididunt laborum.]]></description>
|
||||
<link>http://example.com/test/1540941540</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941540</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:19:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:18:00+00:00]]></title>
|
||||
<description><![CDATA[Velit proident aliquip aliquip anim mollit voluptate laboris voluptate et occaecat occaecat laboris ea nulla.]]></description>
|
||||
<link>http://example.com/test/1540941480</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941480</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:18:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:17:00+00:00]]></title>
|
||||
<description><![CDATA[Do in quis mollit consequat id in minim laborum sint exercitation laborum elit officia.]]></description>
|
||||
<link>http://example.com/test/1540941420</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941420</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:17:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:16:00+00:00]]></title>
|
||||
<description><![CDATA[Irure id sint ullamco Lorem magna consectetur officia adipisicing duis incididunt.]]></description>
|
||||
<link>http://example.com/test/1540941360</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941360</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:16:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:15:00+00:00]]></title>
|
||||
<description><![CDATA[Sunt anim excepteur esse nisi commodo culpa laborum exercitation ad anim ex elit.]]></description>
|
||||
<link>http://example.com/test/1540941300</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941300</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:15:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:14:00+00:00]]></title>
|
||||
<description><![CDATA[Excepteur aliquip fugiat ex labore nisi.]]></description>
|
||||
<link>http://example.com/test/1540941240</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941240</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:14:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Lorem ipsum 2018-10-30T23:13:00+00:00]]></title>
|
||||
<description><![CDATA[Id proident adipisicing proident pariatur aute pariatur pariatur dolor dolor in voluptate dolor.]]></description>
|
||||
<link>http://example.com/test/1540941180</link>
|
||||
<guid isPermaLink="true">http://example.com/test/1540941180</guid>
|
||||
<dc:creator><![CDATA[John Smith]]></dc:creator>
|
||||
<pubDate>Tue, 30 Oct 2018 23:13:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
|
@ -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)
|
||||
|
||||
```
|
||||
<itunes:author>
|
||||
<itunes:block>
|
||||
<itunes:catergory>
|
||||
<itunes:image>
|
||||
<itunes:duration>
|
||||
<itunes:explicit>
|
||||
<itunes:isClosedCaptioned>
|
||||
<itunes:order>
|
||||
<itunes:complete>
|
||||
<itunes:new-feed-url>
|
||||
<itunes:owner>
|
||||
<itunes:subtitle>
|
||||
<itunes:summary>
|
||||
<language>
|
||||
```
|
|
@ -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:])
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="128px" height="128px" id="RSSicon" viewBox="0 0 256 256">
|
||||
<defs>
|
||||
<linearGradient x1="0.085" y1="0.085" x2="0.915" y2="0.915" id="RSSg">
|
||||
<stop offset="0.0" stop-color="#E3702D"/><stop offset="0.1071" stop-color="#EA7D31"/>
|
||||
<stop offset="0.3503" stop-color="#F69537"/><stop offset="0.5" stop-color="#FB9E3A"/>
|
||||
<stop offset="0.7016" stop-color="#EA7C31"/><stop offset="0.8866" stop-color="#DE642B"/>
|
||||
<stop offset="1.0" stop-color="#D95B29"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="55" ry="55" x="0" y="0" fill="#CC5D15"/>
|
||||
<rect width="246" height="246" rx="50" ry="50" x="5" y="5" fill="#F49C52"/>
|
||||
<rect width="236" height="236" rx="47" ry="47" x="10" y="10" fill="url(#RSSg)"/>
|
||||
<circle cx="68" cy="189" r="24" fill="#FFF"/>
|
||||
<path d="M160 213h-34a82 82 0 0 0 -82 -82v-34a116 116 0 0 1 116 116z" fill="#FFF"/>
|
||||
<path d="M184 213A140 140 0 0 0 44 73 V 38a175 175 0 0 1 175 175z" fill="#FFF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
};
|
||||
|
|
|
@ -96,7 +96,11 @@ module.exports = function UserProfile() {
|
|||
/>
|
||||
<Checkbox
|
||||
id="locked"
|
||||
name="Manually approve follow requests? "
|
||||
name="Manually approve follow requests"
|
||||
/>
|
||||
<Checkbox
|
||||
id="enable_rss"
|
||||
name="Enable RSS feed of Public posts"
|
||||
/>
|
||||
{ !allowCustomCSS ? null :
|
||||
<TextArea
|
||||
|
|
|
@ -27,7 +27,12 @@
|
|||
<div class="entry">Posted <b>{{.account.StatusesCount}}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 id="recent">Latest public toots</h2>
|
||||
<h2 id="recent">
|
||||
<span>Latest public toots</span>
|
||||
{{ if .rssFeed }}
|
||||
<a href="{{ .rssFeed }}"><img class="rsslogo" src="/assets/rss.svg" alt="The orange RSS logo."/></a>
|
||||
{{ end }}
|
||||
</h2>
|
||||
{{ if not .statuses }}
|
||||
<div data-nosnippet class="nothinghere">Nothing here!</div>
|
||||
{{ else }}
|
||||
|
|
Loading…
Reference in New Issue