├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── example.dart └── src │ └── examples │ ├── cow_repository.dart │ ├── file_repository.dart │ └── http_proxy_repository.dart ├── lib ├── repository.dart └── shelf_pubserver.dart ├── pubspec.yaml └── test └── shelf_pubserver_test.dart /.gitignore: -------------------------------------------------------------------------------- 1 | # Don’t commit the following directories created by pub. 2 | .dart_tool 3 | .pub/ 4 | .packages 5 | packages 6 | pubspec.lock 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: dart 2 | 3 | dart: 4 | - 2.2.0 5 | - dev 6 | 7 | dart_task: 8 | - test 9 | - dartfmt 10 | - dartanalyzer: --fatal-infos --fatal-warnings . 11 | 12 | # Only building master means that we don't run two builds for each pull request. 13 | branches: 14 | only: [master] 15 | 16 | cache: 17 | directories: 18 | - $HOME/.pub-cache 19 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | # Below is a list of people and organizations that have contributed 2 | # to the Dart project. Names should be added to the list like so: 3 | # 4 | # Name/Organization 5 | 6 | Google Inc. 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.6 2 | 3 | * Require at least Dart 2.2 4 | 5 | ## 0.1.5 6 | 7 | * Differentiate between client- and server-side upload issues. 8 | 9 | ## 0.1.4+2 10 | 11 | * Drop support for Dart 1.x. 12 | 13 | ## 0.1.4+1 14 | 15 | * Support Dart 2 stable releases. 16 | 17 | * Support latest release of `package:pub_semver`. 18 | 19 | ## 0.1.4 20 | 21 | * Dart 2 support with `dart2_constant`. 22 | 23 | ## 0.1.3 24 | 25 | * `PackageRepository.download` now has more specific return type: 26 | `Future>>`. 27 | 28 | * Fix incorrect boundary parsing during upload. 29 | 30 | * Update minimum Dart SDK to `1.23.0`. 31 | 32 | ## 0.1.2 33 | 34 | * Add support for generic exceptions raised e.g. due to `pubspec.yaml` 35 | validation failure. 36 | 37 | ## 0.1.1+4 38 | 39 | * Support the latest version of `pkg/shelf`. 40 | 41 | ## 0.1.1+3 42 | 43 | * Support the latest release of `pub_semver`. 44 | 45 | ## 0.1.1+2 46 | 47 | * Fixed null comparision. 48 | 49 | ## 0.1.1+1 50 | 51 | * Updated dependencies. 52 | 53 | ## 0.1.1 54 | 55 | * Return "400 Bad Request" in case the version number encoded in the URL is not 56 | a valid semantic version 57 | 58 | ## 0.1.0 59 | 60 | Initial release. 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2014, the Dart project authors. All rights reserved. 2 | Redistribution and use in source and binary forms, with or without 3 | modification, are permitted provided that the following conditions are 4 | met: 5 | 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above 9 | copyright notice, this list of conditions and the following 10 | disclaimer in the documentation and/or other materials provided 11 | with the distribution. 12 | * Neither the name of Google Inc. nor the names of its 13 | contributors may be used to endorse or promote products derived 14 | from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ARCHIVED 2 | 3 | This repo has been archived, and is no longer maintained. 4 | 5 | Issues and PRs will *not* be responded to. 6 | 7 | Should there be community interest in alternate package servers for Dart, 8 | we recommend these are handled as community projects. 9 | 10 | ## NOTE: This is package is an alpha version and is not recommended for production use. 11 | 12 | Provides re-usable code for making a Dart package repository server. 13 | The `package:pub_server/shelf_pubserver.dart` library provides a [shelf] HTTP 14 | handler which provides the HTTP API used by the pub client. 15 | One can use different backend implementations by implementing the 16 | `PackageRepository` interface of the `package:pub_server/repository.dart` 17 | library. 18 | 19 | ## Example pub repository server 20 | 21 | An *experimental* pub server based on a file system can be found in 22 | `example/example.dart`. It uses a filesystem-based `PackageRepository` for 23 | storing packages and has a read-only fallback to the real `pub.dartlang.org` 24 | site, if a package is not available locally. This allows one to use all 25 | `pub.dartlang.org` packages and have additional ones, on top of the publicly 26 | available packages, available only locally. 27 | 28 | It can be run as follows 29 | ``` 30 | ~ $ git clone https://github.com/dart-lang/pub_server.git 31 | ~ $ cd pub_server 32 | ~/pub_server $ pub get 33 | ... 34 | ~/pub_server $ dart example/example.dart -d /tmp/package-db 35 | Listening on http://localhost:8080 36 | 37 | To make the pub client use this repository configure your shell via: 38 | 39 | $ export PUB_HOSTED_URL=http://localhost:8080 40 | ``` 41 | 42 | Using it for uploading new packages to the locally running server or downloading 43 | packages locally available or via a fallback to `pub.dartlang.org` is as easy 44 | as: 45 | 46 | ``` 47 | ~/foobar $ export PUB_HOSTED_URL=http://localhost:8080 48 | ~/foobar $ pub get 49 | ... 50 | ~/foobar $ pub publish 51 | Publishing x 0.1.0 to http://localhost:8080: 52 | |-- ... 53 | '-- pubspec.yaml 54 | 55 | Looks great! Are you ready to upload your package (y/n)? y 56 | Uploading... 57 | Successfully uploaded package. 58 | ``` 59 | 60 | The fact that the `pub publish` command requires you to grant it oauth2 access - 61 | which requires a Google account - is due to the fact that the `pub publish` 62 | cannot work without authentication or with another authentication scheme. 63 | *But the information sent by the pub client is not used for this local server 64 | at the moment*. 65 | 66 | [shelf]: https://pub.dartlang.org/packages/shelf 67 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:pedantic/analysis_options.yaml 2 | analyzer: 3 | strong-mode: 4 | implicit-casts: false 5 | linter: 6 | rules: 7 | # Errors 8 | - avoid_empty_else 9 | - await_only_futures 10 | - comment_references 11 | - control_flow_in_finally 12 | - empty_statements 13 | - hash_and_equals 14 | - test_types_in_equals 15 | - throw_in_finally 16 | - unawaited_futures 17 | - unrelated_type_equality_checks 18 | - valid_regexps 19 | 20 | # Style 21 | - annotate_overrides 22 | - avoid_init_to_null 23 | - avoid_return_types_on_setters 24 | - camel_case_types 25 | - directives_ordering 26 | - empty_catches 27 | - empty_constructor_bodies 28 | - library_names 29 | - library_prefixes 30 | - non_constant_identifier_names 31 | - prefer_final_fields 32 | - prefer_generic_function_type_aliases 33 | - prefer_is_not_empty 34 | - prefer_typing_uninitialized_variables 35 | - slash_for_doc_comments 36 | - type_init_formals 37 | - unnecessary_const 38 | - unnecessary_new 39 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, 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 'dart:io'; 6 | 7 | import 'package:args/args.dart'; 8 | import 'package:http/http.dart' as http; 9 | import 'package:logging/logging.dart'; 10 | import 'package:pub_server/shelf_pubserver.dart'; 11 | import 'package:shelf/shelf.dart'; 12 | import 'package:shelf/shelf_io.dart' as shelf_io; 13 | 14 | import 'src/examples/cow_repository.dart'; 15 | import 'src/examples/file_repository.dart'; 16 | import 'src/examples/http_proxy_repository.dart'; 17 | 18 | final Uri pubDartLangOrg = Uri.parse('https://pub.dartlang.org'); 19 | 20 | void main(List args) { 21 | var parser = argsParser(); 22 | var results = parser.parse(args); 23 | 24 | var directory = results['directory'] as String; 25 | var host = results['host'] as String; 26 | var port = int.parse(results['port'] as String); 27 | var standalone = results['standalone'] as bool; 28 | 29 | if (results.rest.isNotEmpty) { 30 | print('Got unexpected arguments: "${results.rest.join(' ')}".\n\nUsage:\n'); 31 | print(parser.usage); 32 | exit(1); 33 | } 34 | 35 | setupLogger(); 36 | runPubServer(directory, host, port, standalone); 37 | } 38 | 39 | Future runPubServer( 40 | String baseDir, String host, int port, bool standalone) { 41 | var client = http.Client(); 42 | 43 | var local = FileRepository(baseDir); 44 | var remote = HttpProxyRepository(client, pubDartLangOrg); 45 | var cow = CopyAndWriteRepository(local, remote, standalone); 46 | 47 | var server = ShelfPubServer(cow); 48 | print('Listening on http://$host:$port\n' 49 | '\n' 50 | 'To make the pub client use this repository configure your shell via:\n' 51 | '\n' 52 | ' \$ export PUB_HOSTED_URL=http://$host:$port\n' 53 | '\n'); 54 | 55 | return shelf_io.serve( 56 | const Pipeline() 57 | .addMiddleware(logRequests()) 58 | .addHandler(server.requestHandler), 59 | host, 60 | port); 61 | } 62 | 63 | ArgParser argsParser() { 64 | var parser = ArgParser(); 65 | 66 | parser.addOption('directory', 67 | abbr: 'd', defaultsTo: 'pub_server-repository-data'); 68 | 69 | parser.addOption('host', abbr: 'h', defaultsTo: 'localhost'); 70 | 71 | parser.addOption('port', abbr: 'p', defaultsTo: '8080'); 72 | parser.addFlag('standalone', abbr: 's', defaultsTo: false); 73 | return parser; 74 | } 75 | 76 | void setupLogger() { 77 | Logger.root.onRecord.listen((LogRecord record) { 78 | var head = '${record.time} ${record.level} ${record.loggerName}'; 79 | var tail = record.stackTrace != null ? '\n${record.stackTrace}' : ''; 80 | print('$head ${record.message} $tail'); 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /example/src/examples/cow_repository.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, 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 | library pub_server.copy_and_write_repository; 6 | 7 | import 'dart:async'; 8 | 9 | import 'package:logging/logging.dart'; 10 | import 'package:pub_server/repository.dart'; 11 | 12 | final Logger _logger = Logger('pub_server.cow_repository'); 13 | 14 | /// A [CopyAndWriteRepository] writes to one repository and directs 15 | /// read-misses to another repository. 16 | /// 17 | /// Package versions not available from the read-write repository will be 18 | /// fetched from a read-fallback repository and uploaded to the read-write 19 | /// repository. This effectively caches all packages requested through this 20 | /// [CopyAndWriteRepository]. 21 | /// 22 | /// New package versions which get uploaded will be stored only locally. 23 | class CopyAndWriteRepository extends PackageRepository { 24 | final PackageRepository local; 25 | final PackageRepository remote; 26 | final _RemoteMetadataCache _localCache; 27 | final _RemoteMetadataCache _remoteCache; 28 | final bool standalone; 29 | 30 | /// Construct a new proxy with [local] as the local [PackageRepository] which 31 | /// is used for uploading new package versions to and [remote] as the 32 | /// read-only [PackageRepository] which is consulted on misses in [local]. 33 | CopyAndWriteRepository( 34 | PackageRepository local, PackageRepository remote, bool standalone) 35 | : local = local, 36 | remote = remote, 37 | standalone = standalone, 38 | _localCache = _RemoteMetadataCache(local), 39 | _remoteCache = _RemoteMetadataCache(remote); 40 | 41 | @override 42 | Stream versions(String package) { 43 | StreamController controller; 44 | void onListen() { 45 | var waitList = [_localCache.fetchVersionlist(package)]; 46 | if (standalone != true) { 47 | waitList.add(_remoteCache.fetchVersionlist(package)); 48 | } 49 | Future.wait(waitList).then((tuple) { 50 | var versions = {}..addAll(tuple[0]); 51 | if (standalone != true) { 52 | versions.addAll(tuple[1]); 53 | } 54 | for (var version in versions) { 55 | controller.add(version); 56 | } 57 | controller.close(); 58 | }); 59 | } 60 | 61 | controller = StreamController(onListen: onListen); 62 | return controller.stream; 63 | } 64 | 65 | @override 66 | Future lookupVersion(String package, String version) { 67 | return versions(package) 68 | .where((pv) => pv.versionString == version) 69 | .toList() 70 | .then((List versions) { 71 | if (versions.isNotEmpty) return versions.first; 72 | return null; 73 | }); 74 | } 75 | 76 | @override 77 | Future>> download(String package, String version) async { 78 | var packageVersion = await local.lookupVersion(package, version); 79 | 80 | if (packageVersion != null) { 81 | _logger.info('Serving $package/$version from local repository.'); 82 | return local.download(package, packageVersion.versionString); 83 | } else { 84 | // We first download the package from the remote repository and store 85 | // it locally. Then we read the local version and return it. 86 | 87 | _logger.info('Downloading $package/$version from remote repository.'); 88 | var stream = await remote.download(package, version); 89 | 90 | _logger.info('Upload $package/$version to local repository.'); 91 | await local.upload(stream); 92 | 93 | _logger.info('Serving $package/$version from local repository.'); 94 | return local.download(package, version); 95 | } 96 | } 97 | 98 | @override 99 | bool get supportsUpload => true; 100 | 101 | @override 102 | Future upload(Stream> data) async { 103 | _logger.info('Starting upload to local package repository.'); 104 | final pkgVersion = await local.upload(data); 105 | // TODO: It's not really necessary to invalidate all. 106 | _logger.info( 107 | 'Upload finished - ${pkgVersion.packageName}@${pkgVersion.version}. ' 108 | 'Invalidating in-memory cache.'); 109 | _localCache.invalidateAll(); 110 | return pkgVersion; 111 | } 112 | 113 | @override 114 | bool get supportsAsyncUpload => false; 115 | } 116 | 117 | /// A cache for [PackageVersion] objects for a given `package`. 118 | /// 119 | /// The constructor takes a [PackageRepository] which will be used to populate 120 | /// the cache. 121 | class _RemoteMetadataCache { 122 | final PackageRepository remote; 123 | 124 | final Map> _versions = {}; 125 | final Map>> _versionCompleters = {}; 126 | 127 | _RemoteMetadataCache(this.remote); 128 | 129 | // TODO: After a cache expiration we should invalidate entries and re-fetch 130 | // them. 131 | Future> fetchVersionlist(String package) { 132 | return _versionCompleters 133 | .putIfAbsent(package, () { 134 | var c = Completer>(); 135 | 136 | _versions.putIfAbsent(package, () => {}); 137 | remote.versions(package).toList().then((versions) { 138 | _versions[package].addAll(versions); 139 | c.complete(_versions[package]); 140 | }); 141 | 142 | return c; 143 | }) 144 | .future 145 | .then((set) => set.toList()); 146 | } 147 | 148 | void addVersion(String package, PackageVersion version) { 149 | _versions 150 | .putIfAbsent(version.packageName, () => {}) 151 | .add(version); 152 | } 153 | 154 | void invalidateAll() { 155 | _versionCompleters.clear(); 156 | _versions.clear(); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /example/src/examples/file_repository.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, 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 'dart:async'; 6 | import 'dart:convert' as convert; 7 | import 'dart:io'; 8 | 9 | import 'package:archive/archive.dart'; 10 | import 'package:logging/logging.dart'; 11 | import 'package:path/path.dart' as p; 12 | import 'package:pub_server/repository.dart'; 13 | import 'package:yaml/yaml.dart'; 14 | 15 | final Logger _logger = Logger('pub_server.file_repository'); 16 | 17 | /// Implements the [PackageRepository] by storing pub packages on a file system. 18 | class FileRepository extends PackageRepository { 19 | final String baseDir; 20 | 21 | FileRepository(this.baseDir); 22 | 23 | @override 24 | Stream versions(String package) { 25 | var directory = Directory(p.join(baseDir, package)); 26 | if (directory.existsSync()) { 27 | return directory 28 | .list(recursive: false) 29 | .where((fse) => fse is Directory) 30 | .map((dir) { 31 | var version = p.basename(dir.path); 32 | var pubspecFile = File(pubspecFilePath(package, version)); 33 | var tarballFile = File(packageTarballPath(package, version)); 34 | if (pubspecFile.existsSync() && tarballFile.existsSync()) { 35 | var pubspec = pubspecFile.readAsStringSync(); 36 | return PackageVersion(package, version, pubspec); 37 | } 38 | return null; 39 | }).where((e) => e != null); 40 | } 41 | 42 | return Stream.fromIterable([]); 43 | } 44 | 45 | // TODO: Could be optimized by searching for the exact package/version 46 | // combination instead of enumerating all. 47 | @override 48 | Future lookupVersion(String package, String version) { 49 | return versions(package) 50 | .where((pv) => pv.versionString == version) 51 | .toList() 52 | .then((List versions) { 53 | if (versions.isNotEmpty) return versions.first; 54 | return null; 55 | }); 56 | } 57 | 58 | @override 59 | bool get supportsUpload => true; 60 | 61 | @override 62 | Future upload(Stream> data) async { 63 | _logger.info('Start uploading package.'); 64 | var bb = await data.fold( 65 | BytesBuilder(), (BytesBuilder byteBuilder, d) => byteBuilder..add(d)); 66 | var tarballBytes = bb.takeBytes(); 67 | var tarBytes = GZipDecoder().decodeBytes(tarballBytes); 68 | var archive = TarDecoder().decodeBytes(tarBytes); 69 | ArchiveFile pubspecArchiveFile; 70 | for (var file in archive.files) { 71 | if (file.name == 'pubspec.yaml') { 72 | pubspecArchiveFile = file; 73 | break; 74 | } 75 | } 76 | 77 | if (pubspecArchiveFile == null) { 78 | throw 'Did not find any pubspec.yaml file in upload. Aborting.'; 79 | } 80 | 81 | // TODO: Error handling. 82 | var pubspec = loadYaml(convert.utf8.decode(_getBytes(pubspecArchiveFile))); 83 | 84 | var package = pubspec['name'] as String; 85 | var version = pubspec['version'] as String; 86 | 87 | var packageVersionDir = Directory(p.join(baseDir, package, version)); 88 | 89 | if (!packageVersionDir.existsSync()) { 90 | packageVersionDir.createSync(recursive: true); 91 | } 92 | 93 | var pubspecFile = File(pubspecFilePath(package, version)); 94 | if (pubspecFile.existsSync()) { 95 | throw StateError('`$package` already exists at version `$version`.'); 96 | } 97 | 98 | var pubspecContent = convert.utf8.decode(_getBytes(pubspecArchiveFile)); 99 | pubspecFile.writeAsStringSync(pubspecContent); 100 | File(packageTarballPath(package, version)).writeAsBytesSync(tarballBytes); 101 | 102 | _logger.info('Uploaded new $package/$version'); 103 | 104 | return PackageVersion(package, version, pubspecContent); 105 | } 106 | 107 | @override 108 | bool get supportsDownloadUrl => false; 109 | 110 | @override 111 | Future>> download(String package, String version) async { 112 | var pubspecFile = File(pubspecFilePath(package, version)); 113 | var tarballFile = File(packageTarballPath(package, version)); 114 | 115 | if (pubspecFile.existsSync() && tarballFile.existsSync()) { 116 | return tarballFile.openRead(); 117 | } else { 118 | throw 'package cannot be downloaded, because it does not exist'; 119 | } 120 | } 121 | 122 | String pubspecFilePath(String package, String version) => 123 | p.join(baseDir, package, version, 'pubspec.yaml'); 124 | 125 | String packageTarballPath(String package, String version) => 126 | p.join(baseDir, package, version, 'package.tar.gz'); 127 | } 128 | 129 | // Since pkg/archive v1.0.31, content is `dynamic` although in our use case 130 | // it's always `List` 131 | List _getBytes(ArchiveFile file) => file.content as List; 132 | -------------------------------------------------------------------------------- /example/src/examples/http_proxy_repository.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, 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 'dart:async'; 6 | 7 | import 'dart:convert' as convert; 8 | import 'package:http/http.dart' as http; 9 | import 'package:logging/logging.dart'; 10 | import 'package:pub_server/repository.dart'; 11 | 12 | final Logger _logger = Logger('pub_server.http_proxy_repository'); 13 | 14 | /// Implements the [PackageRepository] by talking to a remote HTTP server via 15 | /// the pub HTTP API. 16 | /// 17 | /// This [PackageRepository] does not support uploading so far. 18 | class HttpProxyRepository extends PackageRepository { 19 | final http.Client client; 20 | final Uri baseUrl; 21 | 22 | HttpProxyRepository(this.client, this.baseUrl); 23 | 24 | @override 25 | Stream versions(String package) async* { 26 | var versionUrl = 27 | baseUrl.resolve('/api/packages/${Uri.encodeComponent(package)}'); 28 | 29 | var response = await client.get(versionUrl); 30 | 31 | if (response.statusCode != 200) { 32 | return; 33 | } 34 | 35 | var json = convert.json.decode(response.body); 36 | var versions = json['versions'] as List; 37 | if (versions != null) { 38 | for (var item in versions) { 39 | var pubspec = item['pubspec']; 40 | var pubspecString = convert.json.encode(pubspec); 41 | yield PackageVersion(pubspec['name'] as String, 42 | pubspec['version'] as String, pubspecString); 43 | } 44 | } 45 | } 46 | 47 | // TODO: Could be optimized, since we don't need to list all versions and can 48 | // just talk to the HTTP endpoint which gives us a specific package/version 49 | // combination. 50 | @override 51 | Future lookupVersion(String package, String version) { 52 | return versions(package) 53 | .where((v) => v.packageName == package && v.versionString == version) 54 | .toList() 55 | .then((List versions) { 56 | if (versions.isNotEmpty) return versions.first; 57 | return null; 58 | }); 59 | } 60 | 61 | @override 62 | bool get supportsUpload => false; 63 | 64 | @override 65 | bool get supportsAsyncUpload => false; 66 | 67 | @override 68 | bool get supportsDownloadUrl => true; 69 | 70 | @override 71 | Future downloadUrl(String package, String version) async { 72 | package = Uri.encodeComponent(package); 73 | version = Uri.encodeComponent(version); 74 | return baseUrl.resolve('/packages/$package/versions/$version.tar.gz'); 75 | } 76 | 77 | @override 78 | Future>> download(String package, String version) async { 79 | _logger.info('Downloading package $package/$version.'); 80 | 81 | var url = await downloadUrl(package, version); 82 | var response = await client.send(http.Request('GET', url)); 83 | return response.stream; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /lib/repository.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, 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 | library pub_server.repository; 6 | 7 | import 'dart:async'; 8 | 9 | import 'package:pub_semver/pub_semver.dart'; 10 | 11 | /// Represents information about a specific version of a pub package. 12 | class PackageVersion { 13 | /// The name of the package. 14 | final String packageName; 15 | 16 | /// The version string of the package. 17 | final String versionString; 18 | 19 | /// The pubspec yaml file of the package 20 | final String pubspecYaml; 21 | 22 | Version _cached; 23 | 24 | /// The version of the package as a [Version] object. 25 | Version get version { 26 | if (_cached != null) return _cached; 27 | _cached = Version.parse(versionString); 28 | return _cached; 29 | } 30 | 31 | PackageVersion(this.packageName, this.versionString, this.pubspecYaml); 32 | 33 | @override 34 | int get hashCode => 35 | packageName.hashCode ^ versionString.hashCode ^ pubspecYaml.hashCode; 36 | 37 | @override 38 | bool operator ==(other) { 39 | return other is PackageVersion && 40 | other.packageName == packageName && 41 | other.versionString == versionString && 42 | other.pubspecYaml == pubspecYaml; 43 | } 44 | 45 | @override 46 | String toString() => 'PackageVersion: $packageName/$versionString'; 47 | } 48 | 49 | /// Information obtained when starting an asynchronous upload. 50 | class AsyncUploadInfo { 51 | /// The endpoint where the uploaded data should be posted. 52 | /// 53 | /// The upload is a POST to [uri] with the headers [fields] in the HTTP 54 | /// request. The body of the POST request must be a valid tar.gz file. 55 | final Uri uri; 56 | 57 | /// The fields the uploader should add to the multipart upload. 58 | final Map fields; 59 | 60 | AsyncUploadInfo(this.uri, this.fields); 61 | } 62 | 63 | /// A marker interface that indicates a problem with the client-provided inputs. 64 | abstract class ClientSideProblem implements Exception {} 65 | 66 | /// Exception for unauthorized access attempts. 67 | /// 68 | /// Uploading a new package from an unauthorized user will result in an 69 | /// [UnauthorizedAccessException] exception. 70 | class UnauthorizedAccessException implements ClientSideProblem, Exception { 71 | final String message; 72 | 73 | UnauthorizedAccessException(this.message); 74 | 75 | @override 76 | String toString() => 'UnauthorizedAccess: $message'; 77 | } 78 | 79 | /// Exception for removing the last uploader. 80 | /// 81 | /// Removing the last user-email of a package can result in a 82 | /// [UnauthorizedAccessException] exception. 83 | class LastUploaderRemoveException implements ClientSideProblem, Exception { 84 | LastUploaderRemoveException(); 85 | 86 | @override 87 | String toString() => 'LastUploaderRemoved: Cannot remove last uploader of a ' 88 | 'package.'; 89 | } 90 | 91 | /// Exception for adding an already-existent uploader. 92 | /// 93 | /// Removing the last user-email of a package can result in a 94 | /// [UnauthorizedAccessException] exception. 95 | class UploaderAlreadyExistsException implements ClientSideProblem, Exception { 96 | UploaderAlreadyExistsException(); 97 | 98 | @override 99 | String toString() => 'UploaderAlreadyExists: Cannot add an already existent ' 100 | 'uploader.'; 101 | } 102 | 103 | /// Generic exception during processing of the clients request. 104 | /// 105 | /// This may be an issue during validation of `pubspec.yaml`. 106 | class GenericProcessingException implements ClientSideProblem, Exception { 107 | final String message; 108 | 109 | GenericProcessingException(this.message); 110 | 111 | factory GenericProcessingException.validationError(String message) => 112 | GenericProcessingException('ValidationError: $message'); 113 | 114 | @override 115 | String toString() => message; 116 | } 117 | 118 | /// Represents a pub repository. 119 | abstract class PackageRepository { 120 | /// Returns the known versions of [package]. 121 | Stream versions(String package); 122 | 123 | /// Whether the [version] of [package] exists. 124 | Future lookupVersion(String package, String version); 125 | 126 | /// Whether this package repository supports uploading packages. 127 | bool get supportsUpload => false; 128 | 129 | /// Uploads a new pub package. 130 | /// 131 | /// [data] must be a stream of a valid .tar.gz file. 132 | Future upload(Stream> data) async => 133 | throw UnsupportedError('No upload support.'); 134 | 135 | /// Whether this package repository supports asynchronous uploads. 136 | bool get supportsAsyncUpload => false; 137 | 138 | /// Starts a new upload. 139 | /// 140 | /// The given [redirectUrl] instructs the uploading client to make a GET 141 | /// request to this location once the upload is complete. It might contain 142 | /// additional query parameters and must be supplied to `finishAsyncUpload`. 143 | /// 144 | /// The returned [AsyncUploadInfo] specifies where the tar.gz file should be 145 | /// posted to and what headers should be supplied. 146 | Future startAsyncUpload(Uri redirectUrl) async => 147 | throw UnsupportedError('No async upload support.'); 148 | 149 | /// Finishes the upload of a package. 150 | Future finishAsyncUpload(Uri uri) async => 151 | throw UnsupportedError('No async upload support.'); 152 | 153 | /// Downloads a pub package. 154 | Future>> download(String package, String version); 155 | 156 | /// Whether this package repository supports download URLs. 157 | bool get supportsDownloadUrl => false; 158 | 159 | /// A permanent download URL to a package (if supported). 160 | Future downloadUrl(String package, String version) async => 161 | throw UnsupportedError('No download link support.'); 162 | 163 | /// Whether this package repository supports adding/removing users. 164 | bool get supportsUploaders => false; 165 | 166 | /// Adds [userEmail] as an uploader to [package]. 167 | Future addUploader(String package, String userEmail) async => 168 | throw UnsupportedError('No uploader support.'); 169 | 170 | /// Removes [userEmail] as an uploader from [package]. 171 | Future removeUploader(String package, String userEmail) async => 172 | throw UnsupportedError('No uploader support.'); 173 | } 174 | -------------------------------------------------------------------------------- /lib/shelf_pubserver.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014, 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 'dart:async'; 6 | import 'dart:convert' as convert; 7 | 8 | import 'package:http_parser/http_parser.dart'; 9 | import 'package:logging/logging.dart'; 10 | import 'package:mime/mime.dart'; 11 | import 'package:pub_semver/pub_semver.dart' as semver; 12 | import 'package:shelf/shelf.dart' as shelf; 13 | import 'package:yaml/yaml.dart'; 14 | 15 | import 'repository.dart'; 16 | 17 | final Logger _logger = Logger('pubserver.shelf_pubserver'); 18 | 19 | // TODO: Error handling from [PackageRepo] class. 20 | // Distinguish between: 21 | // - Unauthorized Error 22 | // - Version Already Exists Error 23 | // - Internal Server Error 24 | /// A shelf handler for serving a pub [PackageRepository]. 25 | /// 26 | /// The following API endpoints are provided by this shelf handler: 27 | /// 28 | /// * Getting information about all versions of a package. 29 | /// 30 | /// GET /api/packages/ 31 | /// [200 OK] [Content-Type: application/json] 32 | /// { 33 | /// "name" : "", 34 | /// "latest" : { ...}, 35 | /// "versions" : [ 36 | /// { 37 | /// "version" : "", 38 | /// "archive_url" : "", 39 | /// "pubspec" : { 40 | /// "author" : ..., 41 | /// "dependencies" : { ... }, 42 | /// ... 43 | /// }, 44 | /// }, 45 | /// ... 46 | /// ], 47 | /// } 48 | /// or 49 | /// [404 Not Found] 50 | /// 51 | /// * Getting information about a specific (package, version) pair. 52 | /// 53 | /// GET /api/packages//versions/ 54 | /// [200 OK] [Content-Type: application/json] 55 | /// { 56 | /// "version" : "", 57 | /// "archive_url" : "", 58 | /// "pubspec" : { 59 | /// "author" : ..., 60 | /// "dependencies" : { ... }, 61 | /// ... 62 | /// }, 63 | /// } 64 | /// or 65 | /// [404 Not Found] 66 | /// 67 | /// * Downloading package. 68 | /// 69 | /// GET /api/packages//versions/.tar.gz 70 | /// [200 OK] [Content-Type: octet-stream ??? FIXME ???] 71 | /// or 72 | /// [302 Found / Temporary Redirect] 73 | /// Location: 74 | /// or 75 | /// [404 Not Found] 76 | /// 77 | /// * Uploading 78 | /// 79 | /// GET /api/packages/versions/new 80 | /// Headers: 81 | /// Authorization: Bearer 82 | /// [200 OK] 83 | /// { 84 | /// "fields" : { 85 | /// "a": "...", 86 | /// "b": "...", 87 | /// ... 88 | /// }, 89 | /// "url" : "https://storage.googleapis.com" 90 | /// } 91 | /// 92 | /// POST "https://storage.googleapis.com" 93 | /// Headers: 94 | /// a: ... 95 | /// b: ... 96 | /// ... 97 | /// file package.tar.gz 98 | /// [302 Found / Temporary Redirect] 99 | /// Location: https://pub.dartlang.org/finishUploadUrl 100 | /// 101 | /// GET https://pub.dartlang.org/finishUploadUrl 102 | /// [200 OK] 103 | /// { 104 | /// "success" : { 105 | /// "message": "Successfully uploaded package.", 106 | /// }, 107 | /// } 108 | /// 109 | /// * Adding a new uploader 110 | /// 111 | /// POST /api/packages//uploaders 112 | /// email= 113 | /// 114 | /// [200 OK] [Content-Type: application/json] 115 | /// or 116 | /// [400 Client Error] 117 | /// 118 | /// * Removing an existing uploader. 119 | /// 120 | /// DELETE /api/packages//uploaders/ 121 | /// [200 OK] [Content-Type: application/json] 122 | /// or 123 | /// [400 Client Error] 124 | /// 125 | /// 126 | /// It will use the pub [PackageRepository] given in the constructor to provide 127 | /// this HTTP endpoint. 128 | class ShelfPubServer { 129 | static final RegExp _packageRegexp = RegExp(r'^/api/packages/([^/]+)$'); 130 | 131 | static final RegExp _versionRegexp = 132 | RegExp(r'^/api/packages/([^/]+)/versions/([^/]+)$'); 133 | 134 | static final RegExp _addUploaderRegexp = 135 | RegExp(r'^/api/packages/([^/]+)/uploaders$'); 136 | 137 | static final RegExp _removeUploaderRegexp = 138 | RegExp(r'^/api/packages/([^/]+)/uploaders/([^/]+)$'); 139 | 140 | static final RegExp _downloadRegexp = 141 | RegExp(r'^/packages/([^/]+)/versions/([^/]+)\.tar\.gz$'); 142 | 143 | final PackageRepository repository; 144 | final PackageCache cache; 145 | 146 | ShelfPubServer(this.repository, {this.cache}); 147 | 148 | Future requestHandler(shelf.Request request) async { 149 | var path = request.requestedUri.path; 150 | if (request.method == 'GET') { 151 | var downloadMatch = _downloadRegexp.matchAsPrefix(path); 152 | if (downloadMatch != null) { 153 | var package = Uri.decodeComponent(downloadMatch.group(1)); 154 | var version = Uri.decodeComponent(downloadMatch.group(2)); 155 | if (!isSemanticVersion(version)) return _invalidVersion(version); 156 | return _download(request.requestedUri, package, version); 157 | } 158 | 159 | var packageMatch = _packageRegexp.matchAsPrefix(path); 160 | if (packageMatch != null) { 161 | var package = Uri.decodeComponent(packageMatch.group(1)); 162 | return _listVersions(request.requestedUri, package); 163 | } 164 | 165 | var versionMatch = _versionRegexp.matchAsPrefix(path); 166 | if (versionMatch != null) { 167 | var package = Uri.decodeComponent(versionMatch.group(1)); 168 | var version = Uri.decodeComponent(versionMatch.group(2)); 169 | if (!isSemanticVersion(version)) return _invalidVersion(version); 170 | return _showVersion(request.requestedUri, package, version); 171 | } 172 | 173 | if (path == '/api/packages/versions/new') { 174 | if (!repository.supportsUpload) { 175 | return shelf.Response.notFound(null); 176 | } 177 | 178 | if (repository.supportsAsyncUpload) { 179 | return _startUploadAsync(request.requestedUri); 180 | } else { 181 | return _startUploadSimple(request.requestedUri); 182 | } 183 | } 184 | 185 | if (path == '/api/packages/versions/newUploadFinish') { 186 | if (!repository.supportsUpload) { 187 | return shelf.Response.notFound(null); 188 | } 189 | 190 | if (repository.supportsAsyncUpload) { 191 | return _finishUploadAsync(request.requestedUri); 192 | } else { 193 | return _finishUploadSimple(request.requestedUri); 194 | } 195 | } 196 | } else if (request.method == 'POST') { 197 | if (path == '/api/packages/versions/newUpload') { 198 | if (!repository.supportsUpload) { 199 | return shelf.Response.notFound(null); 200 | } 201 | 202 | return _uploadSimple(request.requestedUri, 203 | request.headers['content-type'], request.read()); 204 | } else { 205 | if (!repository.supportsUploaders) { 206 | return shelf.Response.notFound(null); 207 | } 208 | 209 | var addUploaderMatch = _addUploaderRegexp.matchAsPrefix(path); 210 | if (addUploaderMatch != null) { 211 | var package = Uri.decodeComponent(addUploaderMatch.group(1)); 212 | return request.readAsString().then((String body) { 213 | return _addUploader(package, body); 214 | }); 215 | } 216 | } 217 | } else if (request.method == 'DELETE') { 218 | if (!repository.supportsUploaders) { 219 | return shelf.Response.notFound(null); 220 | } 221 | 222 | var removeUploaderMatch = _removeUploaderRegexp.matchAsPrefix(path); 223 | if (removeUploaderMatch != null) { 224 | var package = Uri.decodeComponent(removeUploaderMatch.group(1)); 225 | var user = Uri.decodeComponent(removeUploaderMatch.group(2)); 226 | return removeUploader(package, user); 227 | } 228 | } 229 | return shelf.Response.notFound(null); 230 | } 231 | 232 | // Metadata handlers. 233 | 234 | Future _listVersions(Uri uri, String package) async { 235 | if (cache != null) { 236 | var binaryJson = await cache.getPackageData(package); 237 | if (binaryJson != null) { 238 | return _binaryJsonResponse(binaryJson); 239 | } 240 | } 241 | 242 | var packageVersions = await repository.versions(package).toList(); 243 | if (packageVersions.isEmpty) { 244 | return shelf.Response.notFound(null); 245 | } 246 | 247 | packageVersions.sort((a, b) => a.version.compareTo(b.version)); 248 | 249 | // TODO: Add legacy entries (if necessary), such as version_url. 250 | Map packageVersion2Json(PackageVersion version) { 251 | return { 252 | 'archive_url': 253 | '${_downloadUrl(uri, version.packageName, version.versionString)}', 254 | 'pubspec': loadYaml(version.pubspecYaml), 255 | 'version': version.versionString, 256 | }; 257 | } 258 | 259 | var latestVersion = packageVersions.last; 260 | for (var i = packageVersions.length - 1; i >= 0; i--) { 261 | if (!packageVersions[i].version.isPreRelease) { 262 | latestVersion = packageVersions[i]; 263 | break; 264 | } 265 | } 266 | 267 | // TODO: The 'latest' is something we should get rid of, since it's 268 | // duplicated in 'versions'. 269 | var binaryJson = convert.json.encoder.fuse(convert.utf8.encoder).convert({ 270 | 'name': package, 271 | 'latest': packageVersion2Json(latestVersion), 272 | 'versions': packageVersions.map(packageVersion2Json).toList(), 273 | }); 274 | if (cache != null) { 275 | await cache.setPackageData(package, binaryJson); 276 | } 277 | return _binaryJsonResponse(binaryJson); 278 | } 279 | 280 | Future _showVersion( 281 | Uri uri, String package, String version) async { 282 | var ver = await repository.lookupVersion(package, version); 283 | if (ver == null) { 284 | return shelf.Response.notFound(null); 285 | } 286 | 287 | // TODO: Add legacy entries (if necessary), such as version_url. 288 | return _jsonResponse({ 289 | 'archive_url': '${_downloadUrl(uri, ver.packageName, ver.versionString)}', 290 | 'pubspec': loadYaml(ver.pubspecYaml), 291 | 'version': ver.versionString, 292 | }); 293 | } 294 | 295 | // Download handlers. 296 | 297 | Future _download( 298 | Uri uri, String package, String version) async { 299 | if (repository.supportsDownloadUrl) { 300 | var url = await repository.downloadUrl(package, version); 301 | // This is a redirect to [url] 302 | return shelf.Response.seeOther(url); 303 | } 304 | 305 | var stream = await repository.download(package, version); 306 | return shelf.Response.ok(stream); 307 | } 308 | 309 | // Upload async handlers. 310 | 311 | Future _startUploadAsync(Uri uri) async { 312 | var info = await repository.startAsyncUpload(_finishUploadAsyncUrl(uri)); 313 | return _jsonResponse({ 314 | 'url': '${info.uri}', 315 | 'fields': info.fields, 316 | }); 317 | } 318 | 319 | Future _finishUploadAsync(Uri uri) async { 320 | try { 321 | final vers = await repository.finishAsyncUpload(uri); 322 | if (cache != null) { 323 | _logger.info('Invalidating cache for package ${vers.packageName}.'); 324 | await cache.invalidatePackageData(vers.packageName); 325 | } 326 | return _jsonResponse({ 327 | 'success': { 328 | 'message': 'Successfully uploaded package.', 329 | }, 330 | }); 331 | } on ClientSideProblem catch (error, stack) { 332 | _logger.info('A problem occured while finishing upload.', error, stack); 333 | return _jsonResponse({ 334 | 'error': { 335 | 'message': '$error.', 336 | }, 337 | }, status: 400); 338 | } catch (error, stack) { 339 | _logger.warning('An error occured while finishing upload.', error, stack); 340 | return _jsonResponse({ 341 | 'error': { 342 | 'message': '$error.', 343 | }, 344 | }, status: 500); 345 | } 346 | } 347 | 348 | // Upload custom handlers. 349 | 350 | shelf.Response _startUploadSimple(Uri url) { 351 | _logger.info('Start simple upload.'); 352 | return _jsonResponse({ 353 | 'url': '${_uploadSimpleUrl(url)}', 354 | 'fields': {}, 355 | }); 356 | } 357 | 358 | Future _uploadSimple( 359 | Uri uri, String contentType, Stream> stream) async { 360 | _logger.info('Perform simple upload.'); 361 | 362 | var boundary = _getBoundary(contentType); 363 | 364 | if (boundary == null) { 365 | return _badRequest( 366 | 'Upload must contain a multipart/form-data content type.'); 367 | } 368 | 369 | // We have to listen to all multiparts: Just doing `parts.first` will 370 | // result in the cancellation of the subscription which causes 371 | // eventually a destruction of the socket, this is an odd side-effect. 372 | // What we would like to have is something like this: 373 | // parts.expect(1).then((part) { upload(part); }) 374 | MimeMultipart thePart; 375 | 376 | await for (MimeMultipart part 377 | in stream.transform(MimeMultipartTransformer(boundary))) { 378 | // If we get more than one part, we'll ignore the rest of the input. 379 | if (thePart != null) { 380 | continue; 381 | } 382 | 383 | thePart = part; 384 | } 385 | 386 | try { 387 | // TODO: Ensure that `part.headers['content-disposition']` is 388 | // `form-data; name="file"; filename="package.tar.gz` 389 | var version = await repository.upload(thePart); 390 | if (cache != null) { 391 | _logger.info('Invalidating cache for package ${version.packageName}.'); 392 | await cache.invalidatePackageData(version.packageName); 393 | } 394 | _logger.info('Redirecting to found url.'); 395 | return shelf.Response.found(_finishUploadSimpleUrl(uri)); 396 | } catch (error, stack) { 397 | _logger.warning('Error occured', error, stack); 398 | // TODO: Do error checking and return error codes? 399 | return shelf.Response.found( 400 | _finishUploadSimpleUrl(uri, error: error.toString())); 401 | } 402 | } 403 | 404 | shelf.Response _finishUploadSimple(Uri uri) { 405 | var error = uri.queryParameters['error']; 406 | if (error != null) { 407 | _logger.info('Finish simple upload (error: $error).'); 408 | return _badRequest(error); 409 | } 410 | return _jsonResponse({ 411 | 'success': {'message': 'Successfully uploaded package.'} 412 | }); 413 | } 414 | 415 | // Uploader handlers. 416 | 417 | Future _addUploader(String package, String body) async { 418 | var parts = body.split('='); 419 | if (parts.length == 2 && parts[0] == 'email' && parts[1].isNotEmpty) { 420 | try { 421 | var user = Uri.decodeQueryComponent(parts[1]); 422 | await repository.addUploader(package, user); 423 | return _successfullRequest('Successfully added uploader to package.'); 424 | } on UploaderAlreadyExistsException { 425 | return _badRequest( 426 | 'Cannot add an already-existent uploader to package.'); 427 | } on UnauthorizedAccessException { 428 | return _unauthorizedRequest(); 429 | } on GenericProcessingException catch (e) { 430 | return _badRequest(e.message); 431 | } 432 | } 433 | return _badRequest('Invalid request'); 434 | } 435 | 436 | Future removeUploader( 437 | String package, String userEmail) async { 438 | try { 439 | await repository.removeUploader(package, userEmail); 440 | return _successfullRequest('Successfully removed uploader from package.'); 441 | } on LastUploaderRemoveException { 442 | return _badRequest('Cannot remove last uploader of a package.'); 443 | } on UnauthorizedAccessException { 444 | return _unauthorizedRequest(); 445 | } on GenericProcessingException catch (e) { 446 | return _badRequest(e.message); 447 | } 448 | } 449 | 450 | // Helper functions. 451 | 452 | shelf.Response _invalidVersion(String version) => 453 | _badRequest('Version string "$version" is not a valid semantic version.'); 454 | 455 | Future _successfullRequest(String message) async { 456 | return shelf.Response(200, 457 | body: convert.json.encode({ 458 | 'success': {'message': message} 459 | }), 460 | headers: {'content-type': 'application/json'}); 461 | } 462 | 463 | shelf.Response _unauthorizedRequest() => shelf.Response(403, 464 | body: convert.json.encode({ 465 | 'error': {'message': 'Unauthorized request.'} 466 | }), 467 | headers: {'content-type': 'application/json'}); 468 | 469 | shelf.Response _badRequest(String message) => shelf.Response(400, 470 | body: convert.json.encode({ 471 | 'error': {'message': message} 472 | }), 473 | headers: {'content-type': 'application/json'}); 474 | 475 | shelf.Response _binaryJsonResponse(List d, {int status = 200}) => 476 | shelf.Response(status, 477 | body: Stream.fromIterable([d]), 478 | headers: {'content-type': 'application/json'}); 479 | 480 | shelf.Response _jsonResponse(Map json, {int status = 200}) => 481 | shelf.Response(status, 482 | body: convert.json.encode(json), 483 | headers: {'content-type': 'application/json'}); 484 | 485 | // Download urls. 486 | 487 | Uri _downloadUrl(Uri url, String package, String version) { 488 | var encode = Uri.encodeComponent; 489 | return url.resolve( 490 | '/packages/${encode(package)}/versions/${encode(version)}.tar.gz'); 491 | } 492 | 493 | // Upload async urls. 494 | 495 | Uri _finishUploadAsyncUrl(Uri url) => 496 | url.resolve('/api/packages/versions/newUploadFinish'); 497 | 498 | // Upload custom urls. 499 | 500 | Uri _uploadSimpleUrl(Uri url) => 501 | url.resolve('/api/packages/versions/newUpload'); 502 | 503 | Uri _finishUploadSimpleUrl(Uri url, {String error}) { 504 | var postfix = error == null ? '' : '?error=${Uri.encodeComponent(error)}'; 505 | return url.resolve('/api/packages/versions/newUploadFinish$postfix'); 506 | } 507 | 508 | bool isSemanticVersion(String version) { 509 | try { 510 | semver.Version.parse(version); 511 | return true; 512 | } catch (_) { 513 | return false; 514 | } 515 | } 516 | } 517 | 518 | /// A cache for storing metadata for packages. 519 | abstract class PackageCache { 520 | Future setPackageData(String package, List data); 521 | 522 | Future> getPackageData(String package); 523 | 524 | Future invalidatePackageData(String package); 525 | } 526 | 527 | String _getBoundary(String contentType) { 528 | var mediaType = MediaType.parse(contentType); 529 | 530 | if (mediaType.type == 'multipart' && mediaType.subtype == 'form-data') { 531 | return mediaType.parameters['boundary']; 532 | } 533 | return null; 534 | } 535 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: pub_server 2 | version: 0.1.6-dev 3 | author: Dart Team 4 | description: Re-usable components for creating a custom Dart package server. 5 | homepage: https://github.com/dart-lang/pub_server 6 | 7 | environment: 8 | sdk: '>=2.2.0 <3.0.0' 9 | 10 | dependencies: 11 | http_parser: ^3.0.0 12 | logging: ">=0.9.3 <1.0.0" 13 | mime: ">=0.9.3 <0.10.0" 14 | pub_semver: ^1.1.0 15 | shelf: ">=0.5.6 <0.8.0" 16 | yaml: ^2.1.2 17 | 18 | dev_dependencies: 19 | archive: ">=1.0.0 <3.0.0" 20 | args: ^1.4.0 21 | http: ^0.12.0 22 | path: ^1.5.1 23 | pedantic: ^1.2.0 24 | test: ^1.3.0 25 | -------------------------------------------------------------------------------- /test/shelf_pubserver_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015, 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 | // ignore_for_file: annotate_overrides 6 | 7 | import 'dart:async'; 8 | import 'dart:convert' as convert; 9 | 10 | import 'package:pub_server/repository.dart'; 11 | import 'package:pub_server/shelf_pubserver.dart'; 12 | import 'package:shelf/shelf.dart' as shelf; 13 | import 'package:test/test.dart'; 14 | 15 | class RepositoryMock implements PackageRepository { 16 | final ZoneBinaryCallback>, String, String> downloadFun; 17 | final ZoneBinaryCallback downloadUrlFun; 18 | final ZoneUnaryCallback finishAsyncUploadFun; 19 | final ZoneBinaryCallback lookupVersionFun; 20 | final ZoneUnaryCallback, Uri> startAsyncUploadFun; 21 | final ZoneUnaryCallback, Stream>> uploadFun; 22 | final ZoneUnaryCallback, String> versionsFun; 23 | final ZoneBinaryCallback addUploaderFun; 24 | final ZoneBinaryCallback removeUploaderFun; 25 | 26 | RepositoryMock( 27 | {this.downloadFun, 28 | this.downloadUrlFun, 29 | this.finishAsyncUploadFun, 30 | this.lookupVersionFun, 31 | this.startAsyncUploadFun, 32 | this.uploadFun, 33 | this.versionsFun, 34 | this.supportsAsyncUpload = false, 35 | this.supportsDownloadUrl = false, 36 | this.supportsUpload = false, 37 | this.addUploaderFun, 38 | this.removeUploaderFun, 39 | this.supportsUploaders = false}); 40 | 41 | Future>> download(String package, String version) async { 42 | if (downloadFun != null) return downloadFun(package, version); 43 | throw 'download'; 44 | } 45 | 46 | Future downloadUrl(String package, String version) async { 47 | if (downloadUrlFun != null) return downloadUrlFun(package, version); 48 | throw 'downloadUrl'; 49 | } 50 | 51 | Future finishAsyncUpload(Uri uri) async { 52 | if (finishAsyncUploadFun != null) return finishAsyncUploadFun(uri); 53 | throw 'finishAsyncUpload'; 54 | } 55 | 56 | Future lookupVersion(String package, String version) async { 57 | if (lookupVersionFun != null) return lookupVersionFun(package, version); 58 | throw 'lookupVersion'; 59 | } 60 | 61 | Future startAsyncUpload(Uri redirectUrl) async { 62 | if (startAsyncUploadFun != null) { 63 | return startAsyncUploadFun(redirectUrl); 64 | } 65 | throw 'startAsyncUpload'; 66 | } 67 | 68 | final bool supportsAsyncUpload; 69 | 70 | final bool supportsDownloadUrl; 71 | 72 | final bool supportsUpload; 73 | 74 | final bool supportsUploaders; 75 | 76 | Future upload(Stream> data) { 77 | if (uploadFun != null) return uploadFun(data); 78 | throw 'upload'; 79 | } 80 | 81 | Stream versions(String package) async* { 82 | if (versionsFun == null) { 83 | throw 'versions'; 84 | } 85 | 86 | yield* versionsFun(package); 87 | } 88 | 89 | Future addUploader(String package, String userEmail) { 90 | if (addUploaderFun != null) { 91 | return addUploaderFun(package, userEmail); 92 | } 93 | throw 'addUploader'; 94 | } 95 | 96 | Future removeUploader(String package, String userEmail) { 97 | if (removeUploaderFun != null) { 98 | return removeUploaderFun(package, userEmail); 99 | } 100 | throw 'removeUploader'; 101 | } 102 | } 103 | 104 | class PackageCacheMock implements PackageCache { 105 | final ZoneUnaryCallback, String> getFun; 106 | final Function setFun; 107 | final Function invalidateFun; 108 | 109 | PackageCacheMock({this.getFun, this.setFun, this.invalidateFun}); 110 | 111 | Future> getPackageData(String package) async { 112 | if (getFun != null) return getFun(package); 113 | throw 'no get function'; 114 | } 115 | 116 | Future setPackageData(String package, List data) async { 117 | if (setFun != null) return setFun(package, data); 118 | throw 'no set function'; 119 | } 120 | 121 | Future invalidatePackageData(String package) async { 122 | if (invalidateFun != null) return invalidateFun(package); 123 | throw 'no invalidate function'; 124 | } 125 | } 126 | 127 | Uri getUri(String path) => Uri.parse('http://www.example.com$path'); 128 | 129 | shelf.Request getRequest(String path) { 130 | var url = getUri(path); 131 | return shelf.Request('GET', url); 132 | } 133 | 134 | shelf.Request multipartRequest(Uri uri, List bytes) { 135 | var requestBytes = []; 136 | var boundary = 'testboundary'; 137 | 138 | requestBytes.addAll(convert.ascii.encode('--$boundary\r\n')); 139 | requestBytes.addAll( 140 | convert.ascii.encode('Content-Type: application/octet-stream\r\n')); 141 | requestBytes 142 | .addAll(convert.ascii.encode('Content-Length: ${bytes.length}\r\n')); 143 | requestBytes.addAll(convert.ascii.encode('Content-Disposition: ' 144 | 'form-data; name="file"; ' 145 | 'filename="package.tar.gz"\r\n\r\n')); 146 | requestBytes.addAll(bytes); 147 | requestBytes.addAll(convert.ascii.encode('\r\n--$boundary--\r\n')); 148 | 149 | var headers = { 150 | 'Content-Type': 'multipart/form-data; boundary="$boundary"', 151 | 'Content-Length': '${requestBytes.length}', 152 | }; 153 | 154 | var body = Stream.fromIterable([requestBytes]); 155 | return shelf.Request('POST', uri, headers: headers, body: body); 156 | } 157 | 158 | void main() { 159 | group('shelf_pubserver', () { 160 | test('invalid endpoint', () async { 161 | var mock = RepositoryMock(); 162 | var server = ShelfPubServer(mock); 163 | 164 | Future testInvalidUrl(String path) async { 165 | var request = getRequest(path); 166 | var response = await server.requestHandler(request); 167 | await response.read().drain(); 168 | expect(response.statusCode, equals(404)); 169 | } 170 | 171 | await testInvalidUrl('/foobar'); 172 | await testInvalidUrl('/api'); 173 | await testInvalidUrl('/api/'); 174 | await testInvalidUrl('/api/packages/analyzer/0.1.0'); 175 | }); 176 | 177 | group('/api/packages/', () { 178 | var expectedVersionJson = { 179 | 'pubspec': {'foo': 1}, 180 | 'version': '0.1.0', 181 | 'archive_url': '${getUri('/packages/analyzer/versions/0.1.0.tar.gz')}', 182 | }; 183 | var expectedJson = { 184 | 'name': 'analyzer', 185 | 'latest': expectedVersionJson, 186 | 'versions': [expectedVersionJson], 187 | }; 188 | 189 | test('does not exist', () async { 190 | var mock = RepositoryMock(versionsFun: (_) => Stream.fromIterable([])); 191 | var server = ShelfPubServer(mock); 192 | var request = getRequest('/api/packages/analyzer'); 193 | 194 | var response = await server.requestHandler(request); 195 | await response.read().drain(); 196 | expect(response.statusCode, equals(404)); 197 | }); 198 | 199 | test('successful retrieval of version', () async { 200 | var mock = RepositoryMock(versionsFun: (String package) { 201 | // The pubspec is invalid, but that is irrelevant for this test. 202 | var pubspec = convert.json.encode({'foo': 1}); 203 | var analyzer = PackageVersion('analyzer', '0.1.0', pubspec); 204 | return Stream.fromIterable([analyzer]); 205 | }); 206 | var server = ShelfPubServer(mock); 207 | var request = getRequest('/api/packages/analyzer'); 208 | var response = await server.requestHandler(request); 209 | var body = await response.readAsString(); 210 | 211 | expect(response.mimeType, equals('application/json')); 212 | expect(response.statusCode, equals(200)); 213 | expect(convert.json.decode(body), equals(expectedJson)); 214 | }); 215 | 216 | test('successful retrieval of version - from cache', () async { 217 | var mock = RepositoryMock(); 218 | var cacheMock = PackageCacheMock(getFun: expectAsync1((String pkg) { 219 | expect(pkg, equals('analyzer')); 220 | return convert.utf8.encode('json response'); 221 | })); 222 | var server = ShelfPubServer(mock, cache: cacheMock); 223 | var request = getRequest('/api/packages/analyzer'); 224 | var response = await server.requestHandler(request); 225 | var body = await response.readAsString(); 226 | 227 | expect(response.mimeType, equals('application/json')); 228 | expect(response.statusCode, equals(200)); 229 | expect(body, 'json response'); 230 | }); 231 | 232 | test('successful retrieval of version - populate cache', () async { 233 | var mock = RepositoryMock(versionsFun: (String package) { 234 | // The pubspec is invalid, but that is irrelevant for this test. 235 | var pubspec = convert.json.encode({'foo': 1}); 236 | var analyzer = PackageVersion('analyzer', '0.1.0', pubspec); 237 | return Stream.fromIterable([analyzer]); 238 | }); 239 | var cacheMock = PackageCacheMock(getFun: expectAsync1((String pkg) { 240 | expect(pkg, equals('analyzer')); 241 | return null; 242 | }), setFun: expectAsync2((String package, List data) { 243 | expect(package, equals('analyzer')); 244 | expect(convert.json.decode(convert.utf8.decode(data)), 245 | equals(expectedJson)); 246 | })); 247 | var server = ShelfPubServer(mock, cache: cacheMock); 248 | var request = getRequest('/api/packages/analyzer'); 249 | var response = await server.requestHandler(request); 250 | var body = await response.readAsString(); 251 | 252 | expect(response.mimeType, equals('application/json')); 253 | expect(response.statusCode, equals(200)); 254 | expect(convert.json.decode(body), equals(expectedJson)); 255 | }); 256 | }); 257 | 258 | group('/api/packages//versions/', () { 259 | test('does not exist', () async { 260 | var mock = RepositoryMock(lookupVersionFun: (_, __) => null); 261 | var server = ShelfPubServer(mock); 262 | var request = getRequest('/api/packages/analyzer/versions/0.1.0'); 263 | 264 | var response = await server.requestHandler(request); 265 | await response.read().drain(); 266 | expect(response.statusCode, equals(404)); 267 | }); 268 | 269 | test('invalid version string', () async { 270 | var mock = RepositoryMock(); 271 | var server = ShelfPubServer(mock); 272 | var request = getRequest('/api/packages/analyzer/versions/0.1.0+%40'); 273 | var response = await server.requestHandler(request); 274 | var body = await response.readAsString(); 275 | 276 | expect(response.statusCode, equals(400)); 277 | expect(convert.json.decode(body)['error']['message'], 278 | 'Version string "0.1.0+@" is not a valid semantic version.'); 279 | }); 280 | 281 | test('successful retrieval of version', () async { 282 | var mock = 283 | RepositoryMock(lookupVersionFun: (String package, String version) { 284 | // The pubspec is invalid, but that is irrelevant for this test. 285 | var pubspec = convert.json.encode({'foo': 1}); 286 | return PackageVersion(package, version, pubspec); 287 | }); 288 | var server = ShelfPubServer(mock); 289 | var request = getRequest('/api/packages/analyzer/versions/0.1.0'); 290 | var response = await server.requestHandler(request); 291 | var body = await response.readAsString(); 292 | 293 | var expectedJson = { 294 | 'pubspec': {'foo': 1}, 295 | 'version': '0.1.0', 296 | 'archive_url': 297 | '${getUri('/packages/analyzer/versions/0.1.0.tar.gz')}', 298 | }; 299 | 300 | expect(response.mimeType, equals('application/json')); 301 | expect(response.statusCode, equals(200)); 302 | expect(convert.json.decode(body), equals(expectedJson)); 303 | }); 304 | }); 305 | 306 | group('/packages//versions/.tar.gz', () { 307 | group('download', () { 308 | test('successfull redirect', () async { 309 | var mock = 310 | RepositoryMock(downloadFun: (String package, String version) { 311 | return Stream.fromIterable([ 312 | [1, 2, 3] 313 | ]); 314 | }); 315 | var server = ShelfPubServer(mock); 316 | var request = getRequest('/packages/analyzer/versions/0.1.0.tar.gz'); 317 | var response = await server.requestHandler(request); 318 | var body = await response.read().fold([], (b, d) => b..addAll(d)); 319 | 320 | expect(response.statusCode, equals(200)); 321 | expect(body, equals([1, 2, 3])); 322 | }); 323 | }); 324 | 325 | group('download url', () { 326 | test('successfull redirect', () async { 327 | var expectedUrl = 328 | Uri.parse('https://blobs.com/analyzer-0.1.0.tar.gz'); 329 | var mock = RepositoryMock( 330 | supportsDownloadUrl: true, 331 | downloadUrlFun: (String package, String version) { 332 | return expectedUrl; 333 | }); 334 | var server = ShelfPubServer(mock); 335 | var request = getRequest('/packages/analyzer/versions/0.1.0.tar.gz'); 336 | var response = await server.requestHandler(request); 337 | 338 | expect(response.statusCode, equals(303)); 339 | expect(response.headers['location'], equals('$expectedUrl')); 340 | 341 | var body = await response.readAsString(); 342 | expect(body, isEmpty); 343 | }); 344 | }); 345 | }); 346 | 347 | group('/api/packages/versions/new', () { 348 | for (var useMemcache in [false, true]) { 349 | test('async successfull use-memcache($useMemcache)', () async { 350 | var expectedUrl = Uri.parse('https://storage.googleapis.com'); 351 | var foobarUrl = Uri.parse('https://foobar.com/package/done'); 352 | var newUrl = getUri('/api/packages/versions/new'); 353 | var finishUrl = getUri('/api/packages/versions/newUploadFinish'); 354 | var mock = RepositoryMock( 355 | supportsUpload: true, 356 | supportsAsyncUpload: true, 357 | startAsyncUploadFun: (Uri redirectUri) async { 358 | expect(redirectUri, equals(finishUrl)); 359 | return AsyncUploadInfo(expectedUrl, {'a': '$foobarUrl'}); 360 | }, 361 | finishAsyncUploadFun: (Uri uri) { 362 | expect('$uri', equals('$finishUrl')); 363 | return PackageVersion('foobar', '0.1.0', ''); 364 | }); 365 | PackageCacheMock cacheMock; 366 | if (useMemcache) { 367 | cacheMock = 368 | PackageCacheMock(invalidateFun: expectAsync1((String package) { 369 | expect(package, equals('foobar')); 370 | })); 371 | } 372 | 373 | var server = ShelfPubServer(mock, cache: cacheMock); 374 | 375 | // Start upload 376 | var request = shelf.Request('GET', newUrl); 377 | var response = await server.requestHandler(request); 378 | 379 | expect(response.statusCode, equals(200)); 380 | expect(response.headers['content-type'], equals('application/json')); 381 | 382 | var jsonBody = convert.json.decode(await response.readAsString()); 383 | expect( 384 | jsonBody, 385 | equals({ 386 | 'url': '$expectedUrl', 387 | 'fields': { 388 | 'a': '$foobarUrl', 389 | }, 390 | })); 391 | 392 | // We would do now a multipart POST to `expectedUrl` which would 393 | // redirect us back to the pub.dartlang.org app via `finishUrl`. 394 | 395 | // Call the `finishUrl`. 396 | request = shelf.Request('GET', finishUrl); 397 | response = await server.requestHandler(request); 398 | jsonBody = convert.json.decode(await response.readAsString()); 399 | expect( 400 | jsonBody, 401 | equals({ 402 | 'success': {'message': 'Successfully uploaded package.'}, 403 | })); 404 | }); 405 | } 406 | 407 | for (var useMemcache in [false, true]) { 408 | test('sync successfull use-memcache($useMemcache)', () async { 409 | var tarballBytes = const [1, 2, 3]; 410 | var newUrl = getUri('/api/packages/versions/new'); 411 | var uploadUrl = getUri('/api/packages/versions/newUpload'); 412 | var finishUrl = getUri('/api/packages/versions/newUploadFinish'); 413 | var mock = RepositoryMock( 414 | supportsUpload: true, 415 | uploadFun: (Stream> stream) { 416 | return stream.fold([], (b, d) => b..addAll(d)).then((d) { 417 | expect(d, equals(tarballBytes)); 418 | return PackageVersion('foobar', '0.1.0', ''); 419 | }); 420 | }); 421 | PackageCacheMock cacheMock; 422 | if (useMemcache) { 423 | cacheMock = 424 | PackageCacheMock(invalidateFun: expectAsync1((String package) { 425 | expect(package, equals('foobar')); 426 | })); 427 | } 428 | var server = ShelfPubServer(mock, cache: cacheMock); 429 | 430 | // Start upload 431 | var request = shelf.Request('GET', newUrl); 432 | var response = await server.requestHandler(request); 433 | expect(response.statusCode, equals(200)); 434 | expect(response.headers['content-type'], equals('application/json')); 435 | var jsonBody = convert.json.decode(await response.readAsString()); 436 | expect( 437 | jsonBody, 438 | equals({ 439 | 'url': '$uploadUrl', 440 | 'fields': {}, 441 | })); 442 | 443 | // Post data via a multipart request. 444 | request = multipartRequest(uploadUrl, tarballBytes); 445 | response = await server.requestHandler(request); 446 | await response.read().drain(); 447 | expect(response.statusCode, equals(302)); 448 | expect(response.headers['location'], equals('$finishUrl')); 449 | 450 | // Call the `finishUrl`. 451 | request = shelf.Request('GET', finishUrl); 452 | response = await server.requestHandler(request); 453 | jsonBody = convert.json.decode(await response.readAsString()); 454 | expect( 455 | jsonBody, 456 | equals({ 457 | 'success': {'message': 'Successfully uploaded package.'}, 458 | })); 459 | }); 460 | } 461 | 462 | test('sync failure', () async { 463 | var tarballBytes = const [1, 2, 3]; 464 | var uploadUrl = getUri('/api/packages/versions/newUpload'); 465 | var finishUrl = 466 | getUri('/api/packages/versions/newUploadFinish?error=abc'); 467 | var mock = RepositoryMock( 468 | supportsUpload: true, 469 | uploadFun: (Stream> stream) async { 470 | throw 'abc'; 471 | }); 472 | var server = ShelfPubServer(mock); 473 | 474 | // Start upload - would happen here. 475 | 476 | // Post data via a multipart request. 477 | var request = multipartRequest(uploadUrl, tarballBytes); 478 | var response = await server.requestHandler(request); 479 | await response.read().drain(); 480 | expect(response.statusCode, equals(302)); 481 | expect(response.headers['location'], equals('$finishUrl')); 482 | 483 | // Call the `finishUrl`. 484 | request = shelf.Request('GET', finishUrl); 485 | response = await server.requestHandler(request); 486 | var jsonBody = convert.json.decode(await response.readAsString()); 487 | expect( 488 | jsonBody, 489 | equals({ 490 | 'error': {'message': 'abc'}, 491 | })); 492 | }); 493 | 494 | test('unsupported', () async { 495 | var newUrl = getUri('/api/packages/versions/new'); 496 | var mock = RepositoryMock(); 497 | var server = ShelfPubServer(mock); 498 | var request = shelf.Request('GET', newUrl); 499 | var response = await server.requestHandler(request); 500 | 501 | expect(response.statusCode, equals(404)); 502 | }); 503 | }); 504 | 505 | group('uploaders', () { 506 | group('add uploader', () { 507 | var url = getUri('/api/packages/pkg/uploaders'); 508 | var formEncodedBody = 'email=hans'; 509 | 510 | test('no support', () async { 511 | var mock = RepositoryMock(); 512 | var server = ShelfPubServer(mock); 513 | var request = shelf.Request('POST', url, body: formEncodedBody); 514 | var response = await server.requestHandler(request); 515 | 516 | expect(response.statusCode, equals(404)); 517 | }); 518 | 519 | test('success', () async { 520 | var mock = RepositoryMock( 521 | supportsUploaders: true, 522 | addUploaderFun: expectAsync2((package, user) { 523 | expect(package, equals('pkg')); 524 | expect(user, equals('hans')); 525 | return null; 526 | })); 527 | 528 | var server = ShelfPubServer(mock); 529 | var request = shelf.Request('POST', url, body: formEncodedBody); 530 | var response = await server.requestHandler(request); 531 | 532 | expect(response.statusCode, equals(200)); 533 | }); 534 | 535 | test('already exists', () async { 536 | var mock = RepositoryMock( 537 | supportsUploaders: true, 538 | addUploaderFun: (package, user) { 539 | throw UploaderAlreadyExistsException(); 540 | }); 541 | 542 | var server = ShelfPubServer(mock); 543 | var request = shelf.Request('POST', url, body: formEncodedBody); 544 | var response = await server.requestHandler(request); 545 | 546 | expect(response.statusCode, equals(400)); 547 | }); 548 | 549 | test('unauthorized', () async { 550 | var mock = RepositoryMock( 551 | supportsUploaders: true, 552 | addUploaderFun: (package, user) { 553 | throw UnauthorizedAccessException(''); 554 | }); 555 | 556 | var server = ShelfPubServer(mock); 557 | var request = shelf.Request('POST', url, body: formEncodedBody); 558 | var response = await server.requestHandler(request); 559 | 560 | expect(response.statusCode, equals(403)); 561 | }); 562 | }); 563 | 564 | group('remove uploader', () { 565 | var url = getUri('/api/packages/pkg/uploaders/hans'); 566 | 567 | test('no support', () async { 568 | var mock = RepositoryMock(); 569 | var server = ShelfPubServer(mock); 570 | var request = shelf.Request('DELETE', url); 571 | var response = await server.requestHandler(request); 572 | 573 | expect(response.statusCode, equals(404)); 574 | }); 575 | 576 | test('success', () async { 577 | var mock = RepositoryMock( 578 | supportsUploaders: true, 579 | removeUploaderFun: expectAsync2((package, user) { 580 | expect(package, equals('pkg')); 581 | expect(user, equals('hans')); 582 | return null; 583 | })); 584 | 585 | var server = ShelfPubServer(mock); 586 | var request = shelf.Request('DELETE', url); 587 | var response = await server.requestHandler(request); 588 | 589 | expect(response.statusCode, equals(200)); 590 | }); 591 | 592 | test('cannot remove last uploader', () async { 593 | var mock = RepositoryMock( 594 | supportsUploaders: true, 595 | removeUploaderFun: (package, user) { 596 | throw LastUploaderRemoveException(); 597 | }); 598 | 599 | var server = ShelfPubServer(mock); 600 | var request = shelf.Request('DELETE', url); 601 | var response = await server.requestHandler(request); 602 | 603 | expect(response.statusCode, equals(400)); 604 | }); 605 | 606 | test('unauthorized', () async { 607 | var mock = RepositoryMock( 608 | supportsUploaders: true, 609 | removeUploaderFun: (package, user) { 610 | throw UnauthorizedAccessException(''); 611 | }); 612 | 613 | var server = ShelfPubServer(mock); 614 | var request = shelf.Request('DELETE', url); 615 | var response = await server.requestHandler(request); 616 | 617 | expect(response.statusCode, equals(403)); 618 | }); 619 | }); 620 | }); 621 | }); 622 | } 623 | --------------------------------------------------------------------------------