Skip to content

Commit 1e3830b

Browse files
authored
Remove moderated package and version assets after 3 years. (#8055)
1 parent f24755c commit 1e3830b

File tree

4 files changed

+266
-26
lines changed

4 files changed

+266
-26
lines changed

app/lib/admin/backend.dart

Lines changed: 161 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import 'package:clock/clock.dart';
1212
import 'package:collection/collection.dart';
1313
import 'package:convert/convert.dart';
1414
import 'package:gcloud/service_scope.dart' as ss;
15+
import 'package:gcloud/storage.dart';
1516
import 'package:logging/logging.dart';
17+
import 'package:meta/meta.dart';
1618
import 'package:pool/pool.dart';
1719

1820
import '../account/backend.dart';
@@ -22,14 +24,19 @@ import '../account/models.dart';
2224
import '../admin/models.dart';
2325
import '../audit/models.dart';
2426
import '../package/backend.dart'
25-
show checkPackageVersionParams, packageBackend, purgePackageCache;
27+
show
28+
checkPackageVersionParams,
29+
packageBackend,
30+
purgePackageCache,
31+
tarballObjectName;
2632
import '../package/models.dart';
2733
import '../publisher/models.dart';
2834
import '../scorecard/backend.dart';
2935
import '../shared/configuration.dart';
3036
import '../shared/datastore.dart';
3137
import '../shared/email.dart';
3238
import '../shared/exceptions.dart';
39+
import '../shared/storage.dart';
3340
import '../shared/versions.dart';
3441
import '../task/backend.dart';
3542
import 'actions/actions.dart' show AdminAction;
@@ -297,13 +304,27 @@ class AdminBackend {
297304
/// Creates a [ModeratedPackage] instance (if not already present) in
298305
/// Datastore representing the removed package. No new package with the same
299306
/// name can be published.
307+
///
308+
/// Verifies the current authenticated user for admin permissions.
300309
Future<void> removePackage(String packageName) async {
301310
final caller =
302311
await requireAuthenticatedAdmin(AdminPermission.removePackage);
303-
304312
_logger.info('${caller.displayId}) initiated the delete '
305313
'of package $packageName');
314+
await _removePackage(packageName);
315+
}
306316

317+
/// Removes the package from the Datastore and updates other related
318+
/// entities. It is safe to call [removePackage] on an already removed
319+
/// package, as the call is idempotent.
320+
///
321+
/// Creates a [ModeratedPackage] instance (if not already present) in
322+
/// Datastore representing the removed package. No new package with the same
323+
/// name can be published.
324+
Future<void> _removePackage(
325+
String packageName, {
326+
DateTime? moderated,
327+
}) async {
307328
final packageKey = _db.emptyKey.append(Package, id: packageName);
308329
final versions = (await _db
309330
.query<PackageVersion>(ancestorKey: packageKey)
@@ -312,6 +333,49 @@ class AdminBackend {
312333
.toList())
313334
.toSet();
314335

336+
final pool = Pool(10);
337+
final futures = <Future>[];
338+
for (final v in versions) {
339+
// Deleting public and canonical archives, 404 errors are ignored.
340+
futures.add(pool.withResource(
341+
() => packageBackend.removePackageTarball(packageName, v)));
342+
}
343+
await Future.wait(futures);
344+
await pool.close();
345+
346+
_logger.info('Removing package from Package.replacedBy...');
347+
final replacedByQuery = _db.query<Package>()
348+
..filter('replacedBy =', packageName);
349+
await for (final pkg in replacedByQuery.run()) {
350+
await withRetryTransaction(_db, (tx) async {
351+
final p = await tx.lookupOrNull<Package>(pkg.key);
352+
if (p == null) {
353+
return;
354+
}
355+
if (p.replacedBy == packageName) {
356+
p.replacedBy = null;
357+
tx.insert(p);
358+
}
359+
});
360+
}
361+
362+
_logger.info('Removing package from PackageVersionInfo ...');
363+
await _db.deleteWithQuery(
364+
_db.query<PackageVersionInfo>()..filter('package =', packageName));
365+
366+
_logger.info('Removing package from PackageVersionAsset ...');
367+
await _db.deleteWithQuery(
368+
_db.query<PackageVersionAsset>()..filter('package =', packageName));
369+
370+
_logger.info('Removing package from Like ...');
371+
await _db.deleteWithQuery(
372+
_db.query<Like>()..filter('packageName =', packageName));
373+
374+
_logger.info('Removing package from AuditLogRecord...');
375+
await _db.deleteWithQuery(
376+
_db.query<AuditLogRecord>()..filter('packages =', packageName));
377+
378+
_logger.info('Removing Package from Datastore...');
315379
await withRetryTransaction(_db, (tx) async {
316380
final package = await tx.lookupOrNull<Package>(packageKey);
317381
if (package == null) {
@@ -335,11 +399,13 @@ class AdminBackend {
335399
.map((pv) => pv.version!)
336400
.toList());
337401

402+
versions.addAll(package.deletedVersions ?? const <String>[]);
403+
338404
tx.insert(ModeratedPackage()
339405
..parentKey = _db.emptyKey
340406
..id = packageName
341407
..name = packageName
342-
..moderated = clock.now().toUtc()
408+
..moderated = moderated ?? clock.now().toUtc()
343409
..versions = versions.toList()
344410
..publisherId = package.publisherId
345411
..uploaders = package.uploaders);
@@ -348,31 +414,10 @@ class AdminBackend {
348414
}
349415
});
350416

351-
final pool = Pool(10);
352-
final futures = <Future>[];
353-
versions.forEach((final v) {
354-
futures.add(pool.withResource(
355-
() => packageBackend.removePackageTarball(packageName, v)));
356-
});
357-
await Future.wait(futures);
358-
await pool.close();
359-
360417
_logger.info('Removing package from PackageVersion ...');
361418
await _db
362419
.deleteWithQuery(_db.query<PackageVersion>(ancestorKey: packageKey));
363420

364-
_logger.info('Removing package from PackageVersionInfo ...');
365-
await _db.deleteWithQuery(
366-
_db.query<PackageVersionInfo>()..filter('package =', packageName));
367-
368-
_logger.info('Removing package from PackageVersionAsset ...');
369-
await _db.deleteWithQuery(
370-
_db.query<PackageVersionAsset>()..filter('package =', packageName));
371-
372-
_logger.info('Removing package from Like ...');
373-
await _db.deleteWithQuery(
374-
_db.query<Like>()..filter('packageName =', packageName));
375-
376421
_logger.info('Package "$packageName" got successfully removed.');
377422
_logger.info(
378423
'NOTICE: Redis caches referencing the package will expire given time.');
@@ -740,4 +785,96 @@ class AdminBackend {
740785
}
741786
return refCase;
742787
}
788+
789+
/// Scans datastore and deletes moderated subjects where the last action
790+
/// was more than 3 years ago.
791+
Future<void> deleteModeratedSubjects({
792+
@visibleForTesting DateTime? before,
793+
}) async {
794+
before ??= clock.ago(days: 3 * 366).toUtc(); // extra buffer days
795+
final canonicalBucket =
796+
storageService.bucket(activeConfiguration.canonicalPackagesBucketName!);
797+
798+
// delete packages
799+
final pQuery = _db.query<Package>()
800+
..filter('moderatedAt <', before)
801+
..order('moderatedAt');
802+
await for (final package in pQuery.run()) {
803+
// sanity check
804+
if (!package.isModerated) {
805+
continue;
806+
}
807+
808+
_logger.info('Deleting moderated package: ${package.name}');
809+
await _removePackage(
810+
package.name!,
811+
moderated: package.moderatedAt,
812+
);
813+
_logger.info('Deleted moderated package: ${package.name}');
814+
}
815+
816+
// delete package versions
817+
final pvQuery = _db.query<PackageVersion>()
818+
..filter('moderatedAt <', before)
819+
..order('moderatedAt');
820+
await for (final version in pvQuery.run()) {
821+
// sanity check
822+
if (!version.isModerated) {
823+
continue;
824+
}
825+
826+
_logger.info(
827+
'Deleting moderated package version: ${version.qualifiedVersionKey}');
828+
829+
// deleting from canonical bucket
830+
final objectName = tarballObjectName(version.package, version.version!);
831+
final info = await canonicalBucket.tryInfo(objectName);
832+
if (info != null) {
833+
await canonicalBucket.delete(objectName);
834+
}
835+
836+
// deleting from datastore
837+
await withRetryTransaction(_db, (tx) async {
838+
final pv = await tx.lookupOrNull<PackageVersion>(version.key);
839+
if (pv == null) {
840+
return null;
841+
}
842+
final p = await tx.lookupOrNull<Package>(version.packageKey!);
843+
if (p == null) {
844+
return;
845+
}
846+
final pvi = await tx.lookupOrNull<PackageVersionInfo>(_db.emptyKey
847+
.append(PackageVersionInfo,
848+
id: version.qualifiedVersionKey.qualifiedVersion));
849+
850+
p.deletedVersions ??= [];
851+
p.deletedVersions!.add(version.version!);
852+
p.deletedVersions!.sort();
853+
p.updated = clock.now().toUtc();
854+
tx.insert(p);
855+
856+
// delete version + info + assets
857+
tx.delete(pv.key);
858+
if (pvi != null) {
859+
tx.delete(pvi.key);
860+
861+
for (final assetKind in pvi.assets) {
862+
tx.delete(
863+
_db.emptyKey.append(PackageVersionAsset,
864+
id: Uri(pathSegments: [
865+
version.package,
866+
version.version!,
867+
assetKind
868+
]).path),
869+
);
870+
}
871+
}
872+
});
873+
_logger.info(
874+
'Deleted moderated package version: ${version.qualifiedVersionKey}');
875+
}
876+
877+
// TODO: delete publisher instances
878+
// TODO: mark user instances deleted
879+
}
743880
}

app/lib/tool/neat_task/pub_dev_tasks.dart

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ import 'dart:io';
88
import 'package:gcloud/service_scope.dart' as ss;
99
import 'package:logging/logging.dart';
1010
import 'package:neat_periodic_task/neat_periodic_task.dart';
11-
import 'package:pub_dev/package/export_api_to_bucket.dart';
12-
import 'package:pub_dev/service/download_counts/sync_download_counts.dart';
1311

1412
import '../../account/backend.dart';
1513
import '../../account/consent_backend.dart';
14+
import '../../admin/backend.dart';
1615
import '../../audit/backend.dart';
1716
import '../../package/backend.dart';
17+
import '../../package/export_api_to_bucket.dart';
1818
import '../../search/backend.dart';
19+
import '../../service/download_counts/sync_download_counts.dart';
1920
import '../../service/email/backend.dart';
2021
import '../../service/security_advisories/sync_security_advisories.dart';
2122
import '../../service/topics/count_topics.dart';
@@ -122,6 +123,13 @@ void _setupGenericPeriodicTasks() {
122123
task: () async => await apiExporter?.uploadPkgNameCompletionData(),
123124
);
124125

126+
// Deletes moderated packages, versions, publishers and users.
127+
_weekly(
128+
name: 'delete-moderated-subjects',
129+
isRuntimeVersioned: false,
130+
task: () async => adminBackend.deleteModeratedSubjects(),
131+
);
132+
125133
// Deletes task status entities where the status hasn't been updated
126134
// for more than a month.
127135
_weekly(

app/test/admin/moderate_package_test.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import 'package:_pub_shared/data/account_api.dart';
88
import 'package:_pub_shared/data/admin_api.dart';
99
import 'package:_pub_shared/data/package_api.dart';
1010
import 'package:clock/clock.dart';
11+
import 'package:gcloud/storage.dart';
1112
import 'package:http/http.dart' as http;
13+
import 'package:pub_dev/account/backend.dart';
1214
import 'package:pub_dev/admin/actions/actions.dart';
15+
import 'package:pub_dev/admin/backend.dart';
1316
import 'package:pub_dev/admin/models.dart';
1417
import 'package:pub_dev/fake/backend/fake_auth_provider.dart';
1518
import 'package:pub_dev/fake/backend/fake_pub_worker.dart';
@@ -18,6 +21,7 @@ import 'package:pub_dev/scorecard/backend.dart';
1821
import 'package:pub_dev/search/backend.dart';
1922
import 'package:pub_dev/shared/configuration.dart';
2023
import 'package:pub_dev/shared/datastore.dart';
24+
import 'package:pub_dev/shared/storage.dart';
2125
import 'package:pub_dev/tool/maintenance/update_public_bucket.dart';
2226
import 'package:test/test.dart';
2327

@@ -369,5 +373,43 @@ void main() {
369373
message: 'ModerationCase.status ("no-action") != "pending".',
370374
);
371375
});
376+
377+
testWithProfile(
378+
'cleanup deletes datastore entities and canonical archive file',
379+
fn: () async {
380+
// delete old version
381+
await accountBackend.withBearerToken(siteAdminToken, () async {
382+
await adminBackend.removePackageVersion('oxygen', '1.0.0');
383+
});
384+
385+
// canonical file is present
386+
final bucket = storageService
387+
.bucket(activeConfiguration.canonicalPackagesBucketName!);
388+
expect(
389+
await bucket.tryInfo(tarballObjectName('oxygen', '1.2.0')),
390+
isNotNull,
391+
);
392+
393+
// moderate and cleanup
394+
await _moderate('oxygen', state: true, caseId: 'none');
395+
await adminBackend.deleteModeratedSubjects(before: clock.now().toUtc());
396+
397+
// no package, version or canonical file
398+
expect(await packageBackend.lookupPackage('oxygen'), isNull);
399+
expect(
400+
await packageBackend.lookupPackageVersion('oxygen', '1.2.0'),
401+
isNull,
402+
);
403+
expect(
404+
await bucket.tryInfo(tarballObjectName('oxygen', '1.2.0')),
405+
isNull,
406+
);
407+
408+
// ModeratedPackage entity contains both previously deleted and current versions
409+
final mp = await packageBackend.lookupModeratedPackage('oxygen');
410+
expect(mp, isNotNull);
411+
expect(mp!.versions, contains('1.0.0'));
412+
expect(mp.versions, contains('1.2.0'));
413+
});
372414
});
373415
}

0 commit comments

Comments
 (0)