Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/lib/frontend/handlers/pubapi.client.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions app/lib/frontend/handlers/pubapi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import 'account.dart';
import 'custom_api.dart';
import 'listing.dart';
import 'package.dart';
import 'publisher.dart';

part 'pubapi.g.dart';

Expand Down Expand Up @@ -101,8 +100,12 @@ class PubApi {

/// Starts publisher creation flow.
@EndPoint.post('/api/publishers/<publisherId>')
Future<Response> createPublisher(Request request, String publisherId) =>
createPublisherApiHandler(request, publisherId);
Future<PublisherInfo> createPublisher(
Request request,
String publisherId,
CreatePublisherRequest body,
) =>
publisherBackend.createPublisher(publisherId, body);

/// Returns publisher data in a JSON form.
@EndPoint.get('/api/publishers/<publisherId>')
Expand Down
9 changes: 7 additions & 2 deletions app/lib/frontend/handlers/pubapi.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 0 additions & 7 deletions app/lib/frontend/handlers/publisher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,3 @@ Future<shelf.Response> createPublisherPageHandler(shelf.Request request) async {
// TODO: implement
return notFoundHandler(request);
}

/// Handles requests for POST /api/publisher/<publisherId>
Future<shelf.Response> createPublisherApiHandler(
shelf.Request request, String publisherId) async {
// TODO: implement
return notFoundHandler(request);
}
82 changes: 82 additions & 0 deletions app/lib/publisher/backend.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import '../account/backend.dart';
import '../account/consent_backend.dart';
import '../shared/email.dart';
import '../shared/exceptions.dart';
import 'domain_verifier.dart' show domainVerifier;

import 'models.dart';

Expand Down Expand Up @@ -60,6 +61,87 @@ class PublisherBackend {
});
}

/// Create publisher.
Future<api.PublisherInfo> createPublisher(
String publisherId,
api.CreatePublisherRequest body,
) async {
// Sanity check that domains are:
// - lowercase (because we want that in pub.dev)
// - consist of a-z, 0-9 and dashes
// We do not care if they end in dash, as such domains can't be verified.
InvalidInputException.checkMatchPattern(
publisherId,
'publisherId',
RegExp(r'^[a-z0-9-]{1,63}\.[a-z0-9-]{1,63}$'),
);
InvalidInputException.checkStringLength(
publisherId,
'publisherId',
maximum: 255, // Some upper limit for sanity.
);
InvalidInputException.checkNotNull(body.accessToken, 'accessToken');
InvalidInputException.checkStringLength(
body.accessToken,
'accessToken',
minimum: 1,
maximum: 4096,
);

// Verify ownership of domain.
final isOwner = await domainVerifier.verifyDomainOwnership(
publisherId,
body.accessToken,
);
if (!isOwner) {
throw AuthorizationException.userIsNotDomainOwner(publisherId);
}

// Create the publisher
final now = DateTime.now().toUtc();
await _db.withTransaction((tx) async {
final key = _db.emptyKey.append(Publisher, id: publisherId);
final p = (await tx.lookup<Publisher>([key])).single;
if (p != null) {
// Check that publisher is the same as what we would create.
if (p.created.isBefore(now.subtract(Duration(minutes: 10))) ||
p.updated.isBefore(now.subtract(Duration(minutes: 10))) ||
p.contactEmail != authenticatedUser.email ||
p.description != '' ||
p.websiteUrl != 'https://$publisherId') {
throw ConflictException.publisherAlreadyExists(publisherId);
}
// Avoid creating the same publisher again, this end-point is idempotent
// if we just do nothing here.
return;
}

// Create publisher
tx.queueMutations(inserts: [
Publisher()
..parentKey = _db.emptyKey
..id = publisherId
..created = now
..description = ''
..contactEmail = authenticatedUser.email
..updated = now
..websiteUrl = 'https://$publisherId',
PublisherMember()
..parentKey = _db.emptyKey.append(Publisher, id: publisherId)
..id = authenticatedUser.userId
..created = now
..updated = now
..role = PublisherMemberRole.admin
]);
await tx.commit();
});

// Return publisher as it was created
final key = _db.emptyKey.append(Publisher, id: publisherId);
final p = (await _db.lookup<Publisher>([key])).single;
return _asPublisherInfo(p);
}

/// Gets the publisher data
Future<api.PublisherInfo> getPublisher(String publisherId) async {
final p = await _getPublisher(publisherId);
Expand Down
62 changes: 62 additions & 0 deletions app/lib/publisher/domain_verifier.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import 'package:gcloud/service_scope.dart' as ss;
import 'package:googleapis_auth/auth.dart' as auth;
import 'package:googleapis/webmasters/v3.dart' as wmx;
import 'package:http/http.dart' as http;
import 'package:retry/retry.dart' show retry;

import '../shared/exceptions.dart' show AuthorizationException;

/// Sets the [DomainVerifier] service.
void registerDomainVerifier(DomainVerifier domainVerifier) =>
ss.register(#_domainVerifier, domainVerifier);

/// The active [DomainVerifier] service.
DomainVerifier get domainVerifier =>
ss.lookup(#_domainVerifier) as DomainVerifier;

/// Service that can verify ownership of a domain by asking Search Console
/// through Webmaster API v3.
///
/// Please obtain instances of this from [domainVerifier], as this allows for
/// dependency injection during testing.
class DomainVerifier {
/// Verify ownership of [domain] using [accessToken] which has the read-only
/// scope for Search Console API.
Future<bool> verifyDomainOwnership(String domain, String accessToken) async {
// Create client for talking to Webmasters API:
// https://developers.google.com/webmaster-tools/search-console-api-original/v3/parameters
final client = auth.authenticatedClient(
http.Client(),
auth.AccessCredentials(
auth.AccessToken(
'Bearer',
accessToken,
DateTime.now().toUtc().add(Duration(minutes: 20)), // avoid refresh
),
null,
[wmx.WebmastersApi.WebmastersReadonlyScope],
),
);
try {
// Request list of sites/domains from the Search Console API.
final sites = await retry(
() => wmx.WebmastersApi(client).sites.list(),
maxAttempts: 3,
maxDelay: Duration(milliseconds: 500),
retryIf: (e) => e is! auth.AccessDeniedException,
);
// Determine if the user is in fact owner of the domain in question.
// Note. domains are prefixed 'sc-domain:' and 'siteOwner' is the only
// permission that ensures the user actually did DNS verification.
return sites.siteEntry.any(
(s) =>
s.siteUrl.toLowerCase() == 'sc-domain:$domain' &&
s.permissionLevel == 'siteOwner', // must be 'siteOwner'!
);
} on auth.AccessDeniedException {
throw AuthorizationException.missingSearchConsoleReadAccess();
} finally {
client.close();
}
}
}
40 changes: 39 additions & 1 deletion app/lib/shared/exceptions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ class AuthenticationException extends ResponseException
///
/// Example:
/// * Modifying a package for which the user doesn't have permissions,
/// * Creating a package without domain validation.
/// * Creating a publisher without domain validation.
class AuthorizationException extends ResponseException
implements UnauthorizedAccessException {
AuthorizationException._(String message)
Expand Down Expand Up @@ -201,6 +201,39 @@ class AuthorizationException extends ResponseException
'package `$publisher`.',
);

static final _domainVerificationUrl =
Uri.parse('https://www.google.com/webmasters/verification/verification');

/// Signaling that the user is not a verified owner of the [domain] for which
/// the user is trying to create a publisher.
factory AuthorizationException.userIsNotDomainOwner(String domain) =>
AuthorizationException._([
'Insufficient permissions to create publisher `$domain`, to create ',
'this publisher the domain `$domain` must be _verified_ in the ',
'[search console](https://search.google.com/search-console/welcome).',
'',
'It is not sufficient to be granted access to the domain, the domain ',
'must be verified with the Google account used to created the ',
'publisher. It is also insufficient to verify a URL or URL prefix,',
'the domain must be verified with a **DNS record**.',
'',
'<b><a href="${_domainVerificationUrl.replace(queryParameters: {
"domain": domain
})}" target="_blank">Open domain verification flow.</a></b>',
'',
'Note, once the publisher is created the domain verification need not',
'remain in place. This is only required for publisher creation.',
].join('\n'));

/// Signaling that the user did not grant read-only access to the
/// search console, making it impossible for the server to verify the users
/// domain ownership.
factory AuthorizationException.missingSearchConsoleReadAccess() =>
AuthorizationException._([
'Read-only access to Search Console data was not granted, preventing',
'`pub.dev` from verifying that you own the domain.',
].join('\n'));

@override
String toString() => '$code: $message'; // used by package:pub_server
}
Expand All @@ -225,6 +258,11 @@ class ConflictException extends ResponseException {
/// The active user can't update their own role.
factory ConflictException.cantUpdateOwnRole() =>
ConflictException._('User can\'t update their own role.');

/// The user is trying to create a publisher that already exists.
factory ConflictException.publisherAlreadyExists(String domain) =>
ConflictException._(
'A publisher with the domain `$domain` already exists');
}

/// Thrown when the analysis for a package is not done yet.
Expand Down
2 changes: 2 additions & 0 deletions app/lib/shared/services.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import '../frontend/search_service.dart';
import '../history/backend.dart';
import '../job/backend.dart';
import '../publisher/backend.dart';
import '../publisher/domain_verifier.dart';
import '../scorecard/backend.dart';
import '../search/backend.dart';
import '../search/index_simple.dart';
Expand Down Expand Up @@ -71,6 +72,7 @@ Future<void> withPubServices(FutureOr<void> Function() fn) async {
PopularityStorage(await getOrCreateBucket(
storageService, activeConfiguration.popularityDumpBucketName)),
);
registerDomainVerifier(DomainVerifier());
registerPublisherBackend(PublisherBackend(dbService));
registerScoreCardBackend(ScoreCardBackend(dbService));
registerSearchBackend(SearchBackend(dbService));
Expand Down
49 changes: 49 additions & 0 deletions app/test/publisher/api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,55 @@ void main() {
});
});

group('Create publisher', () {
testWithServices('verified.com', () async {
final api = createPubApiClient(authToken: hansAuthenticated.userId);

// Check that we can create the publisher
final r1 = await api.createPublisher(
'verified.com',
CreatePublisherRequest(accessToken: 'dummy-token-for-testing'),
);
expect(r1.contactEmail, hansAuthenticated.email);

// Check that creating again idempotently works too
final r2 = await api.createPublisher(
'verified.com',
CreatePublisherRequest(accessToken: 'dummy-token-for-testing'),
);
expect(r2.contactEmail, hansAuthenticated.email);

// Check that we can update the description
final r3 = await api.updatePublisher(
'verified.com',
UpdatePublisherRequest(description: 'hello-world'),
);
expect(r3.description, 'hello-world');

// Check that we get a sane result from publisherInfo
final r4 = await api.publisherInfo('verified.com');
expect(r4.toJson(), {
'description': 'hello-world',
'contactEmail': hansAuthenticated.email,
});
});

testWithServices('notverified.com', () async {
final api = createPubApiClient(authToken: hansAuthenticated.userId);

// Check that we can create the publisher
final rs = api.createPublisher(
'notverified.com',
CreatePublisherRequest(accessToken: 'dummy-token-for-testing'),
);
await expectApiException(
rs,
status: 403,
code: 'InsufficientPermissions',
);
});
});

group('Update description', () {
_testAdminAuthIssues(
(client) => client.updatePublisher(
Expand Down
Loading