import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/metadata/errors/exceptions.dart'; import 'package:spotube/services/metadata/metadata.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:uuid/uuid.dart'; import 'package:archive/archive.dart'; import 'package:pub_semver/pub_semver.dart'; final allowedDomainsRegex = RegExp( r"^(https?:\/\/)?(www\.)?(github\.com|codeberg\.org)\/.+", ); class MetadataPluginState { final List plugins; final int defaultPlugin; const MetadataPluginState({ this.plugins = const [], this.defaultPlugin = -1, }); PluginConfiguration? get defaultPluginConfig { if (defaultPlugin < 0 || defaultPlugin >= plugins.length) { return null; } return plugins[defaultPlugin]; } factory MetadataPluginState.fromJson(Map json) { return MetadataPluginState( plugins: (json["plugins"] as List) .map((e) => PluginConfiguration.fromJson(e)) .toList(), defaultPlugin: json["default_plugin"] ?? -1, ); } Map toJson() { return { "plugins": plugins.map((e) => e.toJson()).toList(), "default_plugin": defaultPlugin, }; } MetadataPluginState copyWith({ List? plugins, int? defaultPlugin, }) { return MetadataPluginState( plugins: plugins ?? this.plugins, defaultPlugin: defaultPlugin ?? this.defaultPlugin, ); } } class MetadataPluginNotifier extends AsyncNotifier { AppDatabase get database => ref.read(databaseProvider); @override build() async { final database = ref.watch(databaseProvider); final subscription = database.metadataPluginsTable.select().watch().listen( (event) async { state = AsyncValue.data(await toStatePlugins(event)); }, ); ref.onDispose(() { subscription.cancel(); }); final plugins = await database.metadataPluginsTable.select().get(); return await toStatePlugins(plugins); } Future toStatePlugins( List plugins) async { int defaultPlugin = -1; final pluginConfigs = plugins.mapIndexed( (index, plugin) { if (plugin.selected) { defaultPlugin = index; } return PluginConfiguration( type: PluginType.metadata, name: plugin.name, author: plugin.author, description: plugin.description, version: plugin.version, entryPoint: plugin.entryPoint, pluginApiVersion: plugin.pluginApiVersion, repository: plugin.repository, apis: plugin.apis .map( (e) => PluginApis.values.firstWhereOrNull( (api) => api.name == e, ), ) .nonNulls .toList(), abilities: plugin.abilities .map( (e) => PluginAbilities.values.firstWhereOrNull( (ability) => ability.name == e, ), ) .nonNulls .toList(), ); }, ).toList(); return MetadataPluginState( plugins: pluginConfigs, defaultPlugin: defaultPlugin, ); } Uri _getGithubReleasesUrl(String repoUrl) { final parsedUri = Uri.parse(repoUrl); final uri = parsedUri.replace( host: "api.github.com", pathSegments: [ "repos", ...parsedUri.pathSegments, "releases", ], queryParameters: { "per_page": "1", "page": "1", }, ); return uri; } Uri _getCodebergeReleasesUrl(String repoUrl) { final parsedUri = Uri.parse(repoUrl); final uri = parsedUri.replace( pathSegments: [ "api", "v1", "repos", ...parsedUri.pathSegments, "releases", ], queryParameters: { "limit": "1", "page": "1", }, ); return uri; } Future _getPluginDownloadUrl(Uri uri) async { AppLogger.log.i("Getting plugin download URL from: $uri"); final res = await globalDio.getUri( uri, options: Options(responseType: ResponseType.json), ); if (res.statusCode != 200) { throw MetadataPluginException.failedToGetRelease(); } final releases = res.data as List; if (releases.isEmpty) { throw MetadataPluginException.noReleasesFound(); } final latestRelease = releases.first; final downloadUrl = (latestRelease["assets"] as List).firstWhere( (asset) => (asset["name"] as String).endsWith(".smplug"), )["browser_download_url"]; if (downloadUrl == null) { throw MetadataPluginException.assetUrlNotFound(); } return downloadUrl; } /// Root directory where all metadata plugins are stored. Future _getPluginRootDir() async => Directory( join( (await getApplicationCacheDirectory()).path, "metadata-plugins", ), ); /// Directory where the plugin will be extracted. /// This is a unique directory for each plugin version. /// It is used to avoid conflicts when multiple versions of the same plugin are installed Future _getPluginExtractionDir(PluginConfiguration plugin) async { final pluginDir = await _getPluginRootDir(); final pluginExtractionDirPath = join( pluginDir.path, "${ServiceUtils.sanitizeFilename(plugin.name)}-${plugin.version}", ); return Directory(pluginExtractionDirPath); } Future extractPluginArchive(List bytes) async { final archive = ZipDecoder().decodeBytes(bytes); final pluginJson = archive .firstWhereOrNull((file) => file.isFile && file.name == "plugin.json"); if (pluginJson == null) { throw MetadataPluginException.pluginConfigJsonNotFound(); } final pluginConfig = PluginConfiguration.fromJson( jsonDecode( utf8.decode(pluginJson.content as List), ) as Map, ); final pluginDir = await _getPluginRootDir(); await pluginDir.create(recursive: true); final pluginExtractionDir = await _getPluginExtractionDir(pluginConfig); for (final file in archive) { if (file.isFile) { final filename = file.name; final data = file.content as List; final extractedFile = File(join( pluginExtractionDir.path, filename, )); await extractedFile.create(recursive: true); await extractedFile.writeAsBytes(data); } } return pluginConfig; } /// Downloads, extracts & caches the plugin from the given URL and returns the plugin config. /// If only a text/html URL is provided, it will try to get the latest release from /// the URL for supported websites (github.com, codeberg.org). Future downloadAndCachePlugin(String url) async { final res = await globalDio.head(url); final isSupportedWebsite = (res.headers["Content-Type"]?.first)?.startsWith("text/html") == true && allowedDomainsRegex.hasMatch(url); String pluginDownloadUrl = url; if (isSupportedWebsite) { if (url.contains("github.com")) { final uri = _getGithubReleasesUrl(url); pluginDownloadUrl = await _getPluginDownloadUrl(uri); } else if (url.contains("codeberg.org")) { final uri = _getCodebergeReleasesUrl(url); pluginDownloadUrl = await _getPluginDownloadUrl(uri); } else { throw MetadataPluginException.unsupportedPluginDownloadWebsite(); } } // Now let's download, extract and cache the plugin final pluginDir = await _getPluginRootDir(); await pluginDir.create(recursive: true); final tempPluginName = "${const Uuid().v4()}.smplug"; final pluginFile = File(join(pluginDir.path, tempPluginName)); final pluginRes = await globalDio.download( pluginDownloadUrl, pluginFile.path, options: Options( responseType: ResponseType.bytes, followRedirects: true, receiveTimeout: const Duration(seconds: 30), ), ); if ((pluginRes.statusCode ?? 500) > 299) { throw MetadataPluginException.pluginDownloadFailed(); } return await extractPluginArchive(await pluginFile.readAsBytes()); } bool validatePluginApiCompatibility(PluginConfiguration plugin) { final configPluginApiVersion = Version.parse(plugin.pluginApiVersion); final appPluginApiVersion = MetadataPlugin.pluginApiVersion; // Plugin API's major version must match the app's major version if (configPluginApiVersion.major != appPluginApiVersion.major) { return false; } return configPluginApiVersion >= appPluginApiVersion; } void _assertPluginApiCompatibility(PluginConfiguration plugin) { if (!validatePluginApiCompatibility(plugin)) { throw MetadataPluginException.pluginApiVersionMismatch(); } } Future addPlugin(PluginConfiguration plugin) async { _assertPluginApiCompatibility(plugin); final pluginRes = await (database.metadataPluginsTable.select() ..where( (tbl) => tbl.name.equals(plugin.name), ) ..limit(1)) .get(); if (pluginRes.isNotEmpty) { throw MetadataPluginException.duplicatePlugin(); } await database.metadataPluginsTable.insertOne( MetadataPluginsTableCompanion.insert( name: plugin.name, author: plugin.author, description: plugin.description, version: plugin.version, entryPoint: plugin.entryPoint, apis: plugin.apis.map((e) => e.name).toList(), abilities: plugin.abilities.map((e) => e.name).toList(), pluginApiVersion: plugin.pluginApiVersion, repository: Value(plugin.repository), ), ); } Future removePlugin(PluginConfiguration plugin) async { final pluginExtractionDir = await _getPluginExtractionDir(plugin); if (pluginExtractionDir.existsSync()) { await pluginExtractionDir.delete(recursive: true); } await database.metadataPluginsTable .deleteWhere((tbl) => tbl.name.equals(plugin.name)); } Future updatePlugin( PluginConfiguration plugin, PluginUpdateAvailable update, ) async { final isDefault = plugin == state.valueOrNull?.defaultPluginConfig; final pluginUpdatedConfig = await downloadAndCachePlugin(update.downloadUrl); if (pluginUpdatedConfig.name != plugin.name) { throw MetadataPluginException.invalidPluginConfiguration(); } _assertPluginApiCompatibility(pluginUpdatedConfig); await removePlugin(plugin); await addPlugin(pluginUpdatedConfig); if (isDefault) { await setDefaultPlugin(pluginUpdatedConfig); } } Future setDefaultPlugin(PluginConfiguration plugin) async { await database.metadataPluginsTable .update() .write(const MetadataPluginsTableCompanion(selected: Value(false))); await (database.metadataPluginsTable.update() ..where((tbl) => tbl.name.equals(plugin.name))) .write( const MetadataPluginsTableCompanion(selected: Value(true)), ); } Future getPluginByteCode(PluginConfiguration plugin) async { final pluginExtractionDirPath = await _getPluginExtractionDir(plugin); final libraryFile = File(join(pluginExtractionDirPath.path, "plugin.out")); if (!libraryFile.existsSync()) { throw MetadataPluginException.pluginByteCodeFileNotFound(); } return await libraryFile.readAsBytes(); } Future getLogoPath(PluginConfiguration plugin) async { final pluginExtractionDirPath = await _getPluginExtractionDir(plugin); final logoFile = File(join(pluginExtractionDirPath.path, "logo.png")); if (!logoFile.existsSync()) { return null; } return logoFile; } } final metadataPluginsProvider = AsyncNotifierProvider( MetadataPluginNotifier.new, ); final metadataPluginProvider = FutureProvider( (ref) async { final defaultPlugin = await ref.watch( metadataPluginsProvider.selectAsync((data) => data.defaultPluginConfig), ); if (defaultPlugin == null) { return null; } final pluginsNotifier = ref.read(metadataPluginsProvider.notifier); final pluginByteCode = await pluginsNotifier.getPluginByteCode(defaultPlugin); return await MetadataPlugin.create(defaultPlugin, pluginByteCode); }, );