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: + + ![Field filled with a ListenBrainz URL.](../public/user-settings-listenbrainz-fields.png) + + How it looks on the web when you're listening to something: + + ![The "Now listening to" widget on the web view.](../public/user-settings-listenbrainz.png) + ### 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
inside this + // field? Weird but OK. + return; + } + + const kText = k.textContent; + if (kText === null) { + // Also strange but + // let's just bail. + return; + } + + // Check if key == "ListenBrainz" (case insensitive). + if (kText.localeCompare("ListenBrainz", undefined, { sensitivity: "base" }) !== 0) { + // Not interested. + return; + } + + // Get the value. + const v = field.querySelector("dd"); + if (!v) { + // No
inside this + // field? Weird but OK. + return; + } + + // Look for an tag inside the
. + const oldAs = v.getElementsByTagName("a"); + if (oldAs.length !== 1) { + // Nothing + // in here. + return; + } + + const oldA = oldAs[0]; + const profileURL = oldA.textContent; + if (!profileURL) { + // Also strange but + // let's just bail. + return; + } + + // We're looking for a listenbrainz URL. + const match = profileURL.match(listenbrainzRe); + if (match.length !== 2) { + // Not a match. + return; + } + const lbUsername = match[1]; + + try { + // MusicBrainz/ListenBrainz is very permissive + // re: usernames so make sure to encode the URI + // when doing the fetch, to avoid any shenanigans. + const apiURL = encodeURI(`https://api.listenbrainz.org/1/user/${lbUsername}/playing-now`); + fetch(apiURL).then(res => { + // Mark that we + // called LB already. + calledListenBrainz = true; + + // Check result... + if (!res.ok) { + throw new Error(`Response status: ${res.status}`); + } + + return res.json(); + }).then(json => { + // Parse out the object. + const payload = json.payload; + if (!payload) { + // Can't do anything + // with no payload. + return; + } + + const listens = payload.listens; + if (!listens || !Array.isArray(listens) || listens.length !== 1) { + // Can't do anything + // with no listens. + return; + } + + const listen = listens[0]; + const trackMetadata = listen.track_metadata; + if (!trackMetadata) { + // Can't do anything + // with no track metadata. + return; + } + + const artistName = trackMetadata.artist_name; + const trackName = trackMetadata.track_name; + if (artistName === undefined || trackName === undefined) { + // Can't display + // this track. + return; + } + + // We can work with this. + // + // Rewrite the existing
with the + // current listening song, and keep the + // link to the user's ListenBrainz profile. + const vNew = document.createElement("dd"); + + // Lil music note icon. + const i = document.createElement("i"); + i.ariaHidden = "true"; + i.className = "fa fa-fw fa-music"; + vNew.appendChild(i); + + vNew.appendChild(document.createTextNode(" Now listening to: ")); + vNew.appendChild(document.createElement("br")); + + // Build the new link, taking + // the href from the old link. + const a = document.createElement("a"); + a.href = oldA.href; + a.rel = "nofollow noreferrer noopener"; + a.target = "_blank"; + + // Add track name in bold. + const trackNameE = document.createElement("b"); + trackNameE.textContent = trackName; + a.appendChild(trackNameE); + + // Add joiner in normal font. + a.appendChild(document.createTextNode(" by ")); + + // Add artist name in bold. + const artistNameE = document.createElement("b"); + artistNameE.textContent = artistName; + a.appendChild(artistNameE); + + // Put the link + // in the definish. + vNew.appendChild(a); + + // Do the replacement. + field.replaceChild(vNew, v); + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error.message); + } +});