mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-28 10:06:47 +00:00
a451509d94
* 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.
111 lines
4.4 KiB
TypeScript
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;
|
|
}
|