Files
Psychotoxical-psysonic/src/cover/integrations/discord.ts
T
Psychotoxical a451509d94 feat(discord): restore server cover source via public album-info URLs (#1299)
* feat(discord): allow the server cover source in the store again

DiscordCoverSource gains 'server' back. The rehydrate migration that
forced any persisted 'server' preference to 'none' is dropped — that
coercion only made sense while the source didn't exist.

* feat(discord): resolve server covers via credential-free album-info URLs

Add a resolver that fetches cover art for the Discord 'server' source
through the standard Subsonic getAlbumInfo2 endpoint, never through an
authenticated getCoverArt URL (that was the leak fixed in #1246).

- sanitizeDiscordCoverUrl rejects anything unfit to publish: non-https,
  embedded userinfo, auth-shaped query params (u/t/s/apiKey/jwt/token/...),
  LAN/loopback hosts
- resolveServerCoverForDiscord takes only an album id and a share-base
  string, never a server profile or credentials, and session-caches
  results (including negative ones) per album
- rewrites a LAN-scoped album-info response to the profile's public
  share address, keeping path + query, so the app being connected over
  LAN doesn't hide an otherwise-public /share/img/<jwt> link
- discordPresence.ts wires the 'server' branch in with a staleness
  guard so a slow resolve can't overwrite a newer track's presence

* feat(discord): add the server option back to the cover-source picker

Three-way segmented control again: none / server / apple. The cover
description now discloses that server covers reveal the server's
public address to anyone viewing the presence (no credentials).

Restores discordCoverServer in all 14 locales — the Italian locale
already carried an unused copy of the key from before it existed at
removal time, now wired up instead of left dead.

* fix(discord): reject non-publishable image URLs before they reach Discord

is_publishable_image_url is a backstop applied to every artwork_url
(cover_art_url param and the iTunes result alike) right before it can
become a large_image asset: https only, no userinfo, no auth-shaped
query params. This is the layer a future frontend refactor can't
silently bypass — the failure mode that turned the original
credential-free server-cover design (#462) into the leak fixed in
#1246 was exactly that kind of regression, on the frontend only.

* docs(changelog): note Discord server cover source restoration (#1299)

* fix(discord): close the LAN-host gap in the Rust publish backstop

is_publishable_image_url checked scheme/userinfo/credential params but
not host locality, despite its doc comment claiming parity with the TS
sanitizer's isLanUrl check — a self-review found the gap. Reuses
psysonic-core::log_sanitize's existing is_lan_host (now pub) instead
of a second hand-written LAN-detection copy.

* fix(discord): don't silently resurrect a pre-#1246 'server' preference

The rehydrate migration that forced any persisted 'server' cover
source to 'none' was dropped in the initial revival commit, which
meant a user skipping straight from a pre-#1246 build to this one
would have gotten it silently reactivated without ever seeing the new
opt-in disclosure. Restored as a one-time, sentinel-gated migration:
it still catches that stale value exactly once, but never coerces a
deliberate post-revival choice back.

* fix(discord): harden the server-cover sync against races and cross-server tracks

- Staleness guard on the async resolve now also rechecks
  discordRichPresence/discordCoverSource, not just the track id, so a
  slow resolve can't revive presence after it was disabled or the
  cover source was switched away from 'server' while in flight.
- Skip server-cover resolution (fall back to the app icon) when the
  playing track isn't from the active server — getAlbumInfo2 always
  queries the active server, so a mixed-server queue could otherwise
  ask the wrong server for an album id.
- The change-detection gate now also reacts to the active server
  profile's share-base changing (e.g. adding a public address), not
  only to track/play-state/cover-source/template changes.
- Server profile/shareBase is now computed once per sync and reused,
  instead of a second useAuthStore.getState() call inside the branch.

* fix(discord): preserve reverse-proxy paths and TTL the server-cover cache

- rewriteOriginToShareBase only swapped protocol/hostname/port, so a
  server reachable behind a reverse-proxy subpath (shareBase carrying
  a path prefix) lost that prefix and produced a 404ing URL. The
  prefix is now preserved.
- serverCoverCache entries (including negative ones) no longer live
  forever — a 1h TTL, matching the existing Rust iTunes-artwork cache,
  means a transient resolve failure doesn't hide an album's cover for
  the rest of the session.
2026-07-14 13:52:05 +02:00

111 lines
4.4 KiB
TypeScript

import { getAlbumInfo2 } from '@/lib/api/subsonicAlbumInfo';
import { isLanUrl } from '@/lib/server/serverEndpoint';
/**
* Query-param keys that carry a replayable Subsonic (or generic API) secret.
* Any URL carrying one of these must never be published to Discord — its
* external image proxy exposes the full source URL to anyone viewing the
* presence. Checked case-insensitively; Subsonic's own keys (`u`/`t`/`s`) are
* lower-case but the defensive variants guard against other backends.
*/
const CREDENTIAL_PARAM_KEYS = new Set(['u', 't', 's', 'p', 'apikey', 'jwt', 'token', 'auth']);
/**
* Gate every URL before it may become a Discord `large_image`. Discord's
* external image proxy re-publishes the source URL to anyone who can view
* the presence, so this rejects anything that isn't safe to publish:
* https only, no embedded userinfo, no auth-shaped query params, no
* LAN/loopback host (dead weight for Discord, reveals network topology).
*/
export function sanitizeDiscordCoverUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
let url: URL;
try {
url = new URL(raw);
} catch {
return null;
}
if (url.protocol !== 'https:') return null;
if (url.username || url.password) return null;
for (const key of url.searchParams.keys()) {
if (CREDENTIAL_PARAM_KEYS.has(key.toLowerCase())) return null;
}
if (isLanUrl(url.origin)) return null;
return url.toString();
}
/**
* Swap `raw`'s origin for `shareBase`'s, keeping query untouched and
* preserving both URLs' paths. Navidrome's `getAlbumInfo2` derives the image
* host from the request that reached it — when the app is connected over the
* LAN address, the returned URL is LAN-scoped even though the server also
* has a public address configured. The `/share/img/<jwt>` path itself is
* host-independent, so pointing it at the profile's public share address
* makes it reachable for Discord — but `shareBase` may itself carry a path
* prefix (a server reachable behind a reverse proxy at e.g.
* `https://host/nav`), which must be kept, not dropped, or the rewritten URL
* 404s against the actual public endpoint.
*/
function rewriteOriginToShareBase(raw: string, shareBase: string): string {
try {
const url = new URL(raw);
const share = new URL(shareBase);
if (url.origin === share.origin) return raw;
url.protocol = share.protocol;
url.hostname = share.hostname;
url.port = share.port;
const sharePrefix = share.pathname.replace(/\/$/, '');
if (sharePrefix && !url.pathname.startsWith(sharePrefix)) {
url.pathname = `${sharePrefix}${url.pathname}`;
}
return url.toString();
} catch {
return raw;
}
}
interface ServerCoverCacheEntry {
url: string | null;
fetchedAt: number;
}
/**
* Session cache: `"<shareBase>|<albumId>"` -> resolved (already sanitized)
* cover URL, or `null` for a miss/failure. Negative results are cached too —
* at most one `getAlbumInfo2` call per album per server per TTL window.
* TTL (not "forever") so a transient failure (server briefly unreachable,
* timeout) doesn't hide an album's cover for the rest of the session —
* mirrors the Rust-side iTunes artwork cache TTL for the same reason.
*/
const SERVER_COVER_CACHE_TTL_MS = 60 * 60 * 1000;
const serverCoverCache = new Map<string, ServerCoverCacheEntry>();
/**
* Resolve a credential-free Discord cover URL for `albumId` via the
* standard Subsonic `getAlbumInfo2` endpoint. Deliberately takes only an
* album id and a share-base string — never a server profile/credentials —
* so this resolver has no way to construct an authenticated URL, unlike the
* removed `coverArtUrlForDiscord` that leaked `u`/`t`/`s` (see PR #1246).
*/
export async function resolveServerCoverForDiscord(
albumId: string,
shareBase: string | null,
): Promise<string | null> {
const cacheKey = `${shareBase ?? ''}|${albumId}`;
const cached = serverCoverCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < SERVER_COVER_CACHE_TTL_MS) {
return cached.url;
}
let result: string | null = null;
const info = await getAlbumInfo2(albumId);
const raw = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl;
if (raw) {
const rewritten = shareBase ? rewriteOriginToShareBase(raw, shareBase) : raw;
result = sanitizeDiscordCoverUrl(rewritten);
}
serverCoverCache.set(cacheKey, { url: result, fetchedAt: Date.now() });
return result;
}