Skip to content

Commit

Permalink
feat: support authenticating private pub repository (#627)
Browse files Browse the repository at this point in the history
  • Loading branch information
VictorOhashi authored and spydon committed Jan 11, 2024
1 parent a153e98 commit 34d766d
Show file tree
Hide file tree
Showing 8 changed files with 419 additions and 97 deletions.
7 changes: 5 additions & 2 deletions packages/melos/lib/src/commands/publish.dart
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,12 @@ mixin _PublishMixin on _ExecMixin {
(package) async {
if (package.isPrivate) return;

final versions = await package.getPublishedVersions();
final pubPackage = await package.getPublishedPackage();
final versions = pubPackage?.prioritizedVersions
.map((v) => v.version.toString())
.toList();

if (versions.isEmpty) {
if (versions == null || versions.isEmpty) {
latestPackageVersion[package.name] = null;
return;
}
Expand Down
5 changes: 2 additions & 3 deletions packages/melos/lib/src/common/http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

@visibleForTesting
http.Client? testClient;
http.Client internalHttpClient = http.Client();

Future<http.Response> get(Uri url, {Map<String, String>? headers}) =>
testClient?.get(url, headers: headers) ?? http.get(url, headers: headers);
http.Client get httpClient => internalHttpClient;
108 changes: 108 additions & 0 deletions packages/melos/lib/src/common/pub_credential.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import 'dart:convert';
import 'dart:io';

import 'package:cli_util/cli_util.dart';
import 'package:collection/collection.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as path;

import 'io.dart';
import 'pub_hosted.dart';

const _pubTokenFileName = 'pub-tokens.json';

@visibleForTesting
PubCredentialStore internalPubCredentialStore =
PubCredentialStore.fromConfigFile();

PubCredentialStore get pubCredentialStore => internalPubCredentialStore;

class PubCredentialStore {
PubCredentialStore(this.credentials);

factory PubCredentialStore.fromConfigFile({String? configDir}) {
configDir ??= applicationConfigHome('dart');
final tokenFilePath = path.join(configDir, _pubTokenFileName);

if (!fileExists(tokenFilePath)) {
return PubCredentialStore([]);
}

final content =
jsonDecode(readTextFile(tokenFilePath)) as Map<String, dynamic>?;

final hostedCredentials = content?['hosted'] as List<dynamic>? ?? const [];

final credentials = hostedCredentials
.cast<Map<String, dynamic>>()
.map(PubCredential.fromJson)
.toList();

return PubCredentialStore(credentials);
}

final List<PubCredential> credentials;

PubCredential? findCredential(Uri hostedUrl) {
return credentials.firstWhereOrNull(
(c) => c.url == hostedUrl && c.isValid(),
);
}
}

class PubCredential {
@visibleForTesting
PubCredential({
required this.url,
required this.token,
this.env,
});

factory PubCredential.fromJson(Map<String, dynamic> json) {
final hostedUrl = json['url'] as String?;

if (hostedUrl == null) {
throw const FormatException('Url is not provided for the credential');
}

return PubCredential(
url: normalizeHostedUrl(Uri.parse(hostedUrl)),
token: json['token'] as String?,
env: json['env'] as String?,
);
}

/// Server url which this token authenticates.
final Uri url;

/// Authentication token value
final String? token;

/// Environment variable name that stores token value
final String? env;

bool isValid() => (token == null) ^ (env == null);

String? get _tokenValue {
final environment = env;
if (environment != null) {
final value = Platform.environment[environment];

if (value == null) {
throw FormatException(
'Saved credential for "$url" pub repository requires environment '
'variable named "$env" but not defined.',
);
}

return value;
} else {
return token;
}
}

String? getAuthHeader() {
if (!isValid()) return null;
return 'Bearer $_tokenValue';
}
}
105 changes: 105 additions & 0 deletions packages/melos/lib/src/common/pub_hosted.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';

import 'http.dart';
import 'platform.dart';
import 'pub_credential.dart';
import 'pub_hosted_package.dart';

/// The URL where we can find a package server.
///
/// The default is `pub.dev`, but it can be overridden using the
/// `PUB_HOSTED_URL` environment variable.
/// https://dart.dev/tools/pub/environment-variables
Uri get defaultPubUrl => Uri.parse(
currentPlatform.environment['PUB_HOSTED_URL'] ?? 'https://pub.dev',
);

class PubHostedClient extends http.BaseClient {
@visibleForTesting
PubHostedClient(this.pubHosted, this._inner, this._credentialStore);

factory PubHostedClient.fromUri({required Uri? pubHosted}) {
final store = pubCredentialStore;
final innerClient = httpClient;
final uri = normalizeHostedUrl(pubHosted ?? defaultPubUrl);

return PubHostedClient(uri, innerClient, store);
}

final http.Client _inner;

final PubCredentialStore _credentialStore;

final Uri pubHosted;

@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
final credential = _credentialStore.findCredential(pubHosted);

if (credential != null) {
final authToken = credential.getAuthHeader();
if (authToken != null) {
request.headers[HttpHeaders.authorizationHeader] = authToken;
}
}

return _inner.send(request);
}

Future<PubHostedPackage?> fetchPackage(String name) async {
final url = pubHosted.resolve('api/packages/$name');
final response = await get(url);

if (response.statusCode == 404) {
// The package was never published
return null;
} else if (response.statusCode != 200) {
throw Exception(
'Error reading pub.dev registry for package "$name" '
'(HTTP Status ${response.statusCode}), response: ${response.body}',
);
}

final data = json.decode(response.body) as Map<String, Object?>;
return PubHostedPackage.fromJson(data);
}

@override
void close() => _inner.close();
}

Uri normalizeHostedUrl(Uri uri) {
var u = uri;

if (!u.hasScheme || (u.scheme != 'http' && u.scheme != 'https')) {
throw FormatException('url scheme must be https:// or http://', uri);
}
if (!u.hasAuthority || u.host == '') {
throw FormatException('url must have a hostname', uri);
}
if (u.userInfo != '') {
throw FormatException('user-info is not supported in url', uri);
}
if (u.hasQuery) {
throw FormatException('querystring is not supported in url', uri);
}
if (u.hasFragment) {
throw FormatException('fragment is not supported in url', uri);
}
u = u.normalizePath();
// If we have a path of only `/`
if (u.path == '/') {
u = u.replace(path: '');
}
// If there is a path, and it doesn't end in a slash we normalize to slash
if (u.path.isNotEmpty && !u.path.endsWith('/')) {
u = u.replace(path: '${u.path}/');
}

return u;
}
70 changes: 70 additions & 0 deletions packages/melos/lib/src/common/pub_hosted_package.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import 'package:pub_semver/pub_semver.dart';

class PubHostedPackage {
PubHostedPackage({required this.name, required this.versions, this.latest});

factory PubHostedPackage.fromJson(Map<String, dynamic> json) {
final name = json['name'] as String?;
final latest = json['latest'] as Map<String, dynamic>?;
final versions = json['versions'] as List<dynamic>? ?? const [];

if (name == null) {
throw const FormatException('Name is not provided for the package');
}

final packageVersions = versions
.map((v) => PubPackageVersion.fromJson(v as Map<String, dynamic>))
.toList();

return PubHostedPackage(
name: name,
versions: packageVersions,
latest: latest != null ? PubPackageVersion.fromJson(latest) : null,
);
}

/// Returns the name of this package.
final String name;

/// Returns the latest version of this package if available.
final PubPackageVersion? latest;

/// Returns the versions of this package.
final List<PubPackageVersion> versions;

/// Returns the sorted versions of this package.
List<PubPackageVersion> get prioritizedVersions {
final versions = [...this.versions];
return versions..sort((a, b) => Version.prioritize(a.version, b.version));
}

bool isVersionPublished(Version version) {
if (latest != null && latest!.version == version) {
return true;
}

return prioritizedVersions.map((v) => v.version).contains(version);
}
}

class PubPackageVersion {
PubPackageVersion({required this.version, this.published});

factory PubPackageVersion.fromJson(Map<String, dynamic> json) {
final version = json['version'] as String?;
final published = json['published'] as String?;

if (version == null) {
throw const FormatException('Version is not provided for the package');
}

return PubPackageVersion(
version: Version.parse(version),
published: published != null ? DateTime.tryParse(published) : null,
);
}

final Version version;

final DateTime? published;
}
Loading

0 comments on commit 34d766d

Please sign in to comment.