mirror of
https://github.com/superseriousbusiness/gotosocial
synced 2025-06-05 21:59:39 +02:00
[feature] Add ListenBrainz functionality on the web view (#4184)
This pull request adds a very simple ad-hoc ListenBrainz widget to the frontend web view, with progressive enhancement (in all fail states it just falls back to rendering the field as normal). This necessitated adding the ListenBrainz API endpoint to the `connect-src` part of our Content-Security-Policy header. We might want to tweak this to only add that endpoint to `connect-src` for profiles, and then only for profiles that include a ListenBrainz field, but this would require significant dicking about with the middleware, and checks inside the app logic, such that it might not be worthwhile (after all, we control all the scripts right now anyway). Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4184 Co-authored-by: tobi <tobi.smethurst@protonmail.com> Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
BIN
docs/overrides/public/user-settings-listenbrainz-fields.png
Normal file
BIN
docs/overrides/public/user-settings-listenbrainz-fields.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 34 KiB |
BIN
docs/overrides/public/user-settings-listenbrainz.png
Normal file
BIN
docs/overrides/public/user-settings-listenbrainz.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 35 KiB |
@ -98,6 +98,19 @@ Some examples:
|
|||||||
- Pronouns : she/her
|
- Pronouns : she/her
|
||||||
- My other account : @someone@somewhere.com
|
- 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 and Privacy
|
||||||
|
|
||||||
#### Visibility Level of Posts to Show on Your Profile
|
#### Visibility Level of Posts to Show on Your Profile
|
||||||
|
@ -37,6 +37,7 @@ func ContentSecurityPolicy(extraURIs ...string) gin.HandlerFunc {
|
|||||||
func BuildContentSecurityPolicy(extraURIs ...string) string {
|
func BuildContentSecurityPolicy(extraURIs ...string) string {
|
||||||
const (
|
const (
|
||||||
defaultSrc = "default-src"
|
defaultSrc = "default-src"
|
||||||
|
connectSrc = "connect-src"
|
||||||
objectSrc = "object-src"
|
objectSrc = "object-src"
|
||||||
imgSrc = "img-src"
|
imgSrc = "img-src"
|
||||||
mediaSrc = "media-src"
|
mediaSrc = "media-src"
|
||||||
@ -48,7 +49,7 @@ func BuildContentSecurityPolicy(extraURIs ...string) string {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CSP values keyed by directive.
|
// CSP values keyed by directive.
|
||||||
values := make(map[string][]string, 4)
|
values := make(map[string][]string, 5)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
default-src
|
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
|
object-src
|
||||||
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/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
|
// Iterate through an ordered slice rather than
|
||||||
// iterating through the map, since we want these
|
// iterating through the map, since we want these
|
||||||
// policyDirectives in a determinate order.
|
// policyDirectives in a determinate order.
|
||||||
policyDirectives := make([]string, 4)
|
policyDirectives := make([]string, 5)
|
||||||
for i, directive := range []string{
|
for i, directive := range []string{
|
||||||
defaultSrc,
|
defaultSrc,
|
||||||
|
connectSrc,
|
||||||
objectSrc,
|
objectSrc,
|
||||||
imgSrc,
|
imgSrc,
|
||||||
mediaSrc,
|
mediaSrc,
|
||||||
|
@ -32,38 +32,38 @@ func TestBuildContentSecurityPolicy(t *testing.T) {
|
|||||||
for _, test := range []cspTest{
|
for _, test := range []cspTest{
|
||||||
{
|
{
|
||||||
extraURLs: nil,
|
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{
|
extraURLs: []string{
|
||||||
"https://some-bucket-provider.com",
|
"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{
|
extraURLs: []string{
|
||||||
"https://some-bucket-provider.com:6969",
|
"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{
|
extraURLs: []string{
|
||||||
"http://some-bucket-provider.com:6969",
|
"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{
|
extraURLs: []string{
|
||||||
"https://s3.nl-ams.scw.cloud",
|
"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{
|
extraURLs: []string{
|
||||||
"https://s3.nl-ams.scw.cloud",
|
"https://s3.nl-ams.scw.cloud",
|
||||||
"https://s3.somewhere.else.example.org",
|
"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...)
|
csp := middleware.BuildContentSecurityPolicy(test.extraURLs...)
|
||||||
|
@ -267,3 +267,170 @@ document.body.addEventListener("click", (e) => {
|
|||||||
// stats elements, close it.
|
// stats elements, close it.
|
||||||
openStats.removeAttribute("open");
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Reference in New Issue
Block a user