diff --git a/app/lib/admin/actions/actions.dart b/app/lib/admin/actions/actions.dart index 58653c560f..fab40bcdbb 100644 --- a/app/lib/admin/actions/actions.dart +++ b/app/lib/admin/actions/actions.dart @@ -19,12 +19,14 @@ import 'moderation_case_list.dart'; import 'moderation_case_resolve.dart'; import 'moderation_case_update.dart'; import 'moderation_transparency_metrics.dart'; +import 'package_delete.dart'; import 'package_discontinue.dart'; import 'package_info.dart'; import 'package_latest_update.dart'; import 'package_reservation_create.dart'; import 'package_reservation_delete.dart'; import 'package_reservation_list.dart'; +import 'package_version_delete.dart'; import 'package_version_info.dart'; import 'package_version_retraction.dart'; import 'publisher_create.dart'; @@ -105,12 +107,14 @@ final class AdminAction { moderationCaseResolve, moderationCaseUpdate, moderationTransparencyMetrics, + packageDelete, packageDiscontinue, packageInfo, packageLatestUpdate, packageReservationCreate, packageReservationDelete, packageReservationList, + packageVersionDelete, packageVersionInfo, packageVersionRetraction, publisherCreate, diff --git a/app/lib/admin/actions/package_delete.dart b/app/lib/admin/actions/package_delete.dart new file mode 100644 index 0000000000..34c7e406bf --- /dev/null +++ b/app/lib/admin/actions/package_delete.dart @@ -0,0 +1,57 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../../account/backend.dart'; +import '../../shared/configuration.dart'; +import '../backend.dart'; +import 'actions.dart'; + +final packageDelete = AdminAction( + name: 'package-delete', + options: { + 'package': 'name of package to delete', + }, + summary: 'Deletes package .', + description: ''' +Deletes package . + +Deletes all associated resources: + +* PackageVersions +* Likes +* AuditLogRecords +* PackageVersionAsset +* replacedBy references +* archives (might be retrievable from backup) + +The package will be "tombstoned" and no package with the same name can be +published later. +''', + invoke: (args) async { + final packageName = args['package']; + if (packageName == null) { + throw InvalidInputException('Missing `package` argument'); + } + + await requireAuthenticatedAdmin(AdminPermission.removePackage); + final result = await adminBackend.removePackage(packageName); + + return { + 'message': ''' +Package and all associated resources deleted. + +A tombstone has been created + +'NOTICE: Redis caches referencing the package will expire given time.' +''', + 'package': packageName, + 'deletedPackages': result.deletedPackages, + 'deletedPackageVersions': result.deletedPackageVersions, + 'deletedPackageVersionInfos': result.deletedPackageVersionInfos, + 'deletedPackageVersionAssets': result.deletedPackageVersionAssets, + 'deletedLikes': result.deletedLikes, + 'deletedAuditLogs': result.deletedAuditLogs, + 'replacedByFixes': result.replacedByFixes, + }; + }); diff --git a/app/lib/admin/actions/package_version_delete.dart b/app/lib/admin/actions/package_version_delete.dart new file mode 100644 index 0000000000..1eb459239f --- /dev/null +++ b/app/lib/admin/actions/package_version_delete.dart @@ -0,0 +1,50 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../../account/backend.dart'; +import '../../shared/configuration.dart'; +import '../backend.dart'; +import 'actions.dart'; + +final packageVersionDelete = AdminAction( + name: 'package-version-delete', + options: { + 'package': 'name of package to delete', + 'version': 'version of package', + }, + summary: 'Deletes package version .', + description: ''' +Deletes package version . + +Deletes all associated resources: + +* PackageVersions +* PackageVersionAsset +* archives (might be retrievable from backup) + +The package version will be "tombstoned" and same version cannot be published +later. +''', + invoke: (args) async { + await requireAuthenticatedAdmin(AdminPermission.removePackage); + final packageName = args['package']; + if (packageName == null) { + throw InvalidInputException('Missing `package` argument'); + } + final version = args['version']; + if (version == null) { + throw InvalidInputException('Missing `version` argument'); + } + final result = + await adminBackend.removePackageVersion(packageName, version); + + return { + 'message': 'Package version and all associated resources deleted.', + 'package': packageName, + 'version': version, + 'deletedPackageVersions': result.deletedPackageVersions, + 'deletedPackageVersionInfos': result.deletedPackageVersionInfos, + 'deletedPackageVersionAssets': result.deletedPackageVersionAssets, + }; + }); diff --git a/app/lib/admin/backend.dart b/app/lib/admin/backend.dart index 5e129578af..9c65fb042c 100644 --- a/app/lib/admin/backend.dart +++ b/app/lib/admin/backend.dart @@ -294,24 +294,16 @@ class AdminBackend { /// Creates a [ModeratedPackage] instance (if not already present) in /// Datastore representing the removed package. No new package with the same /// name can be published. - /// - /// Verifies the current authenticated user for admin permissions. - Future removePackage(String packageName) async { - final caller = - await requireAuthenticatedAdmin(AdminPermission.removePackage); - _logger.info('${caller.displayId}) initiated the delete ' - 'of package $packageName'); - await _removePackage(packageName); - } - - /// Removes the package from the Datastore and updates other related - /// entities. It is safe to call [removePackage] on an already removed - /// package, as the call is idempotent. - /// - /// Creates a [ModeratedPackage] instance (if not already present) in - /// Datastore representing the removed package. No new package with the same - /// name can be published. - Future _removePackage( + Future< + ({ + int deletedPackages, + int deletedPackageVersions, + int deletedPackageVersionInfos, + int deletedPackageVersionAssets, + int deletedLikes, + int deletedAuditLogs, + int replacedByFixes, + })> removePackage( String packageName, { DateTime? moderated, }) async { @@ -336,6 +328,7 @@ class AdminBackend { _logger.info('Removing package from Package.replacedBy...'); final replacedByQuery = _db.query() ..filter('replacedBy =', packageName); + var replacedByFixes = 0; await for (final pkg in replacedByQuery.run()) { await withRetryTransaction(_db, (tx) async { final p = await tx.lookupOrNull(pkg.key); @@ -346,26 +339,28 @@ class AdminBackend { p.replacedBy = null; tx.insert(p); } + replacedByFixes++; }); } _logger.info('Removing package from PackageVersionInfo ...'); - await _db.deleteWithQuery( + final deletedPackageVersionInfos = await _db.deleteWithQuery( _db.query()..filter('package =', packageName)); _logger.info('Removing package from PackageVersionAsset ...'); - await _db.deleteWithQuery( + final deletedPackageVersionAssets = await _db.deleteWithQuery( _db.query()..filter('package =', packageName)); _logger.info('Removing package from Like ...'); - await _db.deleteWithQuery( + final deletedLikes = await _db.deleteWithQuery( _db.query()..filter('packageName =', packageName)); _logger.info('Removing package from AuditLogRecord...'); - await _db.deleteWithQuery( + final deletedAuditLogRecords = await _db.deleteWithQuery( _db.query()..filter('packages =', packageName)); _logger.info('Removing Package from Datastore...'); + var deletedPackages = 0; await withRetryTransaction(_db, (tx) async { final package = await tx.lookupOrNull(packageKey); if (package == null) { @@ -376,7 +371,7 @@ class AdminBackend { return; } tx.delete(packageKey); - + deletedPackages = 1; final moderatedPkgKey = _db.emptyKey.append(ModeratedPackage, id: packageName); final moderatedPkg = @@ -409,8 +404,15 @@ class AdminBackend { .deleteWithQuery(_db.query(ancestorKey: packageKey)); _logger.info('Package "$packageName" got successfully removed.'); - _logger.info( - 'NOTICE: Redis caches referencing the package will expire given time.'); + return ( + deletedPackages: deletedPackages, + deletedPackageVersions: versions.length, + deletedPackageVersionInfos: deletedPackageVersionInfos.deleted, + deletedPackageVersionAssets: deletedPackageVersionAssets.deleted, + deletedLikes: deletedLikes.deleted, + deletedAuditLogs: deletedAuditLogRecords.deleted, + replacedByFixes: replacedByFixes + ); } /// Updates the options (e.g. retraction) of the specific package version and @@ -454,13 +456,13 @@ class AdminBackend { /// Removes the specific package version from the Datastore and updates other /// related entities. It is safe to call [removePackageVersion] on an already /// removed version, as the call is idempotent. - Future removePackageVersion(String packageName, String version) async { - final caller = - await requireAuthenticatedAdmin(AdminPermission.removePackage); - - _logger.info('${caller.displayId}) initiated the delete ' - 'of package $packageName $version'); - + Future< + ({ + int deletedPackageVersions, + int deletedPackageVersionInfos, + int deletedPackageVersionAssets, + })> removePackageVersion(String packageName, String version) async { + var deletedPackageVersions = 0; final currentDartSdk = await getCachedDartSdkVersion( lastKnownStable: toolStableDartSdkVersion); final currentFlutterSdk = await getCachedFlutterSdkVersion( @@ -469,8 +471,7 @@ class AdminBackend { final packageKey = _db.emptyKey.append(Package, id: packageName); final package = await tx.lookupOrNull(packageKey); if (package == null) { - throw Exception( - 'Package "$packageName" does not exists. Use full package removal without the version qualifier.'); + throw Exception('Package "$packageName" does not exists.'); } final versionsQuery = tx.query(packageKey); @@ -479,8 +480,9 @@ class AdminBackend { if (versionNames.contains(version)) { tx.delete(packageKey.append(PackageVersion, id: version)); package.updated = clock.now().toUtc(); + deletedPackageVersions = 1; } else { - print('Package $packageName does not have a version $version.'); + _logger.info('Package $packageName does not have a version $version.'); } if (versionNames.length == 1 && versionNames.single == version) { @@ -501,15 +503,15 @@ class AdminBackend { tx.insert(package); }); - print('Removing GCS objects ...'); + _logger.info('Removing GCS objects ...'); await packageBackend.removePackageTarball(packageName, version); - await _db.deleteWithQuery( + final deletedPackageVersionInfos = await _db.deleteWithQuery( _db.query()..filter('package =', packageName), where: (PackageVersionInfo info) => info.version == version, ); - await _db.deleteWithQuery( + final deletedPackageVersionAssets = await _db.deleteWithQuery( _db.query()..filter('package =', packageName), where: (PackageVersionAsset asset) => asset.version == version, ); @@ -518,6 +520,11 @@ class AdminBackend { await purgeScorecardData(packageName, version, isLatest: true); // trigger (eventual) re-analysis await taskBackend.trackPackage(packageName); + return ( + deletedPackageVersions: deletedPackageVersions, + deletedPackageVersionInfos: deletedPackageVersionInfos.deleted, + deletedPackageVersionAssets: deletedPackageVersionAssets.deleted, + ); } /// Handles `GET '/api/admin/packages//assigned-tags'`. @@ -793,7 +800,7 @@ class AdminBackend { } _logger.info('Deleting moderated package: ${package.name}'); - await _removePackage( + await removePackage( package.name!, moderated: package.moderatedAt, ); diff --git a/app/lib/frontend/handlers/pubapi.client.dart b/app/lib/frontend/handlers/pubapi.client.dart index 85e4251a9e..d0906302c7 100644 --- a/app/lib/frontend/handlers/pubapi.client.dart +++ b/app/lib/frontend/handlers/pubapi.client.dart @@ -525,23 +525,6 @@ class PubApiClient { ); } - Future> adminRemovePackage(String package) async { - return await _client.requestBytes( - verb: 'delete', - path: '/api/admin/packages/$package', - ); - } - - Future> adminRemovePackageVersion( - String package, - String version, - ) async { - return await _client.requestBytes( - verb: 'delete', - path: '/api/admin/packages/$package/versions/$version', - ); - } - Future<_i3.VersionOptions> adminUpdateVersionOptions( String package, String version, diff --git a/app/lib/frontend/handlers/pubapi.dart b/app/lib/frontend/handlers/pubapi.dart index d218501a98..fe8a855e73 100644 --- a/app/lib/frontend/handlers/pubapi.dart +++ b/app/lib/frontend/handlers/pubapi.dart @@ -524,19 +524,6 @@ class PubApi { return jsonResponse({'status': 'OK'}); } - @EndPoint.delete('/api/admin/packages/') - Future adminRemovePackage(Request request, String package) async { - await adminBackend.removePackage(package); - return jsonResponse({'status': 'OK'}); - } - - @EndPoint.delete('/api/admin/packages//versions/') - Future adminRemovePackageVersion( - Request request, String package, String version) async { - await adminBackend.removePackageVersion(package, version); - return jsonResponse({'status': 'OK'}); - } - @EndPoint.put('/api/admin/packages//versions//options') Future adminUpdateVersionOptions(Request request, String package, String version, VersionOptions options) async { diff --git a/app/lib/frontend/handlers/pubapi.g.dart b/app/lib/frontend/handlers/pubapi.g.dart index c2be773cdb..ea639aebe1 100644 --- a/app/lib/frontend/handlers/pubapi.g.dart +++ b/app/lib/frontend/handlers/pubapi.g.dart @@ -1161,48 +1161,6 @@ Router _$PubApiRouter(PubApi service) { } }, ); - router.add( - 'DELETE', - r'/api/admin/packages/', - ( - Request request, - String package, - ) async { - try { - final _$result = await service.adminRemovePackage( - request, - package, - ); - return _$result; - } on ApiResponseException catch (e) { - return e.asApiResponse(); - } catch (e, st) { - return $utilities.unhandledError(e, st); - } - }, - ); - router.add( - 'DELETE', - r'/api/admin/packages//versions/', - ( - Request request, - String package, - String version, - ) async { - try { - final _$result = await service.adminRemovePackageVersion( - request, - package, - version, - ); - return _$result; - } on ApiResponseException catch (e) { - return e.asApiResponse(); - } catch (e, st) { - return $utilities.unhandledError(e, st); - } - }, - ); router.add( 'PUT', r'/api/admin/packages//versions//options', diff --git a/app/test/admin/api_test.dart b/app/test/admin/api_test.dart index 457f035641..3372d0dae9 100644 --- a/app/test/admin/api_test.dart +++ b/app/test/admin/api_test.dart @@ -157,7 +157,11 @@ void main() { group('Delete package', () { setupTestsWithAdminTokenIssues( - (client) => client.adminRemovePackage('oxygen')); + (client) => client.adminInvokeAction( + 'package-delete', + AdminInvokeActionArguments(arguments: {'package': 'oxygen'}), + ), + ); testWithProfile('OK', fn: () async { final client = createPubApiClient(authToken: siteAdminToken); @@ -192,9 +196,23 @@ void main() { expect(moderatedPkg, isNull); final timeBeforeRemoval = clock.now().toUtc(); - final rs = await client.adminRemovePackage('oxygen'); + final rs = await client.adminInvokeAction( + 'package-delete', + AdminInvokeActionArguments(arguments: {'package': 'oxygen'}), + ); - expect(utf8.decode(rs), '{"status":"OK"}'); + expect(rs.output, { + 'message': + contains('Package and all associated resources deleted.\n'), + 'package': 'oxygen', + 'deletedPackages': 1, + 'deletedPackageVersions': 3, + 'deletedPackageVersionInfos': 3, + 'deletedPackageVersionAssets': 15, + 'deletedLikes': 1, + 'deletedAuditLogs': 4, + 'replacedByFixes': 0 + }); final pkgAfterRemoval = await dbService.lookupOrNull(pkgKey); expect(pkgAfterRemoval, isNull); @@ -217,8 +235,10 @@ void main() { }); group('Delete package version', () { - setupTestsWithAdminTokenIssues( - (client) => client.adminRemovePackageVersion('oxygen', '1.2.0')); + setupTestsWithAdminTokenIssues((client) => client.adminInvokeAction( + 'package-version-delete', + AdminInvokeActionArguments( + arguments: {'package': 'oxygen', 'version': '1.2.0'}))); testWithProfile('OK', processJobsWithFakeRunners: true, fn: () async { final client = createPubApiClient(authToken: siteAdminToken); @@ -260,10 +280,19 @@ void main() { expect(moderatedPkg, isNull); final timeBeforeRemoval = clock.now().toUtc(); - final rs = - await client.adminRemovePackageVersion('oxygen', removeVersion); - - expect(utf8.decode(rs), '{"status":"OK"}'); + final rs = await client.adminInvokeAction( + 'package-version-delete', + AdminInvokeActionArguments( + arguments: {'package': 'oxygen', 'version': removeVersion})); + + expect(rs.output, { + 'message': 'Package version and all associated resources deleted.', + 'package': 'oxygen', + 'version': '1.2.0', + 'deletedPackageVersions': 1, + 'deletedPackageVersionInfos': 1, + 'deletedPackageVersionAssets': 5 + }); final pkgAfter1stRemoval = await dbService.lookupOrNull(pkgKey); @@ -291,9 +320,18 @@ void main() { expect(moderatedPkg, isNull); // calling remove second time must not affect updated or version count - final rs2 = - await client.adminRemovePackageVersion('oxygen', removeVersion); - expect(utf8.decode(rs2), '{"status":"OK"}'); + final rs2 = await client.adminInvokeAction( + 'package-version-delete', + AdminInvokeActionArguments( + arguments: {'package': 'oxygen', 'version': removeVersion})); + expect(rs2.output, { + 'message': 'Package version and all associated resources deleted.', + 'package': 'oxygen', + 'version': '1.2.0', + 'deletedPackageVersions': 0, + 'deletedPackageVersionInfos': 0, + 'deletedPackageVersionAssets': 0, + }); final pkgAfter2ndRemoval = await dbService.lookupOrNull(pkgKey); expect(pkgAfter2ndRemoval, isNotNull); diff --git a/pkg/_pub_shared/lib/src/pubapi.client.dart b/pkg/_pub_shared/lib/src/pubapi.client.dart index 85e4251a9e..d0906302c7 100644 --- a/pkg/_pub_shared/lib/src/pubapi.client.dart +++ b/pkg/_pub_shared/lib/src/pubapi.client.dart @@ -525,23 +525,6 @@ class PubApiClient { ); } - Future> adminRemovePackage(String package) async { - return await _client.requestBytes( - verb: 'delete', - path: '/api/admin/packages/$package', - ); - } - - Future> adminRemovePackageVersion( - String package, - String version, - ) async { - return await _client.requestBytes( - verb: 'delete', - path: '/api/admin/packages/$package/versions/$version', - ); - } - Future<_i3.VersionOptions> adminUpdateVersionOptions( String package, String version,