diff --git a/docs/overrides/public/user-settings-listenbrainz-fields.png b/docs/overrides/public/user-settings-listenbrainz-fields.png new file mode 100644 index 000000000..a54f2a232 Binary files /dev/null and b/docs/overrides/public/user-settings-listenbrainz-fields.png differ diff --git a/docs/overrides/public/user-settings-listenbrainz.png b/docs/overrides/public/user-settings-listenbrainz.png new file mode 100644 index 000000000..51f53d1eb Binary files /dev/null and b/docs/overrides/public/user-settings-listenbrainz.png differ diff --git a/docs/user_guide/settings.md b/docs/user_guide/settings.md index ab095288a..96cebe911 100644 --- a/docs/user_guide/settings.md +++ b/docs/user_guide/settings.md @@ -98,6 +98,19 @@ Some examples: - Pronouns : she/her - My other account : @someone@somewhere.com +!!! Tip "ListenBrainz integration" + If you set the key of one of your profile fields to "ListenBrainz" and the value to the URL of your ListenBrainz profile (something like `https://listenbrainz.org/user/your_listenbrainz_username/` -- the slash at the end is important!), then the field will be replaced on the web frontend with whatever you're currently listening to! + + This only applies to the web view of your GoToSocial profile, for visitors with Javascript enabled; the "currently listening" value doesn't federate to other servers, only your ListenBrainz URL. + + How to set it: + +  + + How it looks on the web when you're listening to something: + +  + ### Visibility and Privacy #### Visibility Level of Posts to Show on Your Profile diff --git a/internal/middleware/contentsecuritypolicy.go b/internal/middleware/contentsecuritypolicy.go index fb35c3a08..eb5168376 100644 --- a/internal/middleware/contentsecuritypolicy.go +++ b/internal/middleware/contentsecuritypolicy.go @@ -37,6 +37,7 @@ func ContentSecurityPolicy(extraURIs ...string) gin.HandlerFunc { func BuildContentSecurityPolicy(extraURIs ...string) string { const ( defaultSrc = "default-src" + connectSrc = "connect-src" objectSrc = "object-src" imgSrc = "img-src" mediaSrc = "media-src" @@ -48,7 +49,7 @@ func BuildContentSecurityPolicy(extraURIs ...string) string { ) // CSP values keyed by directive. - values := make(map[string][]string, 4) + values := make(map[string][]string, 5) /* default-src @@ -69,6 +70,16 @@ func BuildContentSecurityPolicy(extraURIs ...string) string { } } + /* + connect-src + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src + */ + + // Restrictive default policy, but + // include ListenBrainz API for fields. + const listenBrains = "https://api.listenbrainz.org/1/user/" + values[connectSrc] = append(values[defaultSrc], listenBrains) //nolint + /* object-src https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/object-src @@ -118,9 +129,10 @@ func BuildContentSecurityPolicy(extraURIs ...string) string { // Iterate through an ordered slice rather than // iterating through the map, since we want these // policyDirectives in a determinate order. - policyDirectives := make([]string, 4) + policyDirectives := make([]string, 5) for i, directive := range []string{ defaultSrc, + connectSrc, objectSrc, imgSrc, mediaSrc, diff --git a/internal/middleware/contentsecuritypolicy_test.go b/internal/middleware/contentsecuritypolicy_test.go index a337763df..ef6dc2bf8 100644 --- a/internal/middleware/contentsecuritypolicy_test.go +++ b/internal/middleware/contentsecuritypolicy_test.go @@ -32,38 +32,38 @@ func TestBuildContentSecurityPolicy(t *testing.T) { for _, test := range []cspTest{ { extraURLs: nil, - expected: "default-src 'self'; object-src 'none'; img-src 'self' blob:; media-src 'self'", + expected: "default-src 'self'; connect-src 'self' https://api.listenbrainz.org/1/user/; object-src 'none'; img-src 'self' blob:; media-src 'self'", }, { extraURLs: []string{ "https://some-bucket-provider.com", }, - expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com; media-src 'self' https://some-bucket-provider.com", + expected: "default-src 'self'; connect-src 'self' https://api.listenbrainz.org/1/user/; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com; media-src 'self' https://some-bucket-provider.com", }, { extraURLs: []string{ "https://some-bucket-provider.com:6969", }, - expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com:6969; media-src 'self' https://some-bucket-provider.com:6969", + expected: "default-src 'self'; connect-src 'self' https://api.listenbrainz.org/1/user/; object-src 'none'; img-src 'self' blob: https://some-bucket-provider.com:6969; media-src 'self' https://some-bucket-provider.com:6969", }, { extraURLs: []string{ "http://some-bucket-provider.com:6969", }, - expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: http://some-bucket-provider.com:6969; media-src 'self' http://some-bucket-provider.com:6969", + expected: "default-src 'self'; connect-src 'self' https://api.listenbrainz.org/1/user/; object-src 'none'; img-src 'self' blob: http://some-bucket-provider.com:6969; media-src 'self' http://some-bucket-provider.com:6969", }, { extraURLs: []string{ "https://s3.nl-ams.scw.cloud", }, - expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud; media-src 'self' https://s3.nl-ams.scw.cloud", + expected: "default-src 'self'; connect-src 'self' https://api.listenbrainz.org/1/user/; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud; media-src 'self' https://s3.nl-ams.scw.cloud", }, { extraURLs: []string{ "https://s3.nl-ams.scw.cloud", "https://s3.somewhere.else.example.org", }, - expected: "default-src 'self'; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud https://s3.somewhere.else.example.org; media-src 'self' https://s3.nl-ams.scw.cloud https://s3.somewhere.else.example.org", + expected: "default-src 'self'; connect-src 'self' https://api.listenbrainz.org/1/user/; object-src 'none'; img-src 'self' blob: https://s3.nl-ams.scw.cloud https://s3.somewhere.else.example.org; media-src 'self' https://s3.nl-ams.scw.cloud https://s3.somewhere.else.example.org", }, } { csp := middleware.BuildContentSecurityPolicy(test.extraURLs...) diff --git a/web/source/frontend/index.js b/web/source/frontend/index.js index a1c2ca74b..25c948795 100644 --- a/web/source/frontend/index.js +++ b/web/source/frontend/index.js @@ -267,3 +267,170 @@ document.body.addEventListener("click", (e) => { // stats elements, close it. openStats.removeAttribute("open"); }); + +// Scan for the first ListenBrainz profile field and replace +// its value with currently listening track if available. +// +// ListenBrainz allows a lot of leeway in usernames so be gentle here: +// +// See: +// +// - https://github.com/metabrainz/musicbrainz-server/blob/master/lib/MusicBrainz/Server/Form/Utils.pm#L264-L288 +// - https://regex101.com/r/k5ij9F/1 +const listenbrainzRe = new RegExp(/^https:\/\/listenbrainz\.org\/user\/([^/]+)\/$/, "u"); +let calledListenBrainz = false; +document.querySelectorAll("div#profile-fields dl div.field").forEach((field) => { + // If we called ListenBrainz once + // already this page load, bail. + if (calledListenBrainz) { + return; + } + + const k = field.querySelector("dt"); + if (!k) { + // No