summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/overrides/public/user-settings-listenbrainz-fields.pngbin0 -> 34371 bytes
-rw-r--r--docs/overrides/public/user-settings-listenbrainz.pngbin0 -> 35869 bytes
-rw-r--r--docs/user_guide/settings.md13
-rw-r--r--internal/middleware/contentsecuritypolicy.go16
-rw-r--r--internal/middleware/contentsecuritypolicy_test.go12
-rw-r--r--web/source/frontend/index.js167
6 files changed, 200 insertions, 8 deletions
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
--- /dev/null
+++ b/docs/overrides/public/user-settings-listenbrainz-fields.png
Binary files 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
--- /dev/null
+++ b/docs/overrides/public/user-settings-listenbrainz.png
Binary files 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
@@ -70,6 +71,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 <dt> 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 <dd> inside this
+ // field? Weird but OK.
+ return;
+ }
+
+ // Look for an <a> tag inside the <dd>.
+ 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 <dd> 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);
+ }
+});