Skip to content

Commit 6bd343e

Browse files
committed
ReservedPackage + admin action.
1 parent 87022b5 commit 6bd343e

File tree

5 files changed

+218
-6
lines changed

5 files changed

+218
-6
lines changed

app/lib/admin/actions/actions.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import 'moderation_case_list.dart';
1818
import 'moderation_case_resolve.dart';
1919
import 'moderation_case_update.dart';
2020
import 'package_info.dart';
21+
import 'package_reserve.dart';
2122
import 'package_version_info.dart';
2223
import 'package_version_retraction.dart';
2324
import 'publisher_block.dart';
@@ -98,6 +99,7 @@ final class AdminAction {
9899
moderationCaseResolve,
99100
moderationCaseUpdate,
100101
packageInfo,
102+
packageReserve,
101103
packageVersionInfo,
102104
packageVersionRetraction,
103105
publisherBlock,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:pub_dev/package/backend.dart';
6+
import 'package:pub_dev/package/models.dart';
7+
import 'package:pub_dev/shared/datastore.dart';
8+
9+
import 'actions.dart';
10+
11+
final packageReserve = AdminAction(
12+
name: 'package-reserve',
13+
summary: 'Creates a ReservedPackage entity.',
14+
description: '''
15+
Reserves a package name that can be claimed by @google.com accounts or
16+
the allowed list of email addresses.
17+
18+
The action can be re-run with the same package name. In such cases the provided
19+
email list will be added to the existing ReservedPackage entity.
20+
''',
21+
options: {
22+
'package': 'The package name to be reserved.',
23+
'emails': 'The list of email addresses, separated by comma.'
24+
},
25+
invoke: (options) async {
26+
final package = options['package'];
27+
InvalidInputException.check(
28+
package != null && package.isNotEmpty,
29+
'`package` must be given',
30+
);
31+
final emails = options['emails']?.split(',');
32+
33+
final p = await packageBackend.lookupPackage(package!);
34+
if (p != null) {
35+
throw InvalidInputException('Package `$package` exists.');
36+
}
37+
final mp = await packageBackend.lookupModeratedPackage(package);
38+
if (mp != null) {
39+
throw InvalidInputException('ModeratedPackage `$package` exists.');
40+
}
41+
42+
final entry = await withRetryTransaction(dbService, (tx) async {
43+
final existing = await tx.lookupOrNull<ReservedPackage>(
44+
dbService.emptyKey.append(ReservedPackage, id: package));
45+
final entry = existing ?? ReservedPackage.init(package);
46+
if (emails != null) {
47+
entry.emails = <String>{...entry.emails, ...emails}.toList();
48+
}
49+
tx.insert(entry);
50+
return entry;
51+
});
52+
53+
return {
54+
'ReservedPackage': {
55+
'name': entry.name,
56+
'created': entry.created.toIso8601String(),
57+
'emails': entry.emails,
58+
},
59+
};
60+
},
61+
);

app/lib/package/backend.dart

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,14 @@ class PackageBackend {
183183
return await db.lookupOrNull<ModeratedPackage>(packageKey);
184184
}
185185

186+
/// Looks up a reserved package by name.
187+
///
188+
/// Returns `null` if the package doesn't exist.
189+
Future<ReservedPackage?> lookupReservedPackage(String packageName) async {
190+
final packageKey = db.emptyKey.append(ReservedPackage, id: packageName);
191+
return await db.lookupOrNull<ReservedPackage>(packageKey);
192+
}
193+
186194
/// Looks up a package by name.
187195
Future<List<Package>> lookupPackages(Iterable<String> packageNames) async {
188196
return (await db.lookup(packageNames
@@ -1014,10 +1022,19 @@ class PackageBackend {
10141022
required String name,
10151023
required AuthenticatedAgent agent,
10161024
}) async {
1017-
final isGoogleComUser =
1018-
agent is AuthenticatedUser && agent.user.email!.endsWith('@google.com');
1019-
final isReservedName = matchesReservedPackageName(name);
1020-
final isExempted = isGoogleComUser && isReservedName;
1025+
final reservedPackage = await lookupReservedPackage(name);
1026+
final reservedEmails = reservedPackage?.emails ?? const <String>[];
1027+
1028+
bool isAllowedUser = false;
1029+
if (agent is AuthenticatedUser) {
1030+
final email = agent.user.email;
1031+
isAllowedUser = email != null &&
1032+
(email.endsWith('@google.com') || reservedEmails.contains(email));
1033+
}
1034+
1035+
final isReservedName =
1036+
reservedPackage != null || matchesReservedPackageName(name);
1037+
final isExempted = isReservedName && isAllowedUser;
10211038

10221039
final conflictingName = await nameTracker.accept(name);
10231040
if (conflictingName != null && !isExempted) {
@@ -1039,8 +1056,8 @@ class PackageBackend {
10391056
throw PackageRejectedException(newNameIssues.first.message);
10401057
}
10411058

1042-
// reserved package names for the Dart team
1043-
if (isReservedName && !isGoogleComUser) {
1059+
// reserved package names for the Dart team or allowlisted users
1060+
if (isReservedName && !isAllowedUser) {
10441061
throw PackageRejectedException.nameReserved(name);
10451062
}
10461063
}
@@ -1125,6 +1142,14 @@ class PackageBackend {
11251142
throw PackageRejectedException.nameReserved(newVersion.package);
11261143
}
11271144

1145+
if (isNew) {
1146+
final reservedPackage = await tx.lookupOrNull<ReservedPackage>(
1147+
db.emptyKey.append(ReservedPackage, id: newVersion.package));
1148+
if (reservedPackage != null) {
1149+
tx.delete(reservedPackage.key);
1150+
}
1151+
}
1152+
11281153
// If the version already exists, we fail.
11291154
if (version != null) {
11301155
_logger.info('Version ${version.version} of package '

app/lib/package/models.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,28 @@ class ModeratedPackage extends db.ExpandoModel<String> {
875875
List<String>? versions;
876876
}
877877

878+
/// Entity representing a reserved package: the name is available only
879+
/// for a subset of the users (`@google.com` + list of [emails]).
880+
@db.Kind(name: 'ReservedPackage', idType: db.IdType.String)
881+
class ReservedPackage extends db.ExpandoModel<String> {
882+
@db.StringProperty(required: true)
883+
String? name;
884+
885+
@db.DateTimeProperty()
886+
late DateTime created;
887+
888+
/// List of email addresses that are allowed to claim this package name.
889+
/// This is on top of the `@google.com` email addresses.
890+
@db.StringListProperty()
891+
List<String> emails = <String>[];
892+
893+
ReservedPackage();
894+
ReservedPackage.init(this.name) {
895+
id = name;
896+
created = clock.now().toUtc();
897+
}
898+
}
899+
878900
/// An identifier to point to a specific [package] and [version].
879901
class QualifiedVersionKey {
880902
final String? package;
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:_pub_shared/data/admin_api.dart';
6+
import 'package:clock/clock.dart';
7+
import 'package:pub_dev/fake/backend/fake_auth_provider.dart';
8+
import 'package:pub_dev/package/backend.dart';
9+
import 'package:pub_dev/package/models.dart';
10+
import 'package:pub_dev/shared/datastore.dart';
11+
import 'package:test/test.dart';
12+
13+
import '../package/backend_test_utils.dart';
14+
import '../shared/handlers_test_utils.dart';
15+
import '../shared/test_models.dart';
16+
import '../shared/test_services.dart';
17+
18+
void main() {
19+
group('Reserve package', () {
20+
Future<void> _reserve(
21+
String package, {
22+
List<String>? emails,
23+
}) async {
24+
final api = createPubApiClient(authToken: siteAdminToken);
25+
await api.adminInvokeAction(
26+
'package-reserve',
27+
AdminInvokeActionArguments(arguments: {
28+
'package': package,
29+
if (emails != null) 'emails': emails.join(','),
30+
}),
31+
);
32+
}
33+
34+
testWithProfile('cannot reserve existing package', fn: () async {
35+
await expectApiException(
36+
_reserve('oxygen'),
37+
code: 'InvalidInput',
38+
status: 400,
39+
message: 'Package `oxygen` exists.',
40+
);
41+
});
42+
43+
testWithProfile('cannot reserve ModeratedPackage', fn: () async {
44+
await dbService.commit(inserts: [
45+
ModeratedPackage()
46+
..id = 'pkg'
47+
..name = 'pkg'
48+
..moderated = clock.now()
49+
..uploaders = <String>[]
50+
..versions = <String>['1.0.0']
51+
]);
52+
await expectApiException(
53+
_reserve('pkg'),
54+
code: 'InvalidInput',
55+
status: 400,
56+
message: 'ModeratedPackage `pkg` exists.',
57+
);
58+
});
59+
60+
testWithProfile('prevents non-whitelisted publishing', fn: () async {
61+
await _reserve('pkg');
62+
63+
final pubspecContent = generatePubspecYaml('pkg', '1.0.0');
64+
final bytes = await packageArchiveBytes(pubspecContent: pubspecContent);
65+
await expectApiException(
66+
createPubApiClient(authToken: adminClientToken)
67+
.uploadPackageBytes(bytes),
68+
code: 'PackageRejected',
69+
status: 400,
70+
message: 'Package name pkg is reserved.',
71+
);
72+
});
73+
74+
testWithProfile('allows whitelisted publishing', fn: () async {
75+
await _reserve('pkg');
76+
// update email addresses in a second request
77+
await _reserve('pkg', emails: ['[email protected]']);
78+
79+
final pubspecContent = generatePubspecYaml('pkg', '1.0.0');
80+
final bytes = await packageArchiveBytes(pubspecContent: pubspecContent);
81+
await createPubApiClient(authToken: adminClientToken)
82+
.uploadPackageBytes(bytes);
83+
84+
final rp = await packageBackend.lookupReservedPackage('pkg');
85+
expect(rp, isNull);
86+
});
87+
88+
testWithProfile('allows Dart-team publishing', fn: () async {
89+
await _reserve('pkg');
90+
91+
final pubspecContent = generatePubspecYaml('pkg', '1.0.0');
92+
final bytes = await packageArchiveBytes(pubspecContent: pubspecContent);
93+
await createPubApiClient(
94+
authToken: createFakeAuthTokenForEmail('[email protected]',
95+
audience: 'fake-client-audience'))
96+
.uploadPackageBytes(bytes);
97+
98+
final rp = await packageBackend.lookupReservedPackage('pkg');
99+
expect(rp, isNull);
100+
});
101+
});
102+
}

0 commit comments

Comments
 (0)