mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
fix(discord): drop server cover source that leaked credentials (#1246)
* fix(discord): drop server cover source that leaked credentials The "server" Discord cover source built an authenticated Subsonic getCoverArt URL (carrying the username, auth token, and salt) and handed it to Discord as the large image. Discord fetches external images through its own proxy and exposes the full source URL to anyone viewing the rich presence, leaking replayable server credentials. - Remove the "server" cover source; keep "None" (app icon) and "Apple Music" (iTunes, credential-free), both resolved without server auth - Migrate persisted "server" preference to "None" on rehydrate; new default is "None" - Drop the now-dead frontend cover-URL builder and its test - Move the two-option picker to the shared SettingsSegmented control - Remove the obsolete locale key across all 13 locales * docs(changelog): note Discord server cover credential fix (#1246)
This commit is contained in:
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* coverArtUrlForDiscord — Discord fetches the large image from its own servers,
|
||||
* so the URL must use the public address, not the LAN-preferred connect URL
|
||||
* (regression from the dual-address feature).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { makeServer } from '@/test/helpers/factories';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { coverArtUrlForDiscord } from './discord';
|
||||
import type { CoverArtRef } from '../types';
|
||||
|
||||
function refForServer(serverId: string, url: string): CoverArtRef {
|
||||
return {
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'al-1',
|
||||
fetchCoverArtId: 'al-1',
|
||||
serverScope: { kind: 'server', serverId, url, username: 'tester', password: 'pw' },
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
describe('coverArtUrlForDiscord', () => {
|
||||
it('uses the public address on a dual-address profile, not the LAN one', async () => {
|
||||
const server = makeServer({
|
||||
url: 'http://192.168.1.50:4533',
|
||||
alternateUrl: 'https://music.example.com',
|
||||
});
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
expect(url).not.toContain('192.168.1.50');
|
||||
});
|
||||
|
||||
it('returns the single configured address when there is no alternate', async () => {
|
||||
const server = makeServer({ url: 'https://music.example.com', alternateUrl: undefined });
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const url = await coverArtUrlForDiscord(refForServer(server.id, server.url));
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
});
|
||||
|
||||
it('resolves a playback scope to the active profile (public address)', async () => {
|
||||
// playback scope is the common case for locally-cached tracks; it must
|
||||
// resolve to the active server, not yield a null cover.
|
||||
const server = makeServer({
|
||||
url: 'http://192.168.1.50:4533',
|
||||
alternateUrl: 'https://music.example.com',
|
||||
});
|
||||
useAuthStore.setState({ servers: [server], activeServerId: server.id } as never);
|
||||
|
||||
const ref: CoverArtRef = {
|
||||
cacheKind: 'album',
|
||||
cacheEntityId: 'al-1',
|
||||
fetchCoverArtId: 'al-1',
|
||||
serverScope: { kind: 'playback' },
|
||||
};
|
||||
const url = await coverArtUrlForDiscord(ref);
|
||||
|
||||
expect(url).toContain('music.example.com');
|
||||
expect(url).not.toContain('192.168.1.50');
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
import { buildCoverArtUrlForServer } from '@/lib/api/subsonicStreamUrl';
|
||||
import { serverShareBaseUrl } from '@/lib/server/serverEndpoint';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import type { CoverArtRef } from '../types';
|
||||
|
||||
/**
|
||||
* Discord large image — an https:// URL Discord's own servers can reach.
|
||||
*
|
||||
* Unlike every other cover fetch we must NOT use the connect URL: that prefers
|
||||
* the LAN address (fast for the app itself), but Discord fetches the image
|
||||
* remotely, so a `http://192.168.x.x` address is unreachable and falls back to
|
||||
* the app icon. Discord is an external consumer just like a share link, so use
|
||||
* `serverShareBaseUrl` (public address preferred when both are set).
|
||||
*
|
||||
* Resolve the profile straight from the store: a `playback`/`active` scope
|
||||
* always means the active server (a cross-server track gets an explicit
|
||||
* `server` scope), so we never route through `getPlaybackServerId()`, whose
|
||||
* empty-string / index-key returns previously yielded a null cover URL on
|
||||
* locally-cached tracks.
|
||||
*/
|
||||
export async function coverArtUrlForDiscord(ref: CoverArtRef): Promise<string | null> {
|
||||
const { serverScope, fetchCoverArtId } = ref;
|
||||
const auth = useAuthStore.getState();
|
||||
|
||||
const profile =
|
||||
serverScope.kind === 'server'
|
||||
? auth.servers.find(s => s.id === serverScope.serverId)
|
||||
: auth.servers.find(s => s.id === auth.activeServerId);
|
||||
|
||||
if (profile) {
|
||||
return buildCoverArtUrlForServer(
|
||||
serverShareBaseUrl(profile),
|
||||
profile.username,
|
||||
profile.password,
|
||||
fetchCoverArtId,
|
||||
800,
|
||||
) || null;
|
||||
}
|
||||
|
||||
// Server scope carries its own URL/creds even when not a saved profile.
|
||||
if (serverScope.kind === 'server') {
|
||||
return buildCoverArtUrlForServer(
|
||||
serverShareBaseUrl({ url: serverScope.url }),
|
||||
serverScope.username,
|
||||
serverScope.password,
|
||||
fetchCoverArtId,
|
||||
800,
|
||||
) || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { commands } from '@/generated/bindings';
|
||||
import { resolvePlaybackCoverScope } from '@/cover/ref';
|
||||
import { resolveTrackCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
|
||||
import { coverArtUrlForDiscord } from '@/cover/integrations/discord';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
|
||||
@@ -20,7 +17,6 @@ export function setupDiscordPresence(): () => void {
|
||||
let discordPrevTemplateLargeText: string | null = null;
|
||||
let discordPrevTemplateName: string | null = null;
|
||||
let discordPrevCoverSource: string | null = null;
|
||||
const discordServerCoverCache = new Map<string, string | null>();
|
||||
|
||||
function syncDiscord() {
|
||||
const { currentTrack, isPlaying } = usePlayerStore.getState();
|
||||
@@ -81,39 +77,12 @@ export function setupDiscordPresence(): () => void {
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
if (discordCoverSource === 'server' && currentTrack.coverArt) {
|
||||
const cacheKey = currentTrack.coverArt;
|
||||
const cached = discordServerCoverCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
sendPresence(cached);
|
||||
} else {
|
||||
void resolveTrackCoverRefFromLibrary(
|
||||
{
|
||||
id: currentTrack.id,
|
||||
albumId: currentTrack.albumId,
|
||||
coverArt: currentTrack.coverArt,
|
||||
discNumber: (currentTrack as { discNumber?: number }).discNumber,
|
||||
},
|
||||
resolvePlaybackCoverScope(),
|
||||
).then(ref => {
|
||||
if (!ref) {
|
||||
sendPresence(null);
|
||||
return;
|
||||
}
|
||||
return coverArtUrlForDiscord(ref)
|
||||
.then(url => {
|
||||
discordServerCoverCache.set(cacheKey, url);
|
||||
sendPresence(url);
|
||||
})
|
||||
.catch(() => {
|
||||
discordServerCoverCache.set(cacheKey, null);
|
||||
sendPresence(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sendPresence(null);
|
||||
}
|
||||
// Cover art is resolved Rust-side: 'apple' triggers the iTunes lookup via
|
||||
// the fetchItunesCovers flag above; 'none' shows just the app icon. The
|
||||
// frontend never builds a cover URL for Discord — the removed 'server'
|
||||
// source leaked the authenticated Subsonic getCoverArt URL (u/t/s) through
|
||||
// Discord's public external image proxy.
|
||||
sendPresence(null);
|
||||
}
|
||||
|
||||
const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import SettingsSubSection from '@/features/settings/components/SettingsSubSection';
|
||||
import { SettingsGroup } from '@/features/settings/components/SettingsGroup';
|
||||
import { SettingsToggle } from '@/features/settings/components/SettingsToggle';
|
||||
import { SettingsSegmented, type SegmentedOption } from '@/features/settings/components/SettingsSegmented';
|
||||
import { SettingsSubCard, SettingsField } from '@/features/settings/components/SettingsSubCard';
|
||||
import type { DiscordCoverSource } from '@/store/authStoreTypes';
|
||||
import { BackdropSourceList } from '@/features/settings/components/BackdropSourceList';
|
||||
import type { BackdropSurface } from '@/store/themeStore';
|
||||
import type { BackdropSource } from '@/cover/artistBackdrop';
|
||||
@@ -22,6 +24,10 @@ export function IntegrationsTab() {
|
||||
{ key: 'artistDetailHero', label: t('settings.backdropSurfaceArtistDetail') },
|
||||
{ key: 'fullscreenPlayer', label: t('settings.backdropSurfaceFullscreen') },
|
||||
];
|
||||
const discordCoverOptions: SegmentedOption<DiscordCoverSource>[] = [
|
||||
{ id: 'none', label: t('settings.discordCoverNone') },
|
||||
{ id: 'apple', label: t('settings.discordCoverApple') },
|
||||
];
|
||||
const backdropSourceLabel = (s: BackdropSource): string =>
|
||||
s === 'banner'
|
||||
? t('settings.backdropSourceBanner')
|
||||
@@ -73,22 +79,10 @@ export function IntegrationsTab() {
|
||||
{auth.discordRichPresence && (
|
||||
<>
|
||||
<SettingsGroup title={t('settings.discordCoverTitle')} desc={t('settings.discordCoverDesc')}>
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverNone')}
|
||||
checked={auth.discordCoverSource === 'none'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'none' : 'server')}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverServer')}
|
||||
checked={auth.discordCoverSource === 'server'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'server' : 'none')}
|
||||
/>
|
||||
<div className="settings-section-divider" />
|
||||
<SettingsToggle
|
||||
label={t('settings.discordCoverApple')}
|
||||
checked={auth.discordCoverSource === 'apple'}
|
||||
onChange={c => auth.setDiscordCoverSource(c ? 'apple' : 'none')}
|
||||
<SettingsSegmented
|
||||
options={discordCoverOptions}
|
||||
value={auth.discordCoverSource}
|
||||
onChange={auth.setDiscordCoverSource}
|
||||
/>
|
||||
</SettingsGroup>
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Източник на корица',
|
||||
discordCoverDesc: 'Откъде идва обложката на албума в профила ти в Discord.',
|
||||
discordCoverNone: 'Няма (само икона на приложението)',
|
||||
discordCoverServer: 'Сървър (чрез информация за албума)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Разширени настройки за Discord',
|
||||
discordTemplates: 'Персонализирани текстови шаблони',
|
||||
|
||||
@@ -287,7 +287,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Cover-Quelle',
|
||||
discordCoverDesc: 'Woher das Cover auf deinem Discord-Profil stammt.',
|
||||
discordCoverNone: 'Keine (nur App-Symbol)',
|
||||
discordCoverServer: 'Server (über Album-Info)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Erweiterte Discord-Optionen',
|
||||
discordTemplates: 'Benutzerdefinierte Textvorlagen',
|
||||
|
||||
@@ -290,7 +290,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Cover art source',
|
||||
discordCoverDesc: 'Where the album artwork on your Discord profile comes from.',
|
||||
discordCoverNone: 'None (app icon only)',
|
||||
discordCoverServer: 'Server (via album info)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Advanced Discord options',
|
||||
discordTemplates: 'Custom text templates',
|
||||
|
||||
@@ -286,7 +286,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Fuente de la carátula',
|
||||
discordCoverDesc: 'De dónde proviene la carátula mostrada en tu perfil de Discord.',
|
||||
discordCoverNone: 'Ninguna (solo icono de la app)',
|
||||
discordCoverServer: 'Servidor (vía info del álbum)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Opciones avanzadas de Discord',
|
||||
discordTemplates: 'Plantillas de texto personalizadas',
|
||||
|
||||
@@ -274,7 +274,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Source de la pochette',
|
||||
discordCoverDesc: 'D\'où provient la pochette affichée sur votre profil Discord.',
|
||||
discordCoverNone: 'Aucune (icône de l\'app uniquement)',
|
||||
discordCoverServer: 'Serveur (via infos album)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Options Discord avancées',
|
||||
discordTemplates: 'Modèles de texte personnalisés',
|
||||
|
||||
@@ -290,7 +290,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Borító forrása',
|
||||
discordCoverDesc: 'Honnan származik az albumborító a Discord-profilodon.',
|
||||
discordCoverNone: 'Nincs (csak alkalmazásikon)',
|
||||
discordCoverServer: 'Szerver (albuminfó alapján)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Speciális Discord-beállítások',
|
||||
discordTemplates: 'Egyéni szövegsablonok',
|
||||
|
||||
@@ -290,7 +290,6 @@ export const settings = {
|
||||
discordCoverTitle: 'カバーアートのソース',
|
||||
discordCoverDesc: 'Discord プロフィールに表示するアルバムアートの取得元です。',
|
||||
discordCoverNone: 'なし (アプリアイコンのみ)',
|
||||
discordCoverServer: 'サーバー (アルバム情報経由)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Discord 詳細オプション',
|
||||
discordTemplates: 'カスタムテキストテンプレート',
|
||||
|
||||
@@ -273,7 +273,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Omslagskilde',
|
||||
discordCoverDesc: 'Hvor albumomslaget på Discord-profilen din kommer fra.',
|
||||
discordCoverNone: 'Ingen (kun app-ikon)',
|
||||
discordCoverServer: 'Server (via albuminfo)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Avanserte Discord-alternativer',
|
||||
discordTemplates: 'Egendefinerte tekstmaler',
|
||||
|
||||
@@ -274,7 +274,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Bron albumhoes',
|
||||
discordCoverDesc: 'Waar de albumhoes op je Discord-profiel vandaan komt.',
|
||||
discordCoverNone: 'Geen (alleen app-icoon)',
|
||||
discordCoverServer: 'Server (via albuminfo)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Geavanceerde Discord-opties',
|
||||
discordTemplates: 'Aangepaste tekstsjablonen',
|
||||
|
||||
@@ -290,7 +290,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Źródło okładki',
|
||||
discordCoverDesc: 'Skąd pochodzi okładka albumu wyświetlana na Twoim profilu Discord.',
|
||||
discordCoverNone: 'Żadna (tylko ikona aplikacji)',
|
||||
discordCoverServer: 'Serwer (przez info albumu)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Zaawansowane ustawienia Discord',
|
||||
discordTemplates: 'Niestandardowe szablony tekstowe',
|
||||
|
||||
@@ -289,7 +289,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Sursa coperții',
|
||||
discordCoverDesc: 'De unde provine coperta afișată pe profilul tău Discord.',
|
||||
discordCoverNone: 'Niciuna (doar iconița aplicației)',
|
||||
discordCoverServer: 'Server (prin informații album)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Opțiuni avansate Discord',
|
||||
discordTemplates: 'Șablon text personalizat',
|
||||
|
||||
@@ -293,7 +293,6 @@ export const settings = {
|
||||
discordCoverTitle: 'Источник обложки',
|
||||
discordCoverDesc: 'Откуда берётся обложка, показываемая в вашем профиле Discord.',
|
||||
discordCoverNone: 'Нет (только иконка приложения)',
|
||||
discordCoverServer: 'Сервер (через информацию об альбоме)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Расширенные параметры Discord',
|
||||
discordTemplates: 'Пользовательские шаблоны текста',
|
||||
|
||||
@@ -273,7 +273,6 @@ export const settings = {
|
||||
discordCoverTitle: '封面来源',
|
||||
discordCoverDesc: '你的 Discord 资料中显示的专辑封面来源。',
|
||||
discordCoverNone: '无(仅显示应用图标)',
|
||||
discordCoverServer: '服务器(通过专辑信息)',
|
||||
discordCoverApple: 'Apple Music',
|
||||
discordOptions: 'Discord 高级选项',
|
||||
discordTemplates: '自定义文本模板',
|
||||
|
||||
@@ -212,8 +212,8 @@ describe('replay-gain related setters (write through to player store)', () => {
|
||||
});
|
||||
|
||||
describe('discord cover source setters', () => {
|
||||
it('setDiscordCoverSource accepts none / apple / server', () => {
|
||||
for (const src of ['none', 'apple', 'server'] as const) {
|
||||
it('setDiscordCoverSource accepts none / apple', () => {
|
||||
for (const src of ['none', 'apple'] as const) {
|
||||
useAuthStore.getState().setDiscordCoverSource(src);
|
||||
expect(useAuthStore.getState().discordCoverSource).toBe(src);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
clockFormat: 'auto',
|
||||
showOrbitTrigger: true,
|
||||
discordRichPresence: false,
|
||||
discordCoverSource: 'server',
|
||||
discordCoverSource: 'none',
|
||||
enableBandsintown: false,
|
||||
discordTemplateDetails: '{artist}',
|
||||
discordTemplateState: '{title}',
|
||||
|
||||
@@ -175,6 +175,14 @@ export function computeAuthStoreRehydration(state: AuthState): Partial<AuthState
|
||||
if (legacyAppleCovers === true && (!state.discordCoverSource || state.discordCoverSource === 'none')) {
|
||||
discordCoverSourceMigrated = { discordCoverSource: 'apple' };
|
||||
}
|
||||
// The 'server' cover source was removed: it built an authenticated Subsonic
|
||||
// getCoverArt URL (carrying u/t/s) and handed it to Discord, whose external
|
||||
// image proxy exposes the full URL to anyone viewing the presence. Migrate any
|
||||
// persisted 'server' → 'none' (no external request, no cover) — the least
|
||||
// surprising landing that doesn't silently start hitting a third party.
|
||||
if ((state as { discordCoverSource?: unknown }).discordCoverSource === 'server') {
|
||||
discordCoverSourceMigrated = { discordCoverSource: 'none' };
|
||||
}
|
||||
|
||||
// One-time: legacy unified `maxCacheMb` cap removed from Settings (offline + IDB covers).
|
||||
const maxCacheMbMigrationKey = 'psysonic-max-cache-mb-removed-v1';
|
||||
|
||||
@@ -88,7 +88,7 @@ export type LoggingMode = 'off' | 'normal' | 'debug';
|
||||
*/
|
||||
export type ClockFormat = 'auto' | '24h' | '12h';
|
||||
export type NormalizationEngine = 'off' | 'replaygain' | 'loudness';
|
||||
export type DiscordCoverSource = 'none' | 'apple' | 'server';
|
||||
export type DiscordCoverSource = 'none' | 'apple';
|
||||
/** Wayland + WebKit text/GPU profile (Settings → System, Linux only when available). */
|
||||
export type LinuxWaylandTextRenderProfile = 'balanced' | 'sharp' | 'gpu' | 'minimal';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user