feat(activity): show the current activity on the profile card

The activity pipeline was already complete end to end — Activity type, store,
WS broadcast, server validation, presence relay, and an ActivityCard used by
four list surfaces — but the profile card rendered none of it, which is the
'Listening to Spotify' block the design calls for.

Add ProfileActivity: richer than ActivityCard because the card has room for
artwork, track and artist, so it reads details/state/assets. All optional, so
it degrades to the bare name that today's process-based detector supplies.

Also scheme-check activity image assets server-side. activity.url was already
restricted to http(s) but assets.largeImage/smallImage were only length-checked
— an asymmetry that was harmless while nothing rendered them, and is not once
they become <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
This commit is contained in:
2026-08-31 12:05:01 -03:00
parent bfe62d7078
commit d7da0ff203
3 changed files with 131 additions and 2 deletions
+13 -2
View File
@@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null {
if (obj.assets && typeof obj.assets === 'object') {
const aObj = obj.assets as Record<string, unknown>;
const assets: ActivityAssets = {};
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
// Image assets are rendered as <img src> by clients, so they get the same
// scheme check `url` above already has. Without it a client could point
// them at a host it controls and harvest the IP of everyone who opens
// that profile — and data: URIs would smuggle payloads through a field
// only length-checked.
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
if (Object.keys(assets).length > 0) activity.assets = assets;
}
@@ -485,6 +492,10 @@ function validateActivities(raw: unknown): Activity[] | null {
return validated;
}
function isHttpUrl(value: string): boolean {
return value.startsWith('https://') || value.startsWith('http://');
}
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
const status = event.status as string;