From e21cffff0b9d8a13387da250fd12f410d70f3d43 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 2 Apr 2026 15:29:42 +0700 Subject: [PATCH] fix: validate ISRC in track metadata screen to prevent ID leakage Sanitize the isrc getter to only return valid ISRC codes (12-char format per ISO 3901). Invalid values such as Spotify/Deezer/Tidal IDs that may leak into the ISRC field are now silently discarded, preventing them from being displayed or embedded into file tags. --- lib/screens/track_metadata_screen.dart | 101 +++++++++++++++++-------- 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 3f6b9aaa..7e263403 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -493,9 +493,22 @@ class _TrackMetadataScreenState extends ConsumerState { (_isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate); - String? get isrc => - _editedMetadata?['isrc']?.toString() ?? - (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); + String? get isrc { + final raw = + _editedMetadata?['isrc']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); + if (raw == null || raw.trim().isEmpty) return null; + final upper = raw.trim().toUpperCase(); + // Only accept valid ISRC codes (CC-XXX-YY-NNNNN, 12 alphanumeric chars). + // Strip hyphens/spaces that some sources include. + final stripped = upper.replaceAll(RegExp(r'[-\s]'), ''); + if (_isrcValidationPattern.hasMatch(stripped)) return stripped; + return null; + } + + static final RegExp _isrcValidationPattern = RegExp( + r'^[A-Z]{2}[A-Z0-9]{3}\d{7}$', + ); String? get genre => _editedMetadata?['genre']?.toString() ?? (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre); @@ -582,6 +595,29 @@ class _TrackMetadataScreenState extends ConsumerState { return raw; } + String _serviceForTrackId(String value, {required String fallbackService}) { + final raw = value.trim(); + if (raw.isEmpty) return fallbackService; + final spotifyTrackIdPattern = RegExp(r'^[A-Za-z0-9]{22}$'); + + if (raw.startsWith('deezer:')) return 'deezer'; + if (raw.startsWith('tidal:')) return 'tidal'; + if (raw.startsWith('qobuz:')) return 'qobuz'; + if (raw.startsWith('spotify:')) return 'spotify'; + if (spotifyTrackIdPattern.hasMatch(raw)) return 'spotify'; + + final uri = Uri.tryParse(raw); + if (uri != null) { + final host = uri.host.toLowerCase(); + if (host.contains('spotify.com')) return 'spotify'; + if (host.contains('deezer.com')) return 'deezer'; + if (host.contains('tidal.com')) return 'tidal'; + if (host.contains('qobuz.com')) return 'qobuz'; + } + + return fallbackService; + } + String? get _displayAudioQuality { final fileName = _extractFileNameFromPathOrUri(cleanFilePath); final fileExt = fileName.contains('.') @@ -1092,16 +1128,18 @@ class _TrackMetadataScreenState extends ConsumerState { const SizedBox(height: 8), Builder( builder: (context) { - final isDeezer = _spotifyId!.contains('deezer'); - final svc = _service.toLowerCase(); + final openService = _serviceForTrackId( + _spotifyId!, + fallbackService: _service.toLowerCase(), + ); String buttonLabel; - if (isDeezer) { + if (openService == 'deezer') { buttonLabel = context.l10n.trackOpenInDeezer; - } else if (svc == 'amazon') { + } else if (openService == 'amazon') { buttonLabel = 'Open in Amazon Music'; - } else if (svc == 'tidal') { + } else if (openService == 'tidal') { buttonLabel = 'Open in Tidal'; - } else if (svc == 'qobuz') { + } else if (openService == 'qobuz') { buttonLabel = 'Open in Qobuz'; } else { buttonLabel = context.l10n.trackOpenInSpotify; @@ -1132,28 +1170,29 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openServiceUrl(BuildContext context) async { if (_spotifyId == null) return; - final isDeezer = - _service.toLowerCase() == 'deezer' || _spotifyId!.startsWith('deezer:'); + final openService = _serviceForTrackId( + _spotifyId!, + fallbackService: _service.toLowerCase(), + ); final rawId = _displayServiceTrackId(_spotifyId!); - final svc = _service.toLowerCase(); String webUrl; Uri? appUri; String serviceName; - if (isDeezer) { + if (openService == 'deezer') { webUrl = 'https://www.deezer.com/track/$rawId'; appUri = Uri.parse('deezer://www.deezer.com/track/$rawId'); serviceName = 'Deezer'; - } else if (svc == 'amazon') { + } else if (openService == 'amazon') { webUrl = 'https://music.amazon.com/search/$rawId'; appUri = Uri.parse('amznm://search/$rawId'); serviceName = 'Amazon Music'; - } else if (svc == 'tidal') { + } else if (openService == 'tidal') { webUrl = 'https://listen.tidal.com/track/$rawId'; appUri = Uri.parse('tidal://track/$rawId'); serviceName = 'Tidal'; - } else if (svc == 'qobuz') { + } else if (openService == 'qobuz') { webUrl = 'https://play.qobuz.com/track/$rawId'; appUri = Uri.parse('qobuz://track/$rawId'); serviceName = 'Qobuz'; @@ -1223,23 +1262,23 @@ class _TrackMetadataScreenState extends ConsumerState { ]; if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) { - final isDeezer = - _service.toLowerCase() == 'deezer' || _spotifyId!.startsWith('deezer:'); + final idService = _serviceForTrackId( + _spotifyId!, + fallbackService: _service.toLowerCase(), + ); final cleanId = _displayServiceTrackId(_spotifyId!); String idLabel; - if (isDeezer) { - idLabel = 'Deezer ID'; - } else { - switch (_service.toLowerCase()) { - case 'amazon': - idLabel = 'Amazon ASIN'; - case 'tidal': - idLabel = 'Tidal ID'; - case 'qobuz': - idLabel = 'Qobuz ID'; - default: - idLabel = 'Spotify ID'; - } + switch (idService) { + case 'deezer': + idLabel = 'Deezer ID'; + case 'amazon': + idLabel = 'Amazon ASIN'; + case 'tidal': + idLabel = 'Tidal ID'; + case 'qobuz': + idLabel = 'Qobuz ID'; + default: + idLabel = 'Spotify ID'; } items.add(_MetadataItem(idLabel, cleanId)); }