├── example ├── custed.png ├── filebase_example.dart ├── miniox_example.dart └── minio_example.dart ├── lib ├── models.dart ├── minio.dart ├── src │ ├── minio_stream.dart │ ├── minio_s3.dart │ ├── minio_poller.dart │ ├── minio_errors.dart │ ├── utils.dart │ ├── minio_uploader.dart │ ├── minio_models.dart │ ├── minio_sign.dart │ ├── minio_helpers.dart │ ├── minio_client.dart │ └── minio.dart └── io.dart ├── analysis_options.yaml ├── .github ├── workflows │ ├── test.yml │ └── lint.yml └── dependabot.yml ├── .gitignore ├── test ├── minio_models_test.dart ├── minio_stream_test.dart ├── minio_presigned_url_test.dart ├── helpers.dart ├── minio_helpers_test.dart ├── utils_test.dart └── minio_test.dart ├── pubspec.yaml ├── LICENSE ├── CHANGELOG.md ├── util └── generate_models.dart └── README.md /example/custed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xtyxtyx/minio-dart/HEAD/example/custed.png -------------------------------------------------------------------------------- /lib/models.dart: -------------------------------------------------------------------------------- 1 | export 'src/minio_models.dart'; 2 | export 'src/minio_models_generated.dart'; 3 | -------------------------------------------------------------------------------- /lib/minio.dart: -------------------------------------------------------------------------------- 1 | library minio; 2 | 3 | export 'src/minio.dart'; 4 | export 'src/minio_errors.dart'; 5 | export 'src/minio_stream.dart'; 6 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:mostly_reasonable_lints/analysis_options.yaml 2 | analyzer: 3 | errors: 4 | avoid_print: ignore 5 | prefer_const_declarations: ignore 6 | -------------------------------------------------------------------------------- /lib/src/minio_stream.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | class MinioByteStream extends StreamView> { 4 | MinioByteStream.fromStream({ 5 | required Stream> stream, 6 | required this.contentLength, 7 | }) : super(stream); 8 | 9 | final int? contentLength; 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: dart-lang/setup-dart@v1 15 | 16 | - run: dart pub get 17 | - run: dart test 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub 2 | .dart_tool/ 3 | .packages 4 | # Remove the following pattern if you wish to check in your lock file 5 | pubspec.lock 6 | 7 | # Conventional directory for build outputs 8 | build/ 9 | 10 | # Directory created by dartdoc 11 | doc/api/ 12 | 13 | # IDE directories 14 | .idea/ 15 | 16 | # For debugging purpose 17 | test/main.dart -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | groups: 8 | github-actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: pub 12 | directory: / 13 | schedule: 14 | interval: weekly 15 | groups: 16 | root-pub: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /example/filebase_example.dart: -------------------------------------------------------------------------------- 1 | import 'package:minio/minio.dart'; 2 | 3 | void main() async { 4 | final minio = Minio( 5 | endPoint: 's3.filebase.com', 6 | accessKey: '', 7 | secretKey: '', 8 | useSSL: true, 9 | ); 10 | 11 | final buckets = await minio.listBuckets(); 12 | print('buckets: $buckets'); 13 | 14 | final objects = await minio.listObjects(buckets.first.name).toList(); 15 | print('objects: $objects'); 16 | } 17 | -------------------------------------------------------------------------------- /example/miniox_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:minio/io.dart'; 4 | import 'package:minio/minio.dart'; 5 | 6 | void main() async { 7 | final minio = Minio( 8 | endPoint: 'play.min.io', 9 | accessKey: 'Q3AM3UQ867SPQQA43P2F', 10 | secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', 11 | ); 12 | 13 | await minio.fPutObject('testbucket', 'test.png', 'example/custed.png'); 14 | 15 | final stat = await minio.statObject('testbucket', 'test.png'); 16 | assert(stat.size == File('example/custed.png').lengthSync()); 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | analyze: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: dart-lang/setup-dart@v1 15 | 16 | - run: dart pub get 17 | - run: dart analyze 18 | 19 | format: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: dart-lang/setup-dart@v1 24 | 25 | - run: dart pub get 26 | - run: dart format --output=none --set-exit-if-changed . 27 | -------------------------------------------------------------------------------- /test/minio_models_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:minio/models.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | final date = DateTime.utc(2017, 8, 11, 19, 34, 18); 6 | final dateString = 'Fri, 11 Aug 2017 19:34:18 GMT'; 7 | 8 | test('CopyConditions.setModified() works', () { 9 | final cc = CopyConditions(); 10 | cc.setModified(date); 11 | expect(cc.modified, equals(dateString)); 12 | }); 13 | 14 | test('CopyConditions.setUnmodified() works', () { 15 | final cc = CopyConditions(); 16 | cc.setUnmodified(date); 17 | expect(cc.unmodified, dateString); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: minio 2 | description: Unofficial MinIO Dart Client SDK that provides simple APIs to access any Amazon S3 compatible object storage server. 3 | version: 3.5.8 4 | homepage: https://github.com/xtyxtyx/minio-dart 5 | issue_tracker: https://github.com/xtyxtyx/minio-dart/issues 6 | 7 | environment: 8 | sdk: ">=3.0.0 <4.0.0" 9 | 10 | dependencies: 11 | buffer: ^1.2.3 12 | convert: ^3.1.1 13 | crypto: ^3.0.3 14 | http: ^1.1.0 15 | intl: ">=0.19.0 <0.21.0" 16 | meta: ^1.12.0 17 | mime: ">=1.0.4 <3.0.0" 18 | path: ^1.8.0 19 | xml: ^6.4.2 20 | 21 | dev_dependencies: 22 | html: ^0.15.4 23 | mostly_reasonable_lints: ^0.1.2 24 | test: ^1.25.5 25 | -------------------------------------------------------------------------------- /lib/src/minio_s3.dart: -------------------------------------------------------------------------------- 1 | const awsS3Endpoint = { 2 | 'us-east-1': 's3.amazonaws.com', 3 | 'us-east-2': 's3-us-east-2.amazonaws.com', 4 | 'us-west-1': 's3-us-west-1.amazonaws.com', 5 | 'us-west-2': 's3-us-west-2.amazonaws.com', 6 | 'ca-central-1': 's3.ca-central-1.amazonaws.com', 7 | 'eu-west-1': 's3-eu-west-1.amazonaws.com', 8 | 'eu-west-2': 's3-eu-west-2.amazonaws.com', 9 | 'sa-east-1': 's3-sa-east-1.amazonaws.com', 10 | 'eu-central-1': 's3-eu-central-1.amazonaws.com', 11 | 'ap-south-1': 's3-ap-south-1.amazonaws.com', 12 | 'ap-southeast-1': 's3-ap-southeast-1.amazonaws.com', 13 | 'ap-southeast-2': 's3-ap-southeast-2.amazonaws.com', 14 | 'ap-northeast-1': 's3-ap-northeast-1.amazonaws.com', 15 | 'cn-north-1': 's3.cn-north-1.amazonaws.com.cn', 16 | }; 17 | 18 | // getS3Endpoint get relevant endpoint for the region. 19 | String getS3Endpoint(String region) { 20 | final endpoint = awsS3Endpoint[region]; 21 | return endpoint ?? 's3.amazonaws.com'; 22 | } 23 | -------------------------------------------------------------------------------- /test/minio_stream_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:test/test.dart'; 4 | 5 | import 'helpers.dart'; 6 | 7 | void main() { 8 | group('MinioByteStream', () { 9 | final bucketName = uniqueName(); 10 | final objectName = 'content-length-test'; 11 | final testData = Uint8List.fromList([1, 2, 3, 4, 5]); 12 | 13 | setUpAll(() async { 14 | final minio = getMinioClient(); 15 | await minio.makeBucket(bucketName); 16 | await minio.putObject(bucketName, objectName, Stream.value(testData)); 17 | }); 18 | 19 | tearDownAll(() async { 20 | final minio = getMinioClient(); 21 | await minio.removeObject(bucketName, objectName); 22 | await minio.removeBucket(bucketName); 23 | }); 24 | 25 | test('contains content length', () async { 26 | final minio = getMinioClient(); 27 | final stream = await minio.getObject(bucketName, objectName); 28 | expect(stream.contentLength, equals(testData.length)); 29 | await stream.drain(); 30 | }); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /test/minio_presigned_url_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:minio/minio.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | import 'helpers.dart'; 5 | 6 | void main() { 7 | test('Minio.presignedGetObject() works', () async { 8 | final minio = getMinioClient(); 9 | await minio.presignedGetObject('bucket', 'object'); 10 | }); 11 | 12 | test('Minio.presignedGetObject() throws when [expires] < 0', () async { 13 | final minio = getMinioClient(); 14 | expect( 15 | () => minio.presignedGetObject('bucket', 'object', expires: -1), 16 | throwsA(isA()), 17 | ); 18 | }); 19 | 20 | test('Minio.presignedPutObject() works', () async { 21 | final minio = getMinioClient(); 22 | await minio.presignedPutObject('bucket', 'object'); 23 | }); 24 | 25 | test('Minio.presignedPutObject() throws when [expires] < 0', () async { 26 | final minio = getMinioClient(); 27 | expect( 28 | () => minio.presignedPutObject('bucket', 'object', expires: -1), 29 | throwsA(isA()), 30 | ); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 xuty 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/helpers.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:minio/minio.dart'; 4 | 5 | /// Initializes an instance of [Minio] with per default valid configuration. 6 | /// 7 | /// Don't worry, these credentials for MinIO are publicly available and 8 | /// connect only to the MinIO demo server at `play.minio.io`. 9 | Minio getMinioClient({ 10 | String endpoint = 'play.minio.io', 11 | int? port = 443, 12 | bool useSSL = true, 13 | String accessKey = 'Q3AM3UQ867SPQQA43P2F', 14 | String secretKey = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', 15 | String sessionToken = '', 16 | String region = 'us-east-1', 17 | bool enableTrace = false, 18 | }) => 19 | Minio( 20 | endPoint: endpoint, 21 | port: port, 22 | useSSL: useSSL, 23 | accessKey: accessKey, 24 | secretKey: secretKey, 25 | sessionToken: sessionToken, 26 | region: region, 27 | enableTrace: enableTrace, 28 | ); 29 | 30 | /// Generates a random name for a bucket or object. 31 | String uniqueName() { 32 | final random = Random(); 33 | final now = DateTime.now(); 34 | final name = 'id-${now.microsecondsSinceEpoch}-${random.nextInt(8192)}'; 35 | return name; 36 | } 37 | -------------------------------------------------------------------------------- /test/minio_helpers_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:minio/src/minio_helpers.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('helpers', () { 6 | test('should validate for s3 endpoint', () { 7 | expect(isValidEndpoint('s3.amazonaws.com'), isTrue); 8 | }); 9 | test('should validate for s3 china', () { 10 | expect(isValidEndpoint('s3.cn-north-1.amazonaws.com.cn'), isTrue); 11 | }); 12 | test('should validate for us-west-2', () { 13 | expect(isValidEndpoint('s3-us-west-2.amazonaws.com'), isTrue); 14 | }); 15 | test('should fail for invalid endpoint characters', () { 16 | expect(isValidEndpoint('111.#2.11'), isFalse); 17 | }); 18 | test('should validate for valid ip', () { 19 | expect(isValidIPv4('1.1.1.1'), isTrue); 20 | }); 21 | test('should fail for invalid ip', () { 22 | expect(isValidIPv4('1.1.1'), isFalse); 23 | }); 24 | test('should make date short', () { 25 | final date = DateTime.parse('2012-12-03T17:25:36.331Z'); 26 | expect(makeDateShort(date), '20121203'); 27 | }); 28 | test('should make date long', () { 29 | final date = DateTime.parse('2017-08-11T17:26:34.935Z'); 30 | expect(makeDateLong(date), '20170811T172634Z'); 31 | }); 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /lib/src/minio_poller.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:minio/src/minio_client.dart'; 5 | 6 | class NotificationPoller { 7 | NotificationPoller( 8 | this._client, 9 | this.bucket, 10 | this.prefix, 11 | this.suffix, 12 | this.events, 13 | ); 14 | 15 | final MinioClient _client; 16 | final String bucket; 17 | final String? prefix; 18 | final String? suffix; 19 | final List? events; 20 | 21 | final _eventStream = StreamController>.broadcast(); 22 | Stream> get stream => _eventStream.stream; 23 | 24 | bool _stop = true; 25 | 26 | bool get isStarted { 27 | return !_stop; 28 | } 29 | 30 | /// Starts the polling. 31 | Future start() async { 32 | _stop = false; 33 | while (!_stop) { 34 | await _checkForChanges(); 35 | } 36 | } 37 | 38 | /// Stops the polling. 39 | void stop() { 40 | _stop = true; 41 | } 42 | 43 | Future _checkForChanges() async { 44 | // Don't continue if we're looping again but are cancelled. 45 | if (_stop) return; 46 | 47 | final queries = { 48 | if (prefix != null) 'prefix': prefix, 49 | if (suffix != null) 'suffix': suffix, 50 | if (events != null) 'events': events, 51 | }; 52 | 53 | final respStream = await _client.requestStream( 54 | method: 'GET', 55 | bucket: bucket, 56 | queries: queries, 57 | ); 58 | 59 | await for (var resp in respStream.stream) { 60 | if (_stop) break; 61 | 62 | final chunk = utf8.decode(resp); 63 | if (chunk.trim().isEmpty) continue; 64 | final data = json.decode(chunk); 65 | final records = List>.from(data['Records']); 66 | await _eventStream.addStream(Stream.fromIterable(records)); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /example/minio_example.dart: -------------------------------------------------------------------------------- 1 | import 'package:minio/io.dart'; 2 | import 'package:minio/minio.dart'; 3 | 4 | void main() async { 5 | final minio = Minio( 6 | endPoint: 'play.min.io', 7 | accessKey: 'Q3AM3UQ867SPQQA43P2F', 8 | secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', 9 | useSSL: true, 10 | // enableTrace: true, 11 | ); 12 | 13 | final bucket = '00test'; 14 | final object = 'custed.png'; 15 | final copy1 = 'custed.copy1.png'; 16 | final copy2 = 'custed.copy2.png'; 17 | 18 | if (!await minio.bucketExists(bucket)) { 19 | await minio.makeBucket(bucket); 20 | print('bucket $bucket created'); 21 | } else { 22 | print('bucket $bucket already exists'); 23 | } 24 | 25 | final poller = minio.listenBucketNotification( 26 | bucket, 27 | events: [ 28 | 's3:ObjectCreated:*', 29 | ], 30 | ); 31 | poller.stream.listen((event) { 32 | print('--- event: ${event['eventName']}'); 33 | }); 34 | 35 | final region = await minio.getBucketRegion('00test'); 36 | print('--- object region:'); 37 | print(region); 38 | 39 | final etag = await minio.fPutObject(bucket, object, 'example/$object'); 40 | print('--- etag:'); 41 | print(etag); 42 | 43 | final url = await minio.presignedGetObject(bucket, object, expires: 1000); 44 | print('--- presigned url:'); 45 | print(url); 46 | 47 | final copyResult1 = await minio.copyObject(bucket, copy1, '$bucket/$object'); 48 | final copyResult2 = await minio.copyObject(bucket, copy2, '$bucket/$object'); 49 | print('--- copy1 etag:'); 50 | print(copyResult1.eTag); 51 | print('--- copy2 etag:'); 52 | print(copyResult2.eTag); 53 | 54 | await minio.fGetObject(bucket, object, 'example/$copy1'); 55 | print('--- copy1 downloaded'); 56 | 57 | await minio.listObjects(bucket).forEach((chunk) { 58 | print('--- objects:'); 59 | for (var o in chunk.objects) { 60 | print(o.key); 61 | } 62 | }); 63 | 64 | await minio.listObjectsV2(bucket).forEach((chunk) { 65 | print('--- objects(v2):'); 66 | for (var o in chunk.objects) { 67 | print(o.key); 68 | } 69 | }); 70 | 71 | final stat = await minio.statObject(bucket, object); 72 | print('--- object stat:'); 73 | print(stat.etag); 74 | print(stat.size); 75 | print(stat.lastModified); 76 | print(stat.metaData); 77 | 78 | await minio.removeObject(bucket, object); 79 | print('--- object removed'); 80 | 81 | await minio.removeObjects(bucket, [copy1, copy2]); 82 | print('--- copy1, copy2 removed'); 83 | 84 | await minio.removeBucket(bucket); 85 | print('--- bucket removed'); 86 | 87 | poller.stop(); 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/minio_errors.dart: -------------------------------------------------------------------------------- 1 | import 'package:minio/models.dart'; 2 | import 'package:minio/src/minio_client.dart'; 3 | import 'package:minio/src/minio_helpers.dart'; 4 | 5 | class MinioError implements Exception { 6 | MinioError(this.message); 7 | 8 | final String? message; 9 | 10 | @override 11 | String toString() { 12 | return 'MinioError: $message'; 13 | } 14 | } 15 | 16 | class MinioAnonymousRequestError extends MinioError { 17 | MinioAnonymousRequestError(super.message); 18 | } 19 | 20 | class MinioInvalidArgumentError extends MinioError { 21 | MinioInvalidArgumentError(super.message); 22 | } 23 | 24 | class MinioInvalidPortError extends MinioError { 25 | MinioInvalidPortError(super.message); 26 | } 27 | 28 | class MinioInvalidEndpointError extends MinioError { 29 | MinioInvalidEndpointError(super.message); 30 | } 31 | 32 | class MinioInvalidBucketNameError extends MinioError { 33 | MinioInvalidBucketNameError(super.message); 34 | 35 | static void check(String bucket) { 36 | if (isValidBucketName(bucket)) return; 37 | throw MinioInvalidBucketNameError('Invalid bucket name: $bucket'); 38 | } 39 | } 40 | 41 | class MinioInvalidObjectNameError extends MinioError { 42 | MinioInvalidObjectNameError(super.message); 43 | 44 | static void check(String object) { 45 | if (isValidObjectName(object)) return; 46 | throw MinioInvalidObjectNameError('Invalid object name: $object'); 47 | } 48 | } 49 | 50 | class MinioAccessKeyRequiredError extends MinioError { 51 | MinioAccessKeyRequiredError(super.message); 52 | } 53 | 54 | class MinioSecretKeyRequiredError extends MinioError { 55 | MinioSecretKeyRequiredError(super.message); 56 | } 57 | 58 | class MinioExpiresParamError extends MinioError { 59 | MinioExpiresParamError(super.message); 60 | } 61 | 62 | class MinioInvalidDateError extends MinioError { 63 | MinioInvalidDateError(super.message); 64 | } 65 | 66 | class MinioInvalidPrefixError extends MinioError { 67 | MinioInvalidPrefixError(super.message); 68 | 69 | static void check(String prefix) { 70 | if (isValidPrefix(prefix)) return; 71 | throw MinioInvalidPrefixError('Invalid prefix: $prefix'); 72 | } 73 | } 74 | 75 | class MinioInvalidBucketPolicyError extends MinioError { 76 | MinioInvalidBucketPolicyError(super.message); 77 | } 78 | 79 | class MinioIncorrectSizeError extends MinioError { 80 | MinioIncorrectSizeError(super.message); 81 | } 82 | 83 | class MinioInvalidXMLError extends MinioError { 84 | MinioInvalidXMLError(super.message); 85 | } 86 | 87 | class MinioS3Error extends MinioError { 88 | MinioS3Error(super.message, [this.error, this.response]); 89 | 90 | Error? error; 91 | 92 | MinioResponse? response; 93 | } 94 | -------------------------------------------------------------------------------- /lib/io.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:minio/src/minio.dart'; 5 | import 'package:minio/src/minio_errors.dart'; 6 | import 'package:minio/src/minio_helpers.dart'; 7 | import 'package:path/path.dart' show dirname; 8 | 9 | extension MinioX on Minio { 10 | // Uploads the object using contents from a file 11 | Future fPutObject( 12 | String bucket, 13 | String object, 14 | String filePath, { 15 | Map? metadata, 16 | void Function(int)? onProgress, 17 | }) async { 18 | MinioInvalidBucketNameError.check(bucket); 19 | MinioInvalidObjectNameError.check(object); 20 | 21 | metadata ??= {}; 22 | metadata = insertContentType(metadata, filePath); 23 | metadata = prependXAMZMeta(metadata); 24 | 25 | final file = File(filePath); 26 | final stat = await file.stat(); 27 | if (stat.size > maxObjectSize) { 28 | throw MinioError( 29 | '$filePath size : ${stat.size}, max allowed size : 5TB', 30 | ); 31 | } 32 | 33 | return putObject( 34 | bucket, 35 | object, 36 | file.openRead().cast(), 37 | size: stat.size, 38 | metadata: metadata, 39 | onProgress: onProgress, 40 | ); 41 | } 42 | 43 | /// Downloads and saves the object as a file in the local filesystem. 44 | Future fGetObject( 45 | String bucket, 46 | String object, 47 | String filePath, 48 | ) async { 49 | MinioInvalidBucketNameError.check(bucket); 50 | MinioInvalidObjectNameError.check(object); 51 | 52 | final stat = await statObject(bucket, object); 53 | final dir = dirname(filePath); 54 | await Directory(dir).create(recursive: true); 55 | 56 | final partFileName = '$filePath.${stat.etag}.part.minio'; 57 | final partFile = File(partFileName); 58 | IOSink partFileStream; 59 | var offset = 0; 60 | 61 | Future rename() async { 62 | await partFile.rename(filePath); 63 | } 64 | 65 | if (await partFile.exists()) { 66 | final localStat = await partFile.stat(); 67 | if (stat.size == localStat.size) return await rename(); 68 | offset = localStat.size; 69 | partFileStream = partFile.openWrite(mode: FileMode.append); 70 | } else { 71 | partFileStream = partFile.openWrite(mode: FileMode.write); 72 | } 73 | 74 | final dataStream = await getPartialObject(bucket, object, offset); 75 | await dataStream.pipe(partFileStream); 76 | 77 | final localStat = await partFile.stat(); 78 | if (localStat.size != stat.size) { 79 | throw MinioError('Size mismatch between downloaded file and the object'); 80 | } 81 | 82 | return rename(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/utils_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:intl/date_symbol_data_local.dart'; 4 | import 'package:intl/intl.dart'; 5 | import 'package:minio/src/utils.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | void main() { 9 | testRfc7231Time(); 10 | testBlockStream(); 11 | } 12 | 13 | void testRfc7231Time() { 14 | final time = DateTime(2017, 8, 11, 19, 34, 18); 15 | final timeString = 'Fri, 11 Aug 2017 19:34:18'; 16 | 17 | final timeUtc = DateTime.utc(2017, 8, 11, 19, 34, 18); 18 | final timeStringUtc = 'Fri, 11 Aug 2017 19:34:18 GMT'; 19 | 20 | group('parseRfc7231Time', () { 21 | test('works', () { 22 | expect(parseRfc7231Time(timeString), equals(time)); 23 | expect(parseRfc7231Time(timeString).isUtc, isFalse); 24 | }); 25 | 26 | test('works for GMT time', () { 27 | expect(parseRfc7231Time(timeStringUtc), equals(timeUtc)); 28 | expect(parseRfc7231Time(timeStringUtc).isUtc, isTrue); 29 | }); 30 | 31 | test('works for non en-US locale', () async { 32 | initializeDateFormatting('pt_BR'); 33 | Intl.withLocale('pt_BR', () { 34 | expect(parseRfc7231Time(timeStringUtc), equals(timeUtc)); 35 | expect(parseRfc7231Time(timeStringUtc).isUtc, isTrue); 36 | }); 37 | }); 38 | }); 39 | 40 | group('toRfc7231Time', () { 41 | test('works', () { 42 | expect(toRfc7231Time(time), equals(timeString)); 43 | }); 44 | 45 | test('works for GMT time', () { 46 | expect(toRfc7231Time(timeUtc), equals(timeStringUtc)); 47 | }); 48 | 49 | test('works for non en-US locale', () async { 50 | initializeDateFormatting('pt_BR'); 51 | Intl.withLocale('pt_BR', () { 52 | expect(toRfc7231Time(timeUtc), equals(timeStringUtc)); 53 | }); 54 | }); 55 | }); 56 | } 57 | 58 | void testBlockStream() { 59 | test('MaxChunkSize works', () async { 60 | final streamData = [ 61 | Uint8List.fromList([1, 2]), 62 | Uint8List.fromList([3, 4, 5, 6]), 63 | Uint8List.fromList([7, 8, 9]), 64 | Uint8List.fromList([10, 11, 12, 13]), 65 | ]; 66 | 67 | final stream = Stream.fromIterable(streamData).transform(MaxChunkSize(3)); 68 | 69 | expect( 70 | await stream.toList(), 71 | equals([ 72 | Uint8List.fromList([1, 2]), 73 | Uint8List.fromList([3, 4, 5]), 74 | Uint8List.fromList([6]), 75 | Uint8List.fromList([7, 8, 9]), 76 | Uint8List.fromList([10, 11, 12]), 77 | Uint8List.fromList([13]), 78 | ]), 79 | ); 80 | }); 81 | 82 | test('MinChunkSize works', () async { 83 | final streamData = [ 84 | Uint8List.fromList([1, 2]), 85 | Uint8List.fromList([3, 4, 5, 6]), 86 | Uint8List.fromList([7, 8, 9]), 87 | Uint8List.fromList([10, 11, 12, 13]), 88 | ]; 89 | 90 | final stream = Stream.fromIterable(streamData).transform(MinChunkSize(5)); 91 | 92 | expect( 93 | await stream.toList(), 94 | equals([ 95 | Uint8List.fromList([1, 2, 3, 4, 5, 6]), 96 | Uint8List.fromList([7, 8, 9, 10, 11, 12, 13]), 97 | ]), 98 | ); 99 | }); 100 | 101 | test('MinChunkSize with empty stream', () async { 102 | final stream = const Stream.empty().transform(MinChunkSize(5)); 103 | expect(await stream.toList(), equals([Uint8List.fromList([])])); 104 | }); 105 | 106 | test('MinChunkSize with zero length stream', () async { 107 | final stream = Stream.value(Uint8List(0)).transform(MinChunkSize(5)); 108 | expect(await stream.toList(), equals([Uint8List.fromList([])])); 109 | }); 110 | } 111 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | import 'dart:math' as math; 4 | import 'dart:typed_data'; 5 | 6 | import 'package:convert/convert.dart'; 7 | import 'package:crypto/crypto.dart'; 8 | import 'package:intl/intl.dart'; 9 | import 'package:xml/xml.dart'; 10 | 11 | String sha256Hex(dynamic data) { 12 | if (data is String) { 13 | data = utf8.encode(data); 14 | } else if (data is List) { 15 | data = data; 16 | } else { 17 | throw ArgumentError('unsupported data type: ${data.runtimeType}'); 18 | } 19 | 20 | return hex.encode(sha256.convert(data).bytes); 21 | } 22 | 23 | String sha256HmacHex(String data, List signingKey) => hex 24 | .encode(Hmac(sha256, signingKey).convert(utf8.encode(data)).bytes) 25 | .toLowerCase(); 26 | 27 | String md5Base64(String source) { 28 | final md5digest = md5.convert(utf8.encode(source)).bytes; 29 | return base64.encode(md5digest); 30 | } 31 | 32 | String jsonBase64(Map jsonObject) { 33 | return base64.encode(utf8.encode(json.encode(jsonObject))); 34 | } 35 | 36 | XmlElement? getNodeProp(XmlElement xml, String name) { 37 | final result = xml.findElements(name); 38 | return result.isNotEmpty ? result.first : null; 39 | } 40 | 41 | String encodeQuery(String rawKey, String? rawValue) { 42 | final pair = [rawKey]; 43 | if (rawValue != null) { 44 | pair.add(Uri.encodeQueryComponent(rawValue)); 45 | } 46 | return pair.join('='); 47 | } 48 | 49 | String encodeQueries(Map queries) { 50 | final pairs = []; 51 | for (var key in queries.keys) { 52 | final value = queries[key]; 53 | if (value is String || value == null) { 54 | pairs.add(encodeQuery(key, value)); 55 | } else if (value is Iterable) { 56 | for (var val in value) { 57 | pairs.add(encodeQuery(key, val)); 58 | } 59 | } else { 60 | throw ArgumentError('unsupported value: $value'); 61 | } 62 | } 63 | return pairs.join('&'); 64 | } 65 | 66 | class MaxChunkSize extends StreamTransformerBase { 67 | MaxChunkSize(this.size); 68 | 69 | final int size; 70 | 71 | @override 72 | Stream bind(Stream stream) async* { 73 | await for (var chunk in stream) { 74 | if (chunk.length < size) { 75 | yield chunk; 76 | continue; 77 | } 78 | 79 | final blocks = chunk.length ~/ size; 80 | 81 | for (var i = 0; i < blocks; i++) { 82 | yield Uint8List.sublistView(chunk, i * size, (i + 1) * size); 83 | } 84 | 85 | if (blocks * size < chunk.length) { 86 | yield Uint8List.sublistView(chunk, blocks * size); 87 | } 88 | } 89 | } 90 | } 91 | 92 | class MinChunkSize extends StreamTransformerBase { 93 | MinChunkSize(this.size); 94 | 95 | final int size; 96 | 97 | var _yielded = false; 98 | 99 | @override 100 | Stream bind(Stream stream) async* { 101 | var buffer = BytesBuilder(copy: false); 102 | 103 | await for (var chunk in stream) { 104 | buffer.add(chunk); 105 | 106 | if (buffer.length < size) { 107 | continue; 108 | } 109 | 110 | yield buffer.takeBytes(); 111 | _yielded = true; 112 | } 113 | 114 | if (buffer.isNotEmpty || !_yielded) { 115 | yield buffer.takeBytes(); 116 | } 117 | } 118 | } 119 | 120 | String trimDoubleQuote(String str) { 121 | return str.replaceAll(RegExp('^"'), '').replaceAll(RegExp(r'"$'), ''); 122 | } 123 | 124 | DateTime parseRfc7231Time(String time) { 125 | final format = DateFormat('EEE, dd MMM yyyy HH:mm:ss', 'en-US'); 126 | final isUtc = time.endsWith('GMT'); 127 | return format.parse(time, isUtc); 128 | } 129 | 130 | String toRfc7231Time(DateTime time) { 131 | final format = DateFormat('EEE, dd MMM yyyy HH:mm:ss', 'en-US'); 132 | final result = format.format(time); 133 | return time.isUtc ? '$result GMT' : result; 134 | } 135 | 136 | List> groupList(List list, int maxMembers) { 137 | final groups = (list.length / maxMembers).ceil(); 138 | final result = >[]; 139 | for (var i = 0; i < groups; i++) { 140 | final start = i * maxMembers; 141 | final end = math.min(start + maxMembers, list.length); 142 | result.add(list.sublist(start, end)); 143 | } 144 | return result; 145 | } 146 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 3.5.8 2 | 3 | - Fix path encoding for space and plus characters in object names to prevent signature mismatch errors #7 4 | - Update fPutObject method signature to use named parameters for better API consistency 5 | 6 | # 3.5.7 7 | 8 | - fix maxKeys error while using listObject query #97 9 | - Bump intl from 0.19.0 to 0.20.0 in the root-pub group #96 10 | 11 | # 3.5.6 12 | 13 | - Fix non en-US date time parse #95 14 | 15 | # 3.5.5 16 | 17 | - Move x-amz-security-token allocation before signV4 to correctly sign the request #92 18 | - Bump mime from 1.0.6 to 2.0.0 in the root-pub group #91 19 | 20 | # 3.5.4 21 | 22 | - Add validation for HTTP response for `getObjectACL` / Make retrieval of ACLs optional for `statObject` #48 23 | 24 | # 3.5.3 25 | 26 | - Add `pathStyle` parameter to `Minio` constructor to support custom endpoints. 27 | 28 | # 3.5.2 29 | 30 | - Downgrade `meta` package to `1.12.0` to fix compatibility issues with Flutter. 31 | 32 | # 3.5.1 33 | 34 | - Updates minimum supported SDK version to Dart 3.0. 35 | - Fixes all lint warnings. 36 | - Added x-amz-security-token header support for session tokens. 37 | - Wait for rename to finish before in `fGetObject`. 38 | - Updated `MinioError` to implement the `Exception` interface. 39 | - Added XML validation before parsing response body. 40 | - Updated return type of `presignedPostPolicy` to `Future`. 41 | - Replaced `push` with `add` for adding conditions to the policy. 42 | 43 | # 3.5.0 44 | 45 | - Fix listObject with utf-8 prefix 46 | 47 | # 3.4.0-pre 48 | 49 | - Better upload progress on web 50 | 51 | # 3.3.3 52 | 53 | - Fix empty upload error 54 | - Update README.md 55 | 56 | # 3.3.3-pre 57 | 58 | - Update stream file upload 59 | 60 | # 3.3.2-pre 61 | 62 | - Add tests 63 | 64 | # 3.3.1-pre 65 | 66 | - Improve upload progress granularity 67 | - Fix broken test 68 | 69 | # 3.3.0-pre 70 | 71 | - Support listening upload progress 72 | 73 | # 3.2.0 74 | 75 | - Fix response body utf-8 encoding [#14] 76 | 77 | # 3.1.0 78 | 79 | - Add `listAllObjects` and `listAllObjectsV2` 80 | - Fix signing error in `listObjects` when prefix contains spaces [#34] 81 | - Improved compatibility with Filebase [#31] 82 | 83 | # 3.0.0 84 | 85 | - Fixes signing error in case object name contains symbols [#29] 86 | 87 | # 2.1.0-pre 88 | 89 | - `getObject` now returns `MinioByteStream` with an additional `contentLength` field. 90 | 91 | ## 2.0.0-pre 92 | 93 | - Migrate to NNBD 94 | 95 | ## 1.4.0-pre 96 | 97 | - Object's ACL Query; Include object's ACL in stat [#23](https://github.com/xtyxtyx/minio-dart/pull/23), thanks [@rtgnx](https://github.com/rtgnx) 98 | 99 | ## 1.3.0 100 | 101 | - fix HTTP header for user-defined object metadata [#17](https://github.com/xtyxtyx/minio-dart/issues/17), thanks [@philenius](https://github.com/philenius) 102 | 103 | ## 1.2.0 104 | 105 | - fix [#15](https://github.com/xtyxtyx/minio-dart/issues/15) fPutObject content-type: 'image/jpeg' is ignored 106 | 107 | ## 1.1.0 108 | 109 | ## 1.1.0-pre 110 | 111 | - fix bucketExists is true when bucket doesn't exist #13 112 | 113 | ## 1.0.2-pre 114 | 115 | - Replace static region 'us-east-1' in method listBuckets() with variable's value 116 | 117 | ## 1.0.1-pre 118 | 119 | - Updated dependencies 120 | - Fixed Malformed XML error 121 | - Fixed Types incompatibility in minio_uploader stream subscription queries 122 | - Temporarily closed call for search of unfinished uploads (Causes Signature Error) 123 | 124 | ## 0.1.8 125 | 126 | - Update dependency 127 | 128 | ## 0.1.7 129 | 130 | - Fix region issue, thanks @ivoryxiong 131 | 132 | ## 0.1.6 133 | 134 | - support policy apis 135 | 136 | ## 0.1.5 137 | 138 | - support notification apis 139 | 140 | ## 0.1.4 141 | 142 | - support presignedPostPolicy 143 | 144 | ## 0.1.3 145 | 146 | - support presignedGetObject and presignedPutObject 147 | 148 | ## 0.1.2 149 | 150 | - support presignedUrl 151 | 152 | ## 0.1.1 153 | 154 | - update dependency 155 | 156 | ## 0.1.0+2 157 | 158 | - try to fix table display 159 | 160 | ## 0.1.0+1 161 | 162 | - update README 163 | 164 | ## 0.1.0 165 | 166 | - Initial version, created by Stagehand 167 | 168 | [#34]: https://github.com/xtyxtyx/minio-dart/issues/34 169 | [#31]: https://github.com/xtyxtyx/minio-dart/issues/31 170 | [#29]: https://github.com/xtyxtyx/minio-dart/issues/29 171 | [#14]: https://github.com/xtyxtyx/minio-dart/issues/14 172 | -------------------------------------------------------------------------------- /lib/src/minio_uploader.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: flutter_style_todos 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'dart:typed_data'; 6 | 7 | import 'package:convert/convert.dart'; 8 | import 'package:crypto/crypto.dart'; 9 | import 'package:minio/minio.dart'; 10 | import 'package:minio/models.dart'; 11 | import 'package:minio/src/minio_client.dart'; 12 | import 'package:minio/src/minio_helpers.dart'; 13 | import 'package:minio/src/utils.dart'; 14 | 15 | class MinioUploader implements StreamConsumer { 16 | MinioUploader( 17 | this.minio, 18 | this.client, 19 | this.bucket, 20 | this.object, 21 | this.partSize, 22 | this.metadata, 23 | this.onProgress, 24 | ); 25 | 26 | final Minio minio; 27 | final MinioClient client; 28 | final String bucket; 29 | final String object; 30 | final int partSize; 31 | final Map metadata; 32 | final void Function(int)? onProgress; 33 | 34 | var _partNumber = 1; 35 | 36 | String? _etag; 37 | 38 | // Complete object upload, value is the length of the part. 39 | final _parts = {}; 40 | 41 | Map? _oldParts; 42 | 43 | String? _uploadId; 44 | 45 | // The number of bytes uploaded of the current part. 46 | int? bytesUploaded; 47 | 48 | @override 49 | Future addStream(Stream stream) async { 50 | await for (var chunk in stream) { 51 | List? md5digest; 52 | final headers = {}; 53 | headers.addAll(metadata); 54 | headers['Content-Length'] = chunk.length.toString(); 55 | if (!client.enableSHA256) { 56 | md5digest = md5.convert(chunk).bytes; 57 | headers['Content-MD5'] = base64.encode(md5digest); 58 | } 59 | 60 | if (_partNumber == 1 && chunk.length < partSize) { 61 | _etag = await _uploadChunk(chunk, headers, null); 62 | return; 63 | } 64 | 65 | if (_uploadId == null) { 66 | await _initMultipartUpload(); 67 | } 68 | 69 | final partNumber = _partNumber++; 70 | 71 | if (_oldParts != null) { 72 | final oldPart = _oldParts![partNumber]; 73 | if (oldPart != null) { 74 | md5digest ??= md5.convert(chunk).bytes; 75 | if (hex.encode(md5digest) == oldPart.eTag) { 76 | final part = CompletedPart(oldPart.eTag, partNumber); 77 | _parts[part] = oldPart.size!; 78 | continue; 79 | } 80 | } 81 | } 82 | 83 | final queries = { 84 | 'partNumber': '$partNumber', 85 | 'uploadId': _uploadId, 86 | }; 87 | 88 | final etag = await _uploadChunk(chunk, headers, queries); 89 | final part = CompletedPart(etag, partNumber); 90 | _parts[part] = chunk.length; 91 | } 92 | } 93 | 94 | @override 95 | Future close() async { 96 | if (_uploadId == null) return _etag; 97 | return minio.completeMultipartUpload( 98 | bucket, 99 | object, 100 | _uploadId!, 101 | _parts.keys.toList(), 102 | ); 103 | } 104 | 105 | Map getHeaders(List chunk) { 106 | final headers = {}; 107 | headers.addAll(metadata); 108 | headers['Content-Length'] = chunk.length.toString(); 109 | if (!client.enableSHA256) { 110 | final md5digest = md5.convert(chunk).bytes; 111 | headers['Content-MD5'] = base64.encode(md5digest); 112 | } 113 | return headers; 114 | } 115 | 116 | Future _uploadChunk( 117 | Uint8List chunk, 118 | Map headers, 119 | Map? queries, 120 | ) async { 121 | final resp = await client.request( 122 | method: 'PUT', 123 | headers: headers, 124 | queries: queries, 125 | bucket: bucket, 126 | object: object, 127 | payload: chunk, 128 | onProgress: _updateProgress, 129 | ); 130 | 131 | validate(resp); 132 | 133 | var etag = resp.headers['etag']; 134 | if (etag != null) etag = trimDoubleQuote(etag); 135 | 136 | return etag; 137 | } 138 | 139 | Future _initMultipartUpload() async { 140 | // FIXME: this code still causes Signature Error 141 | // FIXME: https://github.com/xtyxtyx/minio-dart/issues/7 142 | // TODO: uncomment when fixed 143 | // uploadId = await minio.findUploadId(bucket, object); 144 | 145 | if (_uploadId == null) { 146 | _uploadId = 147 | await minio.initiateNewMultipartUpload(bucket, object, metadata); 148 | return; 149 | } 150 | 151 | final parts = minio.listParts(bucket, object, _uploadId!); 152 | final entries = await parts 153 | .asyncMap((part) => MapEntry(part.partNumber, part)) 154 | .toList(); 155 | _oldParts = Map.fromEntries(entries); 156 | } 157 | 158 | void _updateProgress(int bytesUploaded) { 159 | this.bytesUploaded = bytesUploaded; 160 | _reportUploadProgress(); 161 | } 162 | 163 | void _reportUploadProgress() { 164 | if (onProgress == null || bytesUploaded == null) { 165 | return; 166 | } 167 | 168 | var totalBytesUploaded = bytesUploaded!; 169 | 170 | for (var part in _parts.keys) { 171 | totalBytesUploaded += _parts[part]!; 172 | } 173 | 174 | onProgress!(totalBytesUploaded); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /lib/src/minio_models.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: deprecated_member_use 2 | 3 | import 'package:minio/src/minio_errors.dart'; 4 | import 'package:minio/src/minio_models_generated.dart'; 5 | import 'package:minio/src/utils.dart'; 6 | import 'package:xml/xml.dart'; 7 | 8 | class ListObjectsResult { 9 | ListObjectsResult({ 10 | required this.objects, 11 | required this.prefixes, 12 | }); 13 | 14 | /// Metadata about each object returned. 15 | final List objects; 16 | 17 | /// Like directorys in a file system, prefixes are delimited by slashes. 18 | final List prefixes; 19 | 20 | @override 21 | String toString() { 22 | return '$runtimeType{objects: $objects, prefixes: $prefixes}'; 23 | } 24 | } 25 | 26 | class ListObjectsOutput { 27 | bool? isTruncated; 28 | String? nextMarker; 29 | List? contents; 30 | late List commonPrefixes; 31 | } 32 | 33 | class ListObjectsV2Output { 34 | bool? isTruncated; 35 | String? nextContinuationToken; 36 | List? contents; 37 | late List commonPrefixes; 38 | } 39 | 40 | class CompleteMultipartUpload { 41 | CompleteMultipartUpload( 42 | this.parts, 43 | ); 44 | 45 | XmlNode toXml() { 46 | final builder = XmlBuilder(); 47 | builder.element( 48 | 'CompleteMultipartUpload', 49 | nest: parts.map((p) => p.toXml()), 50 | ); 51 | return builder.buildDocument(); 52 | } 53 | 54 | /// Array of CompletedPart data types. 55 | List parts; 56 | } 57 | 58 | class ListMultipartUploadsOutput { 59 | ListMultipartUploadsOutput.fromXml(XmlElement xml) { 60 | isTruncated = getProp(xml, 'IsLatest')?.text.toUpperCase() == 'TRUE'; 61 | nextKeyMarker = getProp(xml, 'NextKeyMarker')?.text; 62 | nextUploadIdMarker = getProp(xml, 'NextUploadIdMarker')?.text; 63 | uploads = xml 64 | .findElements('Upload') 65 | .map((e) => MultipartUpload.fromXml(e)) 66 | .toList(); 67 | } 68 | 69 | bool? isTruncated; 70 | String? nextKeyMarker; 71 | String? nextUploadIdMarker; 72 | late List uploads; 73 | } 74 | 75 | class ListPartsOutput { 76 | ListPartsOutput.fromXml(XmlElement xml) { 77 | isTruncated = getProp(xml, 'IsLatest')?.text.toUpperCase() == 'TRUE'; 78 | nextPartNumberMarker = 79 | int.parse(getProp(xml, 'NextPartNumberMarker')!.text); 80 | parts = xml.findElements('Upload').map((e) => Part.fromXml(e)).toList(); 81 | } 82 | 83 | late bool isTruncated; 84 | late int nextPartNumberMarker; 85 | late List parts; 86 | } 87 | 88 | class IncompleteUpload { 89 | IncompleteUpload({ 90 | this.upload, 91 | this.size, 92 | }); 93 | 94 | final MultipartUpload? upload; 95 | final int? size; 96 | } 97 | 98 | class CopyConditions { 99 | String? modified; 100 | String? unmodified; 101 | String? matchETag; 102 | String? matchETagExcept; 103 | 104 | void setModified(DateTime date) { 105 | modified = toRfc7231Time(date.toUtc()); 106 | } 107 | 108 | void setUnmodified(DateTime date) { 109 | unmodified = toRfc7231Time(date.toUtc()); 110 | } 111 | 112 | void setMatchETag(String etag) { 113 | matchETag = etag; 114 | } 115 | 116 | void setMatchETagExcept(String etag) { 117 | matchETagExcept = etag; 118 | } 119 | } 120 | 121 | class StatObjectResult { 122 | StatObjectResult({ 123 | this.size, 124 | this.etag, 125 | this.lastModified, 126 | this.metaData, 127 | this.acl, 128 | }); 129 | 130 | final int? size; 131 | final String? etag; 132 | final DateTime? lastModified; 133 | final Map? metaData; 134 | final AccessControlPolicy? acl; 135 | } 136 | 137 | /// Build PostPolicy object that can be signed by presignedPostPolicy 138 | class PostPolicy { 139 | final policy = { 140 | 'conditions': [], 141 | }; 142 | 143 | final formData = {}; 144 | 145 | /// set expiration date 146 | void setExpires(DateTime date) { 147 | policy['expiration'] = date.toIso8601String(); 148 | } 149 | 150 | /// set object name 151 | void setKey(String object) { 152 | MinioInvalidObjectNameError.check(object); 153 | policy['conditions'].add(['eq', r'$key', object]); 154 | formData['key'] = object; 155 | } 156 | 157 | /// set object name prefix, i.e policy allows any keys with this prefix 158 | void setKeyStartsWith(String prefix) { 159 | MinioInvalidPrefixError.check(prefix); 160 | policy['conditions'].add(['starts-with', r'$key', prefix]); 161 | formData['key'] = prefix; 162 | } 163 | 164 | /// set bucket name 165 | void setBucket(bucket) { 166 | MinioInvalidBucketNameError.check(bucket); 167 | policy['conditions'].add(['eq', r'$bucket', bucket]); 168 | formData['bucket'] = bucket; 169 | } 170 | 171 | /// set Content-Type 172 | void setContentType(String type) { 173 | policy['conditions'].add(['eq', r'$Content-Type', type]); 174 | formData['Content-Type'] = type; 175 | } 176 | 177 | /// set minimum/maximum length of what Content-Length can be. 178 | void setContentLengthRange(int min, int max) { 179 | if (min > max) { 180 | throw MinioError('min cannot be more than max'); 181 | } 182 | if (min < 0) { 183 | throw MinioError('min should be > 0'); 184 | } 185 | if (max < 0) { 186 | throw MinioError('max should be > 0'); 187 | } 188 | policy['conditions'].add(['content-length-range', min, max]); 189 | } 190 | } 191 | 192 | class PostPolicyResult { 193 | PostPolicyResult({this.postURL, this.formData}); 194 | 195 | final String? postURL; 196 | final Map? formData; 197 | } 198 | -------------------------------------------------------------------------------- /lib/src/minio_sign.dart: -------------------------------------------------------------------------------- 1 | import 'package:convert/convert.dart'; 2 | import 'package:crypto/crypto.dart'; 3 | import 'package:minio/minio.dart'; 4 | import 'package:minio/src/minio_client.dart'; 5 | import 'package:minio/src/minio_helpers.dart'; 6 | import 'package:minio/src/utils.dart'; 7 | 8 | const signV4Algorithm = 'AWS4-HMAC-SHA256'; 9 | 10 | String signV4( 11 | Minio minio, 12 | MinioRequest request, 13 | DateTime requestDate, 14 | String region, 15 | ) { 16 | final signedHeaders = getSignedHeaders(request.headers.keys); 17 | final hashedPayload = request.headers['x-amz-content-sha256']; 18 | final canonicalRequest = 19 | getCanonicalRequest(request, signedHeaders, hashedPayload!); 20 | final stringToSign = getStringToSign(canonicalRequest, requestDate, region); 21 | final signingKey = getSigningKey(requestDate, region, minio.secretKey); 22 | final credential = getCredential(minio.accessKey, region, requestDate); 23 | final signature = hex.encode( 24 | Hmac(sha256, signingKey).convert(stringToSign.codeUnits).bytes, 25 | ); 26 | return '$signV4Algorithm Credential=$credential, SignedHeaders=${signedHeaders.join(';').toLowerCase()}, Signature=$signature'; 27 | } 28 | 29 | List getSignedHeaders(Iterable headers) { 30 | const ignored = { 31 | 'authorization', 32 | 'content-length', 33 | 'content-type', 34 | 'user-agent', 35 | }; 36 | final result = headers.where((header) => !ignored.contains(header)).toList(); 37 | result.sort(); 38 | return result; 39 | } 40 | 41 | String getCanonicalRequest( 42 | MinioRequest request, 43 | List signedHeaders, 44 | String hashedPayload, 45 | ) { 46 | final requestResource = encodePath(request.url); 47 | final headers = signedHeaders.map( 48 | (header) => '${header.toLowerCase()}:${request.headers[header]}', 49 | ); 50 | 51 | final queryKeys = request.url.queryParameters.keys.toList(); 52 | queryKeys.sort(); 53 | final requestQuery = queryKeys.map((key) { 54 | final value = request.url.queryParameters[key]; 55 | final hasValue = value != null; 56 | final valuePart = hasValue ? encodeCanonicalQuery(value) : ''; 57 | return '${encodeCanonicalQuery(key)}=$valuePart'; 58 | }).join('&'); 59 | 60 | final canonical = []; 61 | canonical.add(request.method.toUpperCase()); 62 | canonical.add(requestResource); 63 | canonical.add(requestQuery); 64 | canonical.add('${headers.join('\n')}\n'); 65 | canonical.add(signedHeaders.join(';').toLowerCase()); 66 | canonical.add(hashedPayload); 67 | return canonical.join('\n'); 68 | } 69 | 70 | String getStringToSign( 71 | String canonicalRequest, 72 | DateTime requestDate, 73 | String region, 74 | ) { 75 | final hash = sha256Hex(canonicalRequest); 76 | final scope = getScope(region, requestDate); 77 | final stringToSign = []; 78 | stringToSign.add(signV4Algorithm); 79 | stringToSign.add(makeDateLong(requestDate)); 80 | stringToSign.add(scope); 81 | stringToSign.add(hash); 82 | return stringToSign.join('\n'); 83 | } 84 | 85 | String getScope(String region, DateTime date) { 86 | return '${makeDateShort(date)}/$region/s3/aws4_request'; 87 | } 88 | 89 | List getSigningKey(DateTime date, String region, String secretKey) { 90 | final dateLine = makeDateShort(date); 91 | final key1 = ('AWS4$secretKey').codeUnits; 92 | final hmac1 = Hmac(sha256, key1).convert(dateLine.codeUnits).bytes; 93 | final hmac2 = Hmac(sha256, hmac1).convert(region.codeUnits).bytes; 94 | final hmac3 = Hmac(sha256, hmac2).convert('s3'.codeUnits).bytes; 95 | return Hmac(sha256, hmac3).convert('aws4_request'.codeUnits).bytes; 96 | } 97 | 98 | String getCredential(String accessKey, String region, DateTime requestDate) { 99 | return '$accessKey/${getScope(region, requestDate)}'; 100 | } 101 | 102 | // returns a presigned URL string 103 | String presignSignatureV4( 104 | Minio minio, 105 | MinioRequest request, 106 | String region, 107 | DateTime requestDate, 108 | int expires, 109 | ) { 110 | if (expires < 1) { 111 | throw MinioExpiresParamError('expires param cannot be less than 1 seconds'); 112 | } 113 | if (expires > 604800) { 114 | throw MinioExpiresParamError('expires param cannot be greater than 7 days'); 115 | } 116 | 117 | final iso8601Date = makeDateLong(requestDate); 118 | final signedHeaders = getSignedHeaders(request.headers.keys); 119 | final credential = getCredential(minio.accessKey, region, requestDate); 120 | 121 | final requestQuery = {}; 122 | requestQuery['X-Amz-Algorithm'] = signV4Algorithm; 123 | requestQuery['X-Amz-Credential'] = credential; 124 | requestQuery['X-Amz-Date'] = iso8601Date; 125 | requestQuery['X-Amz-Expires'] = expires.toString(); 126 | requestQuery['X-Amz-SignedHeaders'] = signedHeaders.join(';').toLowerCase(); 127 | if (minio.sessionToken != null) { 128 | requestQuery['X-Amz-Security-Token'] = minio.sessionToken; 129 | } 130 | 131 | request = request.replace( 132 | url: request.url.replace( 133 | queryParameters: { 134 | ...request.url.queryParameters, 135 | ...requestQuery, 136 | }, 137 | ), 138 | ); 139 | 140 | final canonicalRequest = 141 | getCanonicalRequest(request, signedHeaders, 'UNSIGNED-PAYLOAD'); 142 | 143 | final stringToSign = getStringToSign(canonicalRequest, requestDate, region); 144 | final signingKey = getSigningKey(requestDate, region, minio.secretKey); 145 | final signature = sha256HmacHex(stringToSign, signingKey); 146 | final presignedUrl = '${request.url}&X-Amz-Signature=$signature'; 147 | 148 | return presignedUrl; 149 | } 150 | 151 | // calculate the signature of the POST policy 152 | String postPresignSignatureV4( 153 | String region, 154 | DateTime date, 155 | String secretKey, 156 | String policyBase64, 157 | ) { 158 | final signingKey = getSigningKey(date, region, secretKey); 159 | return sha256HmacHex(policyBase64, signingKey); 160 | } 161 | -------------------------------------------------------------------------------- /util/generate_models.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:html/dom.dart' show Element; 4 | import 'package:html/parser.dart' show parse; 5 | import 'package:http/http.dart' as http; 6 | 7 | void main() async { 8 | final urls = await getAllModelUrls(); 9 | // print(await getModel(urls.first)); 10 | final models = await Future.wait(urls.map(getModel)); 11 | 12 | final result = ''' 13 | import 'package:xml/xml.dart'; 14 | 15 | XmlElement getProp(XmlElement xml, String name) { 16 | final result = xml.findElements(name); 17 | return result.isNotEmpty ? result.first : null; 18 | } 19 | 20 | ${models.join('\n')} 21 | '''; 22 | 23 | await File('lib/src/minio_models_generated.dart').writeAsString(result); 24 | } 25 | 26 | const baseUrl = 'https://docs.aws.amazon.com/AmazonS3/latest/API'; 27 | 28 | Future> getAllModelUrls() async { 29 | print('Getting Index.'); 30 | const url = '$baseUrl/API_Types_Amazon_Simple_Storage_Service.html'; 31 | final page = await http.get(Uri.parse(url)); 32 | final document = parse(page.body); 33 | final urls = document.querySelectorAll('.listitem a'); 34 | return urls 35 | .map((a) => a.attributes['href']!.substring(2)) 36 | .map((a) => '$baseUrl/$a') 37 | .toList(); 38 | } 39 | 40 | Future getModel(String url) async { 41 | print('Getting: $url.'); 42 | final page = await http.get(Uri.parse(url)); 43 | final document = parse(page.body); 44 | 45 | final name = document.querySelector('h1')!.text; 46 | final description = document 47 | .querySelector('#main-col-body p')! 48 | .text 49 | .replaceAll(RegExp(r'\s+'), ' '); 50 | 51 | final fields = []; 52 | for (var dt in document.querySelectorAll('dt')) { 53 | final name = dt.text.trim(); 54 | final spec = parseField(name, dt.nextElementSibling!); 55 | fields.add(spec); 56 | } 57 | 58 | final buffer = StringBuffer(); 59 | buffer.writeln('/// $description'); 60 | buffer.writeln('class $name {'); 61 | 62 | buffer.writeln(' $name('); 63 | for (var field in fields) { 64 | buffer.writeln(' this.${field.dartName},'); 65 | } 66 | buffer.writeln(' );'); 67 | buffer.writeln(''); 68 | 69 | buffer.writeln(' $name.fromXml(XmlElement xml) {'); 70 | for (var field in fields) { 71 | switch (field.type.name) { 72 | case 'String': 73 | buffer.writeln( 74 | " ${field.dartName} = getProp(xml, '${field.name}')?.text;", 75 | ); 76 | break; 77 | case 'int': 78 | buffer.writeln( 79 | " ${field.dartName} = int.tryParse(getProp(xml, '${field.name}')?.text);", 80 | ); 81 | break; 82 | case 'bool': 83 | buffer.writeln( 84 | " ${field.dartName} = getProp(xml, '${field.name}')?.text?.toUpperCase() == 'TRUE';", 85 | ); 86 | break; 87 | case 'DateTime': 88 | buffer.writeln( 89 | " ${field.dartName} = DateTime.parse(getProp(xml, '${field.name}')?.text);", 90 | ); 91 | break; 92 | default: 93 | buffer.writeln( 94 | " ${field.dartName} = ${field.type.name}.fromXml(getProp(xml, '${field.name}'));", 95 | ); 96 | } 97 | } 98 | buffer.writeln(' }'); 99 | buffer.writeln(''); 100 | 101 | buffer.writeln(' XmlNode toXml() {'); 102 | buffer.writeln(' final builder = XmlBuilder();'); 103 | buffer.writeln(" builder.element('$name', nest: () {"); 104 | for (var field in fields) { 105 | switch (field.type.name) { 106 | case 'String': 107 | buffer.writeln( 108 | " builder.element('${field.name}', nest: ${field.dartName});", 109 | ); 110 | break; 111 | case 'int': 112 | buffer.writeln( 113 | " builder.element('${field.name}', nest: ${field.dartName}.toString());", 114 | ); 115 | break; 116 | case 'bool': 117 | buffer.writeln( 118 | " builder.element('${field.name}', nest: ${field.dartName} ? 'TRUE' : 'FALSE');", 119 | ); 120 | 121 | break; 122 | case 'DateTime': 123 | buffer.writeln( 124 | " builder.element('${field.name}', nest: ${field.dartName}.toIso8601String());", 125 | ); 126 | break; 127 | default: 128 | buffer.writeln( 129 | " builder.element('${field.name}', nest: ${field.dartName}.toXml());", 130 | ); 131 | } 132 | } 133 | buffer.writeln(' });'); 134 | buffer.writeln(' return builder.buildDocument();'); 135 | buffer.writeln(' }'); 136 | buffer.writeln(''); 137 | 138 | for (var field in fields) { 139 | buffer.writeln(' /// ${field.description}'); 140 | buffer.writeln(' ${field.type.name} ${field.dartName};'); 141 | buffer.writeln(''); 142 | } 143 | buffer.writeln('}'); 144 | 145 | return buffer.toString(); 146 | } 147 | 148 | class FieldSpec { 149 | String? name; 150 | String? dartName; 151 | String? source; 152 | String? description; 153 | bool? isRequired; 154 | late TypeSpec type; 155 | 156 | @override 157 | String toString() { 158 | return ''; 159 | } 160 | } 161 | 162 | class TypeSpec { 163 | String? name; 164 | String? dartName; 165 | bool isObject = false; 166 | bool isArray = false; 167 | 168 | @override 169 | String toString() { 170 | return ''; 171 | } 172 | } 173 | 174 | String toCamelCase(String name) { 175 | return name.substring(0, 1).toLowerCase() + name.substring(1); 176 | } 177 | 178 | FieldSpec parseField(String name, Element dd) { 179 | final source = dd.text; 180 | final description = 181 | dd.querySelector('p')!.text.replaceAll(RegExp(r'\s+'), ' '); 182 | final isRequired = dd.text.contains('Required: Yes'); 183 | final type = parseType(source); 184 | 185 | return FieldSpec() 186 | ..name = name 187 | ..dartName = toCamelCase(name) 188 | ..source = source 189 | ..description = description 190 | ..isRequired = isRequired 191 | ..type = type; 192 | } 193 | 194 | TypeSpec parseType(String source) { 195 | if (source.contains('Type: Base64-encoded binary data object')) { 196 | return TypeSpec()..name = 'String'; 197 | } 198 | 199 | const typeMap = { 200 | 'Integer': 'int', 201 | 'Long': 'int', 202 | 'String': 'String', 203 | 'strings': 'String', 204 | 'Timestamp': 'DateTime', 205 | 'Boolean': 'bool', 206 | }; 207 | final pattern = RegExp(r'Type: (Array of |)(\w+)( data type|)'); 208 | final match = pattern.firstMatch(source)!; 209 | 210 | final isArray = match.group(1)!.trim().isNotEmpty; 211 | final isObject = match.group(3)!.trim().isNotEmpty; 212 | final type = match.group(2); 213 | 214 | final dartType = isObject ? type : typeMap[type!]; 215 | final dartName = isArray ? 'List<$dartType>' : dartType; 216 | 217 | return TypeSpec() 218 | ..name = dartType 219 | ..dartName = dartName 220 | ..isObject = isObject 221 | ..isArray = isArray; 222 | } 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

MinIO Dart

3 |

4 | 5 | This is the _unofficial_ MinIO Dart Client SDK that provides simple APIs to access any Amazon S3 compatible object storage server. 6 | 7 |

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |

18 | 19 | ## API 20 | 21 | | Bucket operations | Object operations | Presigned operations | Bucket Policy & Notification operations | 22 | | ----------------------- | ------------------------ | --------------------- | --------------------------------------- | 23 | | [makeBucket] | [getObject] | [presignedUrl] | [getBucketNotification] | 24 | | [listBuckets] | [getPartialObject] | [presignedGetObject] | [setBucketNotification] | 25 | | [bucketExists] | [fGetObject] | [presignedPutObject] | [removeAllBucketNotification] | 26 | | [removeBucket] | [putObject] | [presignedPostPolicy] | [listenBucketNotification] | 27 | | [listObjects] | [fPutObject] | | [getBucketPolicy] | 28 | | [listObjectsV2] | [copyObject] | | [setBucketPolicy] | 29 | | [listIncompleteUploads] | [statObject] | | | 30 | | [listAllObjects] | [removeObject] | | | 31 | | [listAllObjectsV2] | [removeObjects] | | | 32 | | | [removeIncompleteUpload] | | | 33 | 34 | ## Usage 35 | 36 | ### Initialize MinIO Client 37 | 38 | **MinIO** 39 | 40 | ```dart 41 | final minio = Minio( 42 | endPoint: 'play.min.io', 43 | accessKey: 'Q3AM3UQ867SPQQA43P2F', 44 | secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', 45 | ); 46 | ``` 47 | 48 | **AWS S3** 49 | 50 | ```dart 51 | final minio = Minio( 52 | endPoint: 's3.amazonaws.com', 53 | accessKey: 'YOUR-ACCESSKEYID', 54 | secretKey: 'YOUR-SECRETACCESSKEY', 55 | ); 56 | ``` 57 | 58 | **Filebase** 59 | 60 | ```dart 61 | final minio = Minio( 62 | endPoint: 's3.filebase.com', 63 | accessKey: 'YOUR-ACCESSKEYID', 64 | secretKey: 'YOUR-SECRETACCESSKEY', 65 | useSSL: true, 66 | ); 67 | ``` 68 | 69 | **File upload** 70 | 71 | ```dart 72 | import 'package:minio/io.dart'; 73 | import 'package:minio/minio.dart'; 74 | 75 | void main() async { 76 | final minio = Minio( 77 | endPoint: 'play.min.io', 78 | accessKey: 'Q3AM3UQ867SPQQA43P2F', 79 | secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', 80 | ); 81 | 82 | await minio.fPutObject('mybucket', 'myobject', 'path/to/file'); 83 | } 84 | ``` 85 | 86 | For complete example, see: [example] 87 | 88 | > To use `fPutObject()` and `fGetObject`, you have to `import 'package:minio/io.dart';` 89 | 90 | **Upload with progress** 91 | 92 | ```dart 93 | import 'package:minio/minio.dart'; 94 | 95 | void main() async { 96 | final minio = Minio( 97 | endPoint: 'play.min.io', 98 | accessKey: 'Q3AM3UQ867SPQQA43P2F', 99 | secretKey: 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG', 100 | ); 101 | 102 | await minio.putObject( 103 | 'mybucket', 104 | 'myobject', 105 | Stream.value(Uint8List(1024)), 106 | onProgress: (bytes) => print('$bytes uploaded'), 107 | ); 108 | } 109 | ``` 110 | 111 | **Get object** 112 | 113 | ```dart 114 | import 'dart:io'; 115 | import 'package:minio/minio.dart'; 116 | 117 | void main() async { 118 | final minio = Minio( 119 | endPoint: 's3.amazonaws.com', 120 | accessKey: 'YOUR-ACCESSKEYID', 121 | secretKey: 'YOUR-SECRETACCESSKEY', 122 | ); 123 | 124 | final stream = await minio.getObject('BUCKET-NAME', 'OBJECT-NAME'); 125 | 126 | // Get object length 127 | print(stream.contentLength); 128 | 129 | // Write object data stream to file 130 | await stream.pipe(File('output.txt').openWrite()); 131 | } 132 | ``` 133 | 134 | ## Features and bugs 135 | 136 | Please file feature requests and bugs at the [issue tracker][tracker]. 137 | 138 | Contributions to this repository are welcome. 139 | 140 | ## License 141 | 142 | [MIT](./LICENSE) 143 | 144 | [tracker]: https://github.com/xtyxtyx/minio-dart/issues 145 | [example]: https://pub.dev/packages/minio/example 146 | 147 | [makeBucket]: https://pub.dev/documentation/minio/latest/minio/Minio/makeBucket.html 148 | [listBuckets]: https://pub.dev/documentation/minio/latest/minio/Minio/listBuckets.html 149 | [bucketExists]: https://pub.dev/documentation/minio/latest/minio/Minio/bucketExists.html 150 | [removeBucket]: https://pub.dev/documentation/minio/latest/minio/Minio/removeBucket.html 151 | [listObjects]: https://pub.dev/documentation/minio/latest/minio/Minio/listObjects.html 152 | [listObjectsV2]: https://pub.dev/documentation/minio/latest/minio/Minio/listObjectsV2.html 153 | [listIncompleteUploads]: https://pub.dev/documentation/minio/latest/minio/Minio/listIncompleteUploads.html 154 | [listAllObjects]: https://pub.dev/documentation/minio/latest/minio/Minio/listAllObjects.html 155 | [listAllObjectsV2]: https://pub.dev/documentation/minio/latest/minio/Minio/listAllObjectsV2.html 156 | 157 | [getObject]: https://pub.dev/documentation/minio/latest/minio/Minio/getObject.html 158 | [getPartialObject]: https://pub.dev/documentation/minio/latest/minio/Minio/getPartialObject.html 159 | [putObject]: https://pub.dev/documentation/minio/latest/minio/Minio/putObject.html 160 | [copyObject]: https://pub.dev/documentation/minio/latest/minio/Minio/copyObject.html 161 | [statObject]: https://pub.dev/documentation/minio/latest/minio/Minio/statObject.html 162 | [removeObject]: https://pub.dev/documentation/minio/latest/minio/Minio/removeObject.html 163 | [removeObjects]: https://pub.dev/documentation/minio/latest/minio/Minio/removeObjects.html 164 | [removeIncompleteUpload]: https://pub.dev/documentation/minio/latest/minio/Minio/removeIncompleteUpload.html 165 | 166 | [fGetObject]: https://pub.dev/documentation/minio/latest/io/MinioX/fGetObject.html 167 | [fPutObject]: https://pub.dev/documentation/minio/latest/io/MinioX/fPutObject.html 168 | 169 | [presignedUrl]: https://pub.dev/documentation/minio/latest/minio/Minio/presignedUrl.html 170 | [presignedGetObject]: https://pub.dev/documentation/minio/latest/minio/Minio/presignedGetObject.html 171 | [presignedPutObject]: https://pub.dev/documentation/minio/latest/minio/Minio/presignedPutObject.html 172 | [presignedPostPolicy]: https://pub.dev/documentation/minio/latest/minio/Minio/presignedPostPolicy.html 173 | 174 | [getBucketNotification]: https://pub.dev/documentation/minio/latest/minio/Minio/getBucketNotification.html 175 | [setBucketNotification]: https://pub.dev/documentation/minio/latest/minio/Minio/setBucketNotification.html 176 | [removeAllBucketNotification]: https://pub.dev/documentation/minio/latest/minio/Minio/removeAllBucketNotification.html 177 | [listenBucketNotification]: https://pub.dev/documentation/minio/latest/minio/Minio/listenBucketNotification.html 178 | 179 | [getBucketPolicy]: https://pub.dev/documentation/minio/latest/minio/Minio/getBucketPolicy.html 180 | [setBucketPolicy]: https://pub.dev/documentation/minio/latest/minio/Minio/setBucketPolicy.html 181 | -------------------------------------------------------------------------------- /lib/src/minio_helpers.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: non_constant_identifier_names 2 | 3 | import 'package:convert/convert.dart'; 4 | import 'package:http/http.dart'; 5 | import 'package:mime/mime.dart' show lookupMimeType; 6 | import 'package:minio/src/minio_client.dart'; 7 | import 'package:minio/src/minio_errors.dart'; 8 | import 'package:minio/src/minio_models_generated.dart'; 9 | import 'package:xml/xml.dart' as xml; 10 | 11 | bool isValidBucketName(String bucket) { 12 | if (bucket.length < 3 || bucket.length > 63) { 13 | return false; 14 | } 15 | if (bucket.contains('..')) { 16 | return false; 17 | } 18 | 19 | if (RegExp(r'[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+').hasMatch(bucket)) { 20 | return false; 21 | } 22 | 23 | if (RegExp(r'^[a-z0-9][a-z0-9.-]+[a-z0-9]$').hasMatch(bucket)) { 24 | return true; 25 | } 26 | 27 | return false; 28 | } 29 | 30 | bool isValidObjectName(String objectName) { 31 | if (!isValidPrefix(objectName)) return false; 32 | if (objectName.isEmpty) return false; 33 | return true; 34 | } 35 | 36 | bool isValidPrefix(String prefix) { 37 | if (prefix.length > 1024) return false; 38 | return true; 39 | } 40 | 41 | bool isAmazonEndpoint(String endpoint) { 42 | return endpoint == 's3.amazonaws.com' || 43 | endpoint == 's3.cn-north-1.amazonaws.com.cn'; 44 | } 45 | 46 | bool isVirtualHostStyle(String endpoint, bool useSSL, String? bucket) { 47 | if (bucket == null) { 48 | return false; 49 | } 50 | 51 | if (useSSL && bucket.contains('.')) { 52 | return false; 53 | } 54 | 55 | return isAmazonEndpoint(endpoint); 56 | } 57 | 58 | bool isValidEndpoint(endpoint) { 59 | return isValidDomain(endpoint) || isValidIPv4(endpoint); 60 | } 61 | 62 | bool isValidIPv4(String? ip) { 63 | if (ip == null) return false; 64 | return RegExp(r'^(\d{1,3}\.){3,3}\d{1,3}$').hasMatch(ip); 65 | } 66 | 67 | bool isValidDomain(String? host) { 68 | if (host == null) return false; 69 | 70 | if (host.isEmpty || host.length > 255) { 71 | return false; 72 | } 73 | 74 | if (host.startsWith('-') || host.endsWith('-')) { 75 | return false; 76 | } 77 | 78 | if (host.startsWith('_') || host.endsWith('_')) { 79 | return false; 80 | } 81 | 82 | if (host.startsWith('.') || host.endsWith('.')) { 83 | return false; 84 | } 85 | 86 | final alphaNumerics = '`~!@#\$%^&*()+={}[]|\\"\';:>= minPort && port <= maxPort; 100 | } 101 | 102 | int implyPort(bool ssl) { 103 | return ssl ? 443 : 80; 104 | } 105 | 106 | String makeDateLong(DateTime date) { 107 | final isoDate = date.toIso8601String(); 108 | 109 | // 'YYYYMMDDTHHmmss' + Z 110 | return '${isoDate.substring(0, 4)}' 111 | '${isoDate.substring(5, 7)}' 112 | '${isoDate.substring(8, 13)}' 113 | '${isoDate.substring(14, 16)}' 114 | '${isoDate.substring(17, 19)}Z'; 115 | } 116 | 117 | String makeDateShort(DateTime date) { 118 | final isoDate = date.toIso8601String(); 119 | 120 | // 'YYYYMMDD' 121 | return isoDate.substring(0, 4) + 122 | isoDate.substring(5, 7) + 123 | isoDate.substring(8, 10); 124 | } 125 | 126 | Map prependXAMZMeta(Map metadata) { 127 | final newMetadata = Map.from(metadata); 128 | for (var key in metadata.keys) { 129 | if (!isAmzHeader(key) && 130 | !isSupportedHeader(key) && 131 | !isStorageclassHeader(key)) { 132 | newMetadata['x-amz-meta-$key'] = newMetadata[key]!; 133 | newMetadata.remove(key); 134 | } 135 | } 136 | return newMetadata; 137 | } 138 | 139 | bool isAmzHeader(key) { 140 | key = key.toLowerCase(); 141 | return key.startsWith('x-amz-meta-') || 142 | key == 'x-amz-acl' || 143 | key.startsWith('x-amz-server-side-encryption-') || 144 | key == 'x-amz-server-side-encryption'; 145 | } 146 | 147 | bool isSupportedHeader(key) { 148 | var supportedHeaders = { 149 | 'content-type', 150 | 'cache-control', 151 | 'content-encoding', 152 | 'content-disposition', 153 | 'content-language', 154 | 'x-amz-website-redirect-location', 155 | }; 156 | return (supportedHeaders.contains(key.toLowerCase())); 157 | } 158 | 159 | bool isStorageclassHeader(key) { 160 | return key.toLowerCase() == 'x-amz-storage-class'; 161 | } 162 | 163 | Map extractMetadata(Map metaData) { 164 | var newMetadata = {}; 165 | for (var key in metaData.keys) { 166 | if (isSupportedHeader(key) || 167 | isStorageclassHeader(key) || 168 | isAmzHeader(key)) { 169 | if (key.toLowerCase().startsWith('x-amz-meta-')) { 170 | newMetadata[key.substring(11, key.length)] = metaData[key]!; 171 | } else { 172 | newMetadata[key] = metaData[key]!; 173 | } 174 | } 175 | } 176 | return newMetadata; 177 | } 178 | 179 | String probeContentType(String path) { 180 | final contentType = lookupMimeType(path); 181 | return contentType ?? 'application/octet-stream'; 182 | } 183 | 184 | Map insertContentType( 185 | Map metaData, 186 | String filePath, 187 | ) { 188 | for (var key in metaData.keys) { 189 | if (key.toLowerCase() == 'content-type') { 190 | return metaData; 191 | } 192 | } 193 | 194 | final newMetadata = Map.from(metaData); 195 | newMetadata['content-type'] = probeContentType(filePath); 196 | return newMetadata; 197 | } 198 | 199 | Future validateStreamed( 200 | StreamedResponse streamedResponse, { 201 | int? expect, 202 | }) async { 203 | if (streamedResponse.statusCode >= 400) { 204 | final response = await MinioResponse.fromStream(streamedResponse); 205 | final body = xml.XmlDocument.parse(response.body); 206 | final error = Error.fromXml(body.rootElement); 207 | throw MinioS3Error(error.message, error, response); 208 | } 209 | 210 | if (expect != null && streamedResponse.statusCode != expect) { 211 | final response = await MinioResponse.fromStream(streamedResponse); 212 | throw MinioS3Error( 213 | '$expect expected, got ${streamedResponse.statusCode}', 214 | null, 215 | response, 216 | ); 217 | } 218 | } 219 | 220 | void validate(MinioResponse response, {int? expect}) { 221 | if (response.statusCode >= 400) { 222 | dynamic error; 223 | 224 | // Parse HTTP response body as XML only when not empty 225 | if (response.body.isEmpty) { 226 | error = Error(response.reasonPhrase, null, response.reasonPhrase, null); 227 | } else { 228 | // check valid xml 229 | if (response.body.startsWith(' stream; 28 | 29 | if (body is Stream) { 30 | stream = body; 31 | } else if (body is String) { 32 | final data = const Utf8Encoder().convert(body); 33 | headers['content-length'] = data.length.toString(); 34 | stream = Stream.value(data); 35 | } else if (body is Uint8List) { 36 | stream = Stream.value(body); 37 | headers['content-length'] = body.length.toString(); 38 | } else { 39 | throw UnsupportedError('Unsupported body type: ${body.runtimeType}'); 40 | } 41 | 42 | if (onProgress == null) { 43 | return ByteStream(stream); 44 | } 45 | 46 | var bytesRead = 0; 47 | 48 | stream = stream.transform(MaxChunkSize(1 << 16)); 49 | 50 | return ByteStream( 51 | stream.transform( 52 | StreamTransformer.fromHandlers( 53 | handleData: (data, sink) { 54 | sink.add(data); 55 | bytesRead += data.length; 56 | onProgress!(bytesRead); 57 | }, 58 | ), 59 | ), 60 | ); 61 | } 62 | 63 | MinioRequest replace({ 64 | String? method, 65 | Uri? url, 66 | Map? headers, 67 | body, 68 | }) { 69 | final result = MinioRequest(method ?? this.method, url ?? this.url); 70 | result.body = body ?? this.body; 71 | result.headers.addAll(headers ?? this.headers); 72 | return result; 73 | } 74 | } 75 | 76 | /// An HTTP response where the entire response body is known in advance. 77 | class MinioResponse extends BaseResponse { 78 | /// Create a new HTTP response with a byte array body. 79 | MinioResponse.bytes( 80 | this.bodyBytes, 81 | int statusCode, { 82 | BaseRequest? request, 83 | Map headers = const {}, 84 | bool isRedirect = false, 85 | bool persistentConnection = true, 86 | String? reasonPhrase, 87 | }) : super( 88 | statusCode, 89 | contentLength: bodyBytes.length, 90 | request: request, 91 | headers: headers, 92 | isRedirect: isRedirect, 93 | persistentConnection: persistentConnection, 94 | reasonPhrase: reasonPhrase, 95 | ); 96 | 97 | /// The bytes comprising the body of this response. 98 | final Uint8List bodyBytes; 99 | 100 | /// Body of s3 response is always encoded as UTF-8. 101 | String get body => utf8.decode(bodyBytes); 102 | 103 | static Future fromStream(StreamedResponse response) async { 104 | final body = await response.stream.toBytes(); 105 | return MinioResponse.bytes( 106 | body, 107 | response.statusCode, 108 | request: response.request, 109 | headers: response.headers, 110 | isRedirect: response.isRedirect, 111 | persistentConnection: response.persistentConnection, 112 | reasonPhrase: response.reasonPhrase, 113 | ); 114 | } 115 | } 116 | 117 | class MinioClient { 118 | MinioClient(this.minio) { 119 | anonymous = minio.accessKey.isEmpty && minio.secretKey.isEmpty; 120 | enableSHA256 = !anonymous && !minio.useSSL; 121 | port = minio.port; 122 | } 123 | 124 | final Minio minio; 125 | final String userAgent = 'MinIO (Unknown; Unknown) minio-dart/2.0.0'; 126 | 127 | late bool enableSHA256; 128 | late bool anonymous; 129 | late final int port; 130 | 131 | Future _request({ 132 | required String method, 133 | String? bucket, 134 | String? object, 135 | String? region, 136 | String? resource, 137 | dynamic payload = '', 138 | Map? queries, 139 | Map? headers, 140 | void Function(int)? onProgress, 141 | }) async { 142 | if (bucket != null) { 143 | region ??= await minio.getBucketRegion(bucket); 144 | } 145 | 146 | region ??= 'us-east-1'; 147 | 148 | final request = getBaseRequest( 149 | method, 150 | bucket, 151 | object, 152 | region, 153 | resource, 154 | queries, 155 | headers, 156 | onProgress, 157 | ); 158 | request.body = payload; 159 | 160 | final date = DateTime.now().toUtc(); 161 | final sha256sum = enableSHA256 ? sha256Hex(payload) : 'UNSIGNED-PAYLOAD'; 162 | request.headers.addAll({ 163 | 'user-agent': userAgent, 164 | 'x-amz-date': makeDateLong(date), 165 | 'x-amz-content-sha256': sha256sum, 166 | }); 167 | 168 | if (minio.sessionToken != null) { 169 | request.headers['x-amz-security-token'] = minio.sessionToken!; 170 | } 171 | 172 | final authorization = signV4(minio, request, date, region); 173 | request.headers['authorization'] = authorization; 174 | logRequest(request); 175 | final response = await request.send(); 176 | return response; 177 | } 178 | 179 | Future request({ 180 | required String method, 181 | String? bucket, 182 | String? object, 183 | String? region, 184 | String? resource, 185 | dynamic payload = '', 186 | Map? queries, 187 | Map? headers, 188 | void Function(int)? onProgress, 189 | }) async { 190 | final stream = await _request( 191 | method: method, 192 | bucket: bucket, 193 | object: object, 194 | region: region, 195 | payload: payload, 196 | resource: resource, 197 | queries: queries, 198 | headers: headers, 199 | onProgress: onProgress, 200 | ); 201 | 202 | final response = await MinioResponse.fromStream(stream); 203 | logResponse(response); 204 | 205 | return response; 206 | } 207 | 208 | Future requestStream({ 209 | required String method, 210 | String? bucket, 211 | String? object, 212 | String? region, 213 | String? resource, 214 | dynamic payload = '', 215 | Map? queries, 216 | Map? headers, 217 | }) async { 218 | final response = await _request( 219 | method: method, 220 | bucket: bucket, 221 | object: object, 222 | region: region, 223 | payload: payload, 224 | resource: resource, 225 | queries: queries, 226 | headers: headers, 227 | ); 228 | 229 | logResponse(response); 230 | return response; 231 | } 232 | 233 | MinioRequest getBaseRequest( 234 | String method, 235 | String? bucket, 236 | String? object, 237 | String region, 238 | String? resource, 239 | Map? queries, 240 | Map? headers, 241 | void Function(int)? onProgress, 242 | ) { 243 | final url = getRequestUrl(bucket, object, resource, queries); 244 | final request = MinioRequest(method, url, onProgress: onProgress); 245 | request.headers['host'] = url.authority; 246 | 247 | if (headers != null) { 248 | request.headers.addAll(headers); 249 | } 250 | 251 | return request; 252 | } 253 | 254 | Uri getRequestUrl( 255 | String? bucket, 256 | String? object, 257 | String? resource, 258 | Map? queries, 259 | ) { 260 | var host = minio.endPoint.toLowerCase(); 261 | var path = '/'; 262 | 263 | bool pathStyle = minio.pathStyle ?? true; 264 | if (isAmazonEndpoint(host)) { 265 | host = getS3Endpoint(minio.region!); 266 | pathStyle = !isVirtualHostStyle(host, minio.useSSL, bucket); 267 | } 268 | 269 | if (!pathStyle) { 270 | if (bucket != null) host = '$bucket.$host'; 271 | if (object != null) path = '/$object'; 272 | } else { 273 | if (bucket != null) path = '/$bucket'; 274 | if (object != null) path = '/$bucket/$object'; 275 | } 276 | 277 | final query = StringBuffer(); 278 | if (resource != null) { 279 | query.write(resource); 280 | } 281 | if (queries != null) { 282 | if (query.isNotEmpty) query.write('&'); 283 | query.write(encodeQueries(queries)); 284 | } 285 | 286 | return Uri( 287 | scheme: minio.useSSL ? 'https' : 'http', 288 | host: host, 289 | port: minio.port, 290 | pathSegments: path.split('/'), 291 | query: query.toString(), 292 | ); 293 | } 294 | 295 | void logRequest(MinioRequest request) { 296 | if (!minio.enableTrace) return; 297 | 298 | final buffer = StringBuffer(); 299 | buffer.writeln('REQUEST: ${request.method} ${request.url}'); 300 | for (var header in request.headers.entries) { 301 | buffer.writeln('${header.key}: ${header.value}'); 302 | } 303 | 304 | if (request.body is List) { 305 | buffer.writeln('List of size ${request.body.length}'); 306 | } else { 307 | buffer.writeln(request.body); 308 | } 309 | 310 | print(buffer.toString()); 311 | } 312 | 313 | void logResponse(BaseResponse response) { 314 | if (!minio.enableTrace) return; 315 | 316 | final buffer = StringBuffer(); 317 | buffer.writeln('RESPONSE: ${response.statusCode} ${response.reasonPhrase}'); 318 | for (var header in response.headers.entries) { 319 | buffer.writeln('${header.key}: ${header.value}'); 320 | } 321 | 322 | if (response is Response) { 323 | buffer.writeln(response.body); 324 | } else if (response is StreamedResponse) { 325 | buffer.writeln('STREAMED BODY'); 326 | } 327 | 328 | print(buffer.toString()); 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /test/minio_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:minio/io.dart'; 5 | import 'package:minio/minio.dart'; 6 | import 'package:minio/src/minio_models_generated.dart'; 7 | import 'package:minio/src/utils.dart'; 8 | import 'package:test/test.dart'; 9 | 10 | import 'helpers.dart'; 11 | 12 | void main() { 13 | testConstruct(); 14 | testListBuckets(); 15 | testBucketExists(); 16 | testFPutObject(); 17 | testGetObjectACL(); 18 | testSetObjectACL(); 19 | testGetObject(); 20 | testPutObject(); 21 | testGetBucketNotification(); 22 | testSetBucketNotification(); 23 | testRemoveAllBucketNotification(); 24 | testListenBucketNotification(); 25 | testStatObject(); 26 | testMakeBucket(); 27 | testRemoveBucket(); 28 | testRemoveObject(); 29 | testListObjects(); 30 | } 31 | 32 | void testConstruct() { 33 | test('Minio() implies http port', () { 34 | final client = getMinioClient(port: null, useSSL: false); 35 | expect(client.port, equals(80)); 36 | }); 37 | 38 | test('Minio() implies https port', () { 39 | final client = getMinioClient(port: null, useSSL: true); 40 | expect(client.port, equals(443)); 41 | }); 42 | 43 | test('Minio() overrides port with http', () { 44 | final client = getMinioClient(port: 1234, useSSL: false); 45 | expect(client.port, equals(1234)); 46 | }); 47 | 48 | test('Minio() overrides port with https', () { 49 | final client = getMinioClient(port: 1234, useSSL: true); 50 | expect(client.port, equals(1234)); 51 | }); 52 | 53 | test('Minio() throws when endPoint is url', () { 54 | expect( 55 | () => getMinioClient(endpoint: 'http://play.min.io'), 56 | throwsA(isA()), 57 | ); 58 | }); 59 | 60 | test('Minio() throws when port is invalid', () { 61 | expect( 62 | () => getMinioClient(port: -1), 63 | throwsA(isA()), 64 | ); 65 | 66 | expect( 67 | () => getMinioClient(port: 65536), 68 | throwsA(isA()), 69 | ); 70 | }); 71 | } 72 | 73 | void testListBuckets() { 74 | test('listBuckets() succeeds', () async { 75 | final minio = getMinioClient(); 76 | 77 | expect(() async => await minio.listBuckets(), returnsNormally); 78 | }); 79 | 80 | test('listBuckets() can list buckets', () async { 81 | final minio = getMinioClient(); 82 | final bucketName1 = uniqueName(); 83 | await minio.makeBucket(bucketName1); 84 | 85 | final bucketName2 = uniqueName(); 86 | await minio.makeBucket(bucketName2); 87 | 88 | final buckets = await minio.listBuckets(); 89 | expect(buckets.any((b) => b.name == bucketName1), isTrue); 90 | expect(buckets.any((b) => b.name == bucketName2), isTrue); 91 | 92 | await minio.removeBucket(bucketName1); 93 | await minio.removeBucket(bucketName2); 94 | }); 95 | 96 | test('listBuckets() fails due to wrong access key', () async { 97 | final minio = getMinioClient(accessKey: 'incorrect-access-key'); 98 | 99 | expect( 100 | () async => await minio.listBuckets(), 101 | throwsA( 102 | isA().having( 103 | (e) => e.error!.code, 104 | 'code', 105 | isIn(['AccessDenied', 'InvalidAccessKeyId']), 106 | ), 107 | ), 108 | ); 109 | }); 110 | 111 | test('listBuckets() fails due to wrong secret key', () async { 112 | final minio = getMinioClient(secretKey: 'incorrect-secret-key'); 113 | 114 | expect( 115 | () async => await minio.listBuckets(), 116 | throwsA( 117 | isA().having( 118 | (e) => e.error!.code, 119 | 'code', 120 | isIn(['AccessDenied', 'SignatureDoesNotMatch']), 121 | ), 122 | ), 123 | ); 124 | }); 125 | } 126 | 127 | void testBucketExists() { 128 | group('bucketExists', () { 129 | final bucketName = uniqueName(); 130 | 131 | setUpAll(() async { 132 | final minio = getMinioClient(); 133 | await minio.makeBucket(bucketName); 134 | }); 135 | 136 | tearDownAll(() async { 137 | final minio = getMinioClient(); 138 | await minio.removeBucket(bucketName); 139 | }); 140 | 141 | test('bucketExists() returns true for an existing bucket', () async { 142 | final minio = getMinioClient(); 143 | expect(await minio.bucketExists(bucketName), equals(true)); 144 | }); 145 | 146 | test('bucketExists() returns false for a non-existent bucket', () async { 147 | final minio = getMinioClient(); 148 | expect( 149 | await minio.bucketExists('non-existing-bucket-name'), 150 | equals(false), 151 | ); 152 | }); 153 | 154 | test('bucketExists() fails due to wrong access key', () async { 155 | final minio = getMinioClient(accessKey: 'incorrect-access-key'); 156 | expect( 157 | () async => await minio.bucketExists(bucketName), 158 | throwsA( 159 | isA().having( 160 | (e) => e.message, 161 | 'message', 162 | 'Forbidden', 163 | ), 164 | ), 165 | ); 166 | }); 167 | 168 | test('bucketExists() fails due to wrong secret key', () async { 169 | final minio = getMinioClient(secretKey: 'incorrect-secret-key'); 170 | expect( 171 | () async => await minio.bucketExists(bucketName), 172 | throwsA( 173 | isA().having( 174 | (e) => e.message, 175 | 'message', 176 | 'Forbidden', 177 | ), 178 | ), 179 | ); 180 | }); 181 | }); 182 | } 183 | 184 | void testFPutObject() { 185 | group('fPutObject', () { 186 | final bucketName = uniqueName(); 187 | late Directory tempDir; 188 | late File testFile; 189 | final objectName = 'a.jpg'; 190 | 191 | setUpAll(() async { 192 | tempDir = await Directory.systemTemp.createTemp(); 193 | testFile = await File('${tempDir.path}/$objectName').create(); 194 | await testFile.writeAsString('random bytes'); 195 | 196 | final minio = getMinioClient(); 197 | await minio.makeBucket(bucketName); 198 | }); 199 | 200 | tearDownAll(() async { 201 | final minio = getMinioClient(); 202 | await minio.removeObject(bucketName, objectName); 203 | await tempDir.delete(recursive: true); 204 | }); 205 | 206 | test('fPutObject() inserts content-type to metadata', () async { 207 | final minio = getMinioClient(); 208 | await minio.fPutObject(bucketName, objectName, testFile.path); 209 | 210 | final stat = await minio.statObject(bucketName, objectName); 211 | expect(stat.metaData!['content-type'], equals('image/jpeg')); 212 | }); 213 | 214 | test('fPutObject() adds user-defined object metadata w/ prefix', () async { 215 | final prefix = 'x-amz-meta-'; 216 | final userDefinedMetadataKey = '${prefix}user-defined-metadata-key-1'; 217 | final userDefinedMetadataValue = 'custom value 1'; 218 | final metadata = { 219 | userDefinedMetadataKey: userDefinedMetadataValue, 220 | }; 221 | 222 | final minio = getMinioClient(); 223 | await minio.fPutObject(bucketName, objectName, testFile.path, metadata: metadata); 224 | 225 | final stat = await minio.statObject(bucketName, objectName); 226 | expect( 227 | stat.metaData![userDefinedMetadataKey.substring(prefix.length)], 228 | equals(userDefinedMetadataValue), 229 | ); 230 | }); 231 | 232 | test('fPutObject() adds user-defined object metadata w/o prefix', () async { 233 | final userDefinedMetadataKey = 'user-defined-metadata-key-2'; 234 | final userDefinedMetadataValue = 'custom value 2'; 235 | final metadata = { 236 | userDefinedMetadataKey: userDefinedMetadataValue, 237 | }; 238 | 239 | final minio = getMinioClient(); 240 | await minio.fPutObject(bucketName, objectName, testFile.path, metadata: metadata); 241 | 242 | final stat = await minio.statObject(bucketName, objectName); 243 | expect( 244 | stat.metaData![userDefinedMetadataKey], 245 | equals(userDefinedMetadataValue), 246 | ); 247 | }); 248 | 249 | test('fPutObject() with empty file', () async { 250 | final objectName = 'empty.txt'; 251 | final emptyFile = await File('${tempDir.path}/$objectName').create(); 252 | await emptyFile.writeAsString(''); 253 | 254 | final minio = getMinioClient(); 255 | await minio.fPutObject(bucketName, objectName, emptyFile.path); 256 | 257 | final stat = await minio.statObject(bucketName, objectName); 258 | expect(stat.size, equals(0)); 259 | }); 260 | }); 261 | } 262 | 263 | void testSetObjectACL() { 264 | group('setObjectACL', () { 265 | late String bucketName; 266 | late Directory tempDir; 267 | File testFile; 268 | final objectName = 'a.jpg'; 269 | 270 | setUpAll(() async { 271 | bucketName = uniqueName(); 272 | 273 | tempDir = await Directory.systemTemp.createTemp(); 274 | testFile = await File('${tempDir.path}/$objectName').create(); 275 | await testFile.writeAsString('random bytes'); 276 | 277 | final minio = getMinioClient(); 278 | await minio.makeBucket(bucketName); 279 | 280 | await minio.fPutObject(bucketName, objectName, testFile.path); 281 | }); 282 | 283 | tearDownAll(() async { 284 | await tempDir.delete(recursive: true); 285 | }); 286 | 287 | test('setObjectACL() set objects acl', () async { 288 | final minio = getMinioClient(); 289 | await minio.setObjectACL(bucketName, objectName, 'public-read'); 290 | }); 291 | }); 292 | } 293 | 294 | void testGetObjectACL() { 295 | group('getObjectACL', () { 296 | late String bucketName; 297 | late Directory tempDir; 298 | File testFile; 299 | final objectName = 'a.jpg'; 300 | 301 | setUpAll(() async { 302 | bucketName = uniqueName(); 303 | 304 | tempDir = await Directory.systemTemp.createTemp(); 305 | testFile = await File('${tempDir.path}/$objectName').create(); 306 | await testFile.writeAsString('random bytes'); 307 | 308 | final minio = getMinioClient(); 309 | await minio.makeBucket(bucketName); 310 | 311 | await minio.fPutObject(bucketName, objectName, testFile.path); 312 | }); 313 | 314 | tearDownAll(() async { 315 | await tempDir.delete(recursive: true); 316 | }); 317 | 318 | test('getObjectACL() fetch objects acl', () async { 319 | final minio = getMinioClient(); 320 | var acl = await minio.getObjectACL(bucketName, objectName); 321 | expect(acl.grants!.permission, equals(null)); 322 | }); 323 | }); 324 | } 325 | 326 | void testGetObject() { 327 | group('getObject()', () { 328 | final minio = getMinioClient(); 329 | final bucketName = uniqueName(); 330 | final object = uniqueName(); 331 | final objectUtf8 = '${uniqueName()}/あるところ/某个文件.🦐'; 332 | final objectData = Uint8List.fromList([1, 2, 3]); 333 | 334 | setUpAll(() async { 335 | await minio.makeBucket(bucketName); 336 | await minio.putObject(bucketName, object, Stream.value(objectData)); 337 | await minio.putObject(bucketName, objectUtf8, Stream.value(objectData)); 338 | }); 339 | 340 | tearDownAll(() async { 341 | await minio.removeObject(bucketName, object); 342 | await minio.removeObject(bucketName, objectUtf8); 343 | await minio.removeBucket(bucketName); 344 | }); 345 | 346 | test('succeeds', () async { 347 | final stream = await minio.getObject(bucketName, object); 348 | final buffer = BytesBuilder(); 349 | await stream.forEach((data) => buffer.add(data)); 350 | expect(stream.contentLength, equals(objectData.length)); 351 | expect(buffer.takeBytes(), equals(objectData)); 352 | }); 353 | 354 | test('succeeds with utf8 object name', () async { 355 | final stream = await minio.getObject(bucketName, object); 356 | final buffer = BytesBuilder(); 357 | await stream.forEach((data) => buffer.add(data)); 358 | expect(stream.contentLength, equals(objectData.length)); 359 | expect(buffer.takeBytes(), equals(objectData)); 360 | }); 361 | 362 | test('fails on invalid bucket', () { 363 | expect( 364 | () async => await minio.getObject('$bucketName-invalid', object), 365 | throwsA(isA()), 366 | ); 367 | }); 368 | 369 | test('fails on invalid object', () { 370 | expect( 371 | () async => await minio.getObject(bucketName, '$object-invalid'), 372 | throwsA(isA()), 373 | ); 374 | }); 375 | }); 376 | } 377 | 378 | void testPutObject() { 379 | group('putObject()', () { 380 | final minio = getMinioClient(); 381 | final bucketName = uniqueName(); 382 | final objectData = Uint8List.fromList([1, 2, 3]); 383 | 384 | setUpAll(() async { 385 | await minio.makeBucket(bucketName); 386 | }); 387 | 388 | tearDownAll(() async { 389 | await minio.removeBucket(bucketName); 390 | }); 391 | 392 | test('succeeds', () async { 393 | final objectName = uniqueName(); 394 | await minio.putObject(bucketName, objectName, Stream.value(objectData)); 395 | final stat = await minio.statObject(bucketName, objectName); 396 | expect(stat.size, equals(objectData.length)); 397 | await minio.removeObject(bucketName, objectName); 398 | }); 399 | 400 | test('works with object names with symbols', () async { 401 | final objectName = uniqueName() + r'-._~,!@#$%^&*()'; 402 | await minio.putObject(bucketName, objectName, Stream.value(objectData)); 403 | final stat = await minio.statObject(bucketName, objectName); 404 | expect(stat.size, equals(objectData.length)); 405 | await minio.removeObject(bucketName, objectName); 406 | }); 407 | 408 | test('progress report works', () async { 409 | final objectName = uniqueName(); 410 | int? progress; 411 | await minio.putObject( 412 | bucketName, 413 | objectName, 414 | Stream.value(objectData), 415 | onProgress: (bytes) => progress = bytes, 416 | ); 417 | await minio.removeObject(bucketName, objectName); 418 | expect(progress, equals(objectData.length)); 419 | }); 420 | 421 | test('medium size file upload works', () async { 422 | final objectName = uniqueName(); 423 | final dataLength = 1024 * 1024; 424 | final data = Uint8List.fromList(List.generate(dataLength, (i) => i)); 425 | await minio.putObject(bucketName, objectName, Stream.value(data)); 426 | final stat = await minio.statObject(bucketName, objectName); 427 | await minio.removeObject(bucketName, objectName); 428 | expect(stat.size, equals(dataLength)); 429 | }); 430 | 431 | test('stream upload works', () async { 432 | final objectName = uniqueName(); 433 | final dataLength = 1024 * 1024; 434 | final data = Uint8List.fromList(List.generate(dataLength, (i) => i)); 435 | await minio.putObject( 436 | bucketName, 437 | objectName, 438 | Stream.value(data).transform(MaxChunkSize(123)), 439 | ); 440 | final stat = await minio.statObject(bucketName, objectName); 441 | await minio.removeObject(bucketName, objectName); 442 | expect(stat.size, equals(dataLength)); 443 | }); 444 | 445 | test('empty stream upload works', () async { 446 | final objectName = uniqueName(); 447 | await minio.putObject(bucketName, objectName, const Stream.empty()); 448 | final stat = await minio.statObject(bucketName, objectName); 449 | await minio.removeObject(bucketName, objectName); 450 | expect(stat.size, equals(0)); 451 | }); 452 | 453 | test('zero byte stream upload works', () async { 454 | final objectName = uniqueName(); 455 | await minio.putObject(bucketName, objectName, Stream.value(Uint8List(0))); 456 | final stat = await minio.statObject(bucketName, objectName); 457 | await minio.removeObject(bucketName, objectName); 458 | expect(stat.size, equals(0)); 459 | }); 460 | 461 | test('multipart file upload works', () async { 462 | final objectName = uniqueName(); 463 | final dataLength = 12 * 1024 * 1024; 464 | final data = Uint8List.fromList(List.generate(dataLength, (i) => i)); 465 | await minio.putObject( 466 | bucketName, 467 | objectName, 468 | Stream.value(data), 469 | chunkSize: 5 * 1024 * 1024, 470 | ); 471 | final stat = await minio.statObject(bucketName, objectName); 472 | await minio.removeObject(bucketName, objectName); 473 | expect(stat.size, equals(dataLength)); 474 | }); 475 | }); 476 | } 477 | 478 | void testGetBucketNotification() { 479 | group('getBucketNotification()', () { 480 | final minio = getMinioClient(); 481 | final bucketName = uniqueName(); 482 | 483 | setUpAll(() async { 484 | await minio.makeBucket(bucketName); 485 | }); 486 | 487 | tearDownAll(() async { 488 | await minio.removeBucket(bucketName); 489 | }); 490 | 491 | test('succeeds', () async { 492 | await minio.getBucketNotification(bucketName); 493 | }); 494 | }); 495 | } 496 | 497 | void testSetBucketNotification() { 498 | group('setBucketNotification()', () { 499 | final minio = getMinioClient(); 500 | final bucketName = uniqueName(); 501 | 502 | setUpAll(() async { 503 | await minio.makeBucket(bucketName); 504 | }); 505 | 506 | tearDownAll(() async { 507 | await minio.removeBucket(bucketName); 508 | }); 509 | 510 | test('succeeds', () async { 511 | await minio.setBucketNotification( 512 | bucketName, 513 | NotificationConfiguration(null, null, null), 514 | ); 515 | }); 516 | }); 517 | } 518 | 519 | void testRemoveAllBucketNotification() { 520 | group('removeAllBucketNotification()', () { 521 | final minio = getMinioClient(); 522 | final bucketName = uniqueName(); 523 | 524 | setUpAll(() async { 525 | await minio.makeBucket(bucketName); 526 | }); 527 | 528 | tearDownAll(() async { 529 | await minio.removeBucket(bucketName); 530 | }); 531 | 532 | test('succeeds', () async { 533 | await minio.removeAllBucketNotification(bucketName); 534 | }); 535 | }); 536 | } 537 | 538 | void testListenBucketNotification() { 539 | group('listenBucketNotification()', () { 540 | final minio = getMinioClient(); 541 | final bucketName = uniqueName(); 542 | // final objectName = uniqueName(); 543 | 544 | setUpAll(() async { 545 | await minio.makeBucket(bucketName); 546 | }); 547 | 548 | tearDownAll(() async { 549 | await minio.removeBucket(bucketName); 550 | }); 551 | 552 | test('succeeds', () async { 553 | final poller = minio.listenBucketNotification(bucketName); 554 | expect(poller.isStarted, isTrue); 555 | poller.stop(); 556 | }); 557 | 558 | // test('can receive notification', () async { 559 | // final poller = minio.listenBucketNotification( 560 | // bucketName, 561 | // events: ['s3:ObjectCreated:*'], 562 | // ); 563 | 564 | // final receivedEvents = []; 565 | // poller.stream.listen((event) => receivedEvents.add(event)); 566 | // expect(receivedEvents, isEmpty); 567 | 568 | // await minio.putObject(bucketName, objectName, Stream.value([0])); 569 | // await minio.removeObject(bucketName, objectName); 570 | 571 | // // FIXME: Needs sleep here 572 | // expect(receivedEvents, isNotEmpty); 573 | 574 | // poller.stop(); 575 | // }); 576 | }); 577 | } 578 | 579 | void testStatObject() { 580 | group('statObject()', () { 581 | final minio = getMinioClient(); 582 | final bucketName = uniqueName(); 583 | final object = uniqueName(); 584 | final objectUtf8 = '${uniqueName()}オブジェクト。📦'; 585 | final data = Uint8List.fromList([1, 2, 3, 4, 5]); 586 | 587 | setUpAll(() async { 588 | await minio.makeBucket(bucketName); 589 | await minio.putObject(bucketName, object, Stream.value(data)); 590 | await minio.putObject(bucketName, objectUtf8, Stream.value(data)); 591 | }); 592 | 593 | tearDownAll(() async { 594 | await minio.removeObject(bucketName, object); 595 | await minio.removeObject(bucketName, objectUtf8); 596 | await minio.removeBucket(bucketName); 597 | }); 598 | 599 | test('succeeds', () async { 600 | final stats = await minio.statObject(bucketName, object); 601 | expect(stats.lastModified, isNotNull); 602 | expect(stats.lastModified!.isBefore(DateTime.now()), isTrue); 603 | expect(stats.size, isNotNull); 604 | expect(stats.size, equals(data.length)); 605 | }); 606 | 607 | test('succeeds with utf8 object name', () async { 608 | final stats = await minio.statObject(bucketName, objectUtf8); 609 | expect(stats.lastModified, isNotNull); 610 | expect(stats.lastModified!.isBefore(DateTime.now()), isTrue); 611 | expect(stats.size, isNotNull); 612 | expect(stats.size, equals(data.length)); 613 | }); 614 | 615 | test('fails on invalid bucket', () { 616 | expect( 617 | () async => await minio.statObject('$bucketName-invalid', object), 618 | throwsA(isA()), 619 | ); 620 | }); 621 | 622 | test('fails on invalid object', () { 623 | expect( 624 | () async => await minio.statObject(bucketName, '$object-invalid'), 625 | throwsA(isA()), 626 | ); 627 | }); 628 | }); 629 | } 630 | 631 | void testMakeBucket() { 632 | group('makeBucket()', () { 633 | final minio = getMinioClient(); 634 | final bucketName = uniqueName(); 635 | 636 | setUpAll(() async { 637 | await minio.makeBucket(bucketName); 638 | }); 639 | 640 | tearDownAll(() async { 641 | await minio.removeBucket(bucketName); 642 | }); 643 | 644 | test('succeeds', () async { 645 | final buckets = await minio.listBuckets(); 646 | final bucketNames = buckets.map((b) => b.name).toList(); 647 | expect(bucketNames, contains(bucketName)); 648 | }); 649 | }); 650 | } 651 | 652 | void testRemoveBucket() { 653 | group('removeBucket()', () { 654 | final minio = getMinioClient(); 655 | final bucketName = uniqueName(); 656 | 657 | test('succeeds', () async { 658 | await minio.makeBucket(bucketName); 659 | await minio.removeBucket(bucketName); 660 | }); 661 | 662 | test('fails on invalid bucket name', () { 663 | expect( 664 | () async => await minio.removeBucket('$bucketName-invalid'), 665 | throwsA(isA()), 666 | ); 667 | }); 668 | }); 669 | } 670 | 671 | void testRemoveObject() { 672 | group('removeObject()', () { 673 | final minio = getMinioClient(); 674 | final bucketName = uniqueName(); 675 | final objectName = uniqueName(); 676 | final data = Uint8List.fromList([1, 2, 3, 4, 5]); 677 | 678 | setUpAll(() async { 679 | await minio.makeBucket(bucketName); 680 | }); 681 | 682 | tearDownAll(() async { 683 | await minio.removeBucket(bucketName); 684 | }); 685 | 686 | test('succeeds', () async { 687 | await minio.putObject(bucketName, objectName, Stream.value(data)); 688 | await minio.removeObject(bucketName, objectName); 689 | 690 | await for (var chunk in minio.listObjects(bucketName)) { 691 | expect(chunk.objects.map((e) => e.key).contains(objectName), isFalse); 692 | } 693 | }); 694 | 695 | test('fails on invalid bucket', () { 696 | expect( 697 | () async => await minio.removeObject('$bucketName-invalid', objectName), 698 | throwsA(isA()), 699 | ); 700 | }); 701 | 702 | test('does not throw on invalid object', () async { 703 | await minio.removeObject(bucketName, '$objectName-invalid'); 704 | }); 705 | }); 706 | } 707 | 708 | void testListObjects() { 709 | group('listAllObjects()', () { 710 | final minio = getMinioClient(); 711 | final bucketName = uniqueName(); 712 | final objectName = uniqueName(); 713 | final objectNameUtf8 = '${uniqueName()}文件ファイル。ㄴㅁㄴ'; 714 | final data = Uint8List.fromList([1, 2, 3, 4, 5]); 715 | 716 | setUpAll(() async { 717 | await minio.makeBucket(bucketName); 718 | await minio.putObject(bucketName, objectName, Stream.value(data)); 719 | await minio.putObject(bucketName, objectNameUtf8, Stream.value(data)); 720 | }); 721 | 722 | tearDownAll(() async { 723 | await minio.removeObject(bucketName, objectName); 724 | await minio.removeObject(bucketName, objectNameUtf8); 725 | await minio.removeBucket(bucketName); 726 | }); 727 | 728 | test('succeeds', () async { 729 | final result = await minio.listAllObjects(bucketName); 730 | print(result); 731 | expect(result.objects.map((e) => e.key).contains(objectName), isTrue); 732 | expect(result.objects.map((e) => e.key).contains(objectNameUtf8), isTrue); 733 | }); 734 | 735 | test('fails on invalid bucket', () { 736 | expect( 737 | () async => await minio.listAllObjects('$bucketName-invalid'), 738 | throwsA(isA()), 739 | ); 740 | }); 741 | }); 742 | 743 | group('listAllObjects() works when prefix contains spaces', () { 744 | final minio = getMinioClient(); 745 | final bucket = uniqueName(); 746 | final object = 'new folder/new file.txt'; 747 | final data = Uint8List.fromList([1, 2, 3, 4, 5]); 748 | 749 | setUpAll(() async { 750 | await minio.makeBucket(bucket); 751 | await minio.putObject(bucket, object, Stream.value(data)); 752 | }); 753 | 754 | tearDownAll(() async { 755 | await minio.removeObject(bucket, object); 756 | await minio.removeBucket(bucket); 757 | }); 758 | 759 | test('succeeds', () async { 760 | final result = await minio.listAllObjects(bucket, prefix: 'new folder/'); 761 | expect(result.objects.map((e) => e.key).contains(object), isTrue); 762 | }); 763 | }); 764 | 765 | group('listAllObjects() works when prefix contains utf-8 characters', () { 766 | final minio = getMinioClient(); 767 | final bucket = uniqueName(); 768 | final prefix = '🍎🌰🍌🍓/文件夹 1 2/'; 769 | final object = '${prefix}new file.txt'; 770 | final data = Uint8List.fromList([1, 2, 3, 4, 5]); 771 | 772 | setUpAll(() async { 773 | await minio.makeBucket(bucket); 774 | await minio.putObject(bucket, object, Stream.value(data)); 775 | }); 776 | 777 | tearDownAll(() async { 778 | await minio.removeObject(bucket, object); 779 | await minio.removeBucket(bucket); 780 | }); 781 | 782 | test('succeeds', () async { 783 | final result = await minio.listAllObjects(bucket, prefix: prefix); 784 | print(result); 785 | expect(result.objects.map((e) => e.key).contains(object), isTrue); 786 | }); 787 | }); 788 | } 789 | -------------------------------------------------------------------------------- /lib/src/minio.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: deprecated_member_use 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'dart:typed_data'; 6 | 7 | import 'package:minio/src/minio_client.dart'; 8 | import 'package:minio/src/minio_errors.dart'; 9 | import 'package:minio/src/minio_helpers.dart'; 10 | import 'package:minio/src/minio_models.dart'; 11 | import 'package:minio/src/minio_models_generated.dart'; 12 | import 'package:minio/src/minio_poller.dart'; 13 | import 'package:minio/src/minio_sign.dart'; 14 | import 'package:minio/src/minio_stream.dart'; 15 | import 'package:minio/src/minio_uploader.dart'; 16 | import 'package:minio/src/utils.dart'; 17 | import 'package:xml/xml.dart' as xml; 18 | import 'package:xml/xml.dart' show XmlElement; 19 | 20 | class Minio { 21 | /// Initializes a new client object. 22 | Minio({ 23 | required this.endPoint, 24 | required this.accessKey, 25 | required this.secretKey, 26 | int? port, 27 | this.useSSL = true, 28 | this.sessionToken, 29 | this.region, 30 | this.pathStyle, 31 | this.enableTrace = false, 32 | }) : port = port ?? implyPort(useSSL) { 33 | if (!isValidEndpoint(endPoint)) { 34 | throw MinioInvalidEndpointError( 35 | 'End point $endPoint is not a valid domain or ip address', 36 | ); 37 | } 38 | 39 | if (!isValidPort(this.port)) { 40 | throw MinioInvalidPortError( 41 | 'Invalid port number ${this.port}', 42 | ); 43 | } 44 | 45 | _client = MinioClient(this); 46 | } 47 | 48 | /// default part size for multipart uploads. 49 | final partSize = 64 * 1024 * 1024; 50 | 51 | /// maximum part size for multipart uploads. 52 | final maximumPartSize = 5 * 1024 * 1024 * 1024; 53 | 54 | /// maximum object size (5TB) 55 | final maxObjectSize = 5 * 1024 * 1024 * 1024 * 1024; 56 | 57 | /// endPoint is a host name or an IP address. 58 | /// 59 | /// For example: 60 | /// - play.min.io 61 | /// - 1.2.3.4 62 | final String endPoint; 63 | 64 | /// TCP/IP port number. This input is optional. Default value set to 80 for HTTP and 443 for HTTPs. 65 | final int port; 66 | 67 | /// If set to true, https is used instead of http. Default is true. 68 | final bool useSSL; 69 | 70 | /// accessKey is like user-id that uniquely identifies your account. 71 | final String accessKey; 72 | 73 | /// secretKey is the password to your account. 74 | final String secretKey; 75 | 76 | /// Set this value to provide x-amz-security-token (AWS S3 specific). (Optional) 77 | final String? sessionToken; 78 | 79 | /// Set this value to override region cache. (Optional) 80 | final String? region; 81 | 82 | /// Set this value to override default access behavior (path) for non AWS endpoints. Default is true. (Optional) 83 | final bool? pathStyle; 84 | 85 | /// Set this value to enable tracing. (Optional) 86 | final bool enableTrace; 87 | 88 | late MinioClient _client; 89 | final _regionMap = {}; 90 | 91 | /// Checks if a bucket exists. 92 | /// 93 | /// Returns `true` only if the [bucket] exists and you have the permission 94 | /// to access it. Returns `false` if the [bucket] does not exist or you 95 | /// don't have the permission to access it. 96 | Future bucketExists(String bucket) async { 97 | MinioInvalidBucketNameError.check(bucket); 98 | try { 99 | final response = await _client.request(method: 'HEAD', bucket: bucket); 100 | validate(response); 101 | return response.statusCode == 200; 102 | } on MinioS3Error catch (e) { 103 | final code = e.error?.code; 104 | if (code == 'NoSuchBucket' || code == 'NotFound' || code == 'Not Found') { 105 | return false; 106 | } 107 | rethrow; 108 | } on StateError catch (e) { 109 | // Insight from testing: in most cases, AWS S3 returns the HTTP status code 110 | // 404 when a bucket does not exist. Whereas in other cases, when the bucket 111 | // does not exist, S3 returns the HTTP status code 301 Redirect instead of 112 | // status code 404 as officially documented. Then, this redirect response 113 | // lacks the HTTP header `location` which causes this exception in Dart's 114 | // HTTP library (`http_impl.dart`). 115 | if (e.message == 'Response has no Location header for redirect') { 116 | return false; 117 | } 118 | rethrow; 119 | } 120 | } 121 | 122 | int _calculatePartSize(int size) { 123 | assert(size >= 0); 124 | 125 | if (size > maxObjectSize) { 126 | throw ArgumentError('size should not be more than $maxObjectSize'); 127 | } 128 | 129 | var partSize = this.partSize; 130 | while (true) { 131 | if ((partSize * 10000) > size) { 132 | return partSize; 133 | } 134 | partSize += 16 * 1024 * 1024; 135 | } 136 | } 137 | 138 | /// Complete the multipart upload. After all the parts are uploaded issuing 139 | /// this call will aggregate the parts on the server into a single object. 140 | Future completeMultipartUpload( 141 | String bucket, 142 | String object, 143 | String uploadId, 144 | List parts, 145 | ) async { 146 | MinioInvalidBucketNameError.check(bucket); 147 | MinioInvalidObjectNameError.check(object); 148 | 149 | var queries = {'uploadId': uploadId}; 150 | var payload = CompleteMultipartUpload(parts).toXml().toString(); 151 | 152 | final resp = await _client.request( 153 | method: 'POST', 154 | bucket: bucket, 155 | object: object, 156 | queries: queries, 157 | payload: payload, 158 | ); 159 | validate(resp, expect: 200); 160 | 161 | final node = xml.XmlDocument.parse(resp.body); 162 | final errorNode = node.findAllElements('Error'); 163 | if (errorNode.isNotEmpty) { 164 | final error = Error.fromXml(errorNode.first); 165 | throw MinioS3Error(error.message, error, resp); 166 | } 167 | 168 | final etag = node.findAllElements('ETag').first.text; 169 | return etag; 170 | } 171 | 172 | /// Copy the object. 173 | Future copyObject( 174 | String bucket, 175 | String object, 176 | String srcObject, [ 177 | CopyConditions? conditions, 178 | ]) async { 179 | MinioInvalidBucketNameError.check(bucket); 180 | MinioInvalidObjectNameError.check(object); 181 | MinioInvalidObjectNameError.check(srcObject); 182 | 183 | final headers = {}; 184 | headers['x-amz-copy-source'] = srcObject; 185 | 186 | if (conditions != null) { 187 | if (conditions.modified != null) { 188 | headers['x-amz-copy-source-if-modified-since'] = conditions.modified!; 189 | } 190 | if (conditions.unmodified != null) { 191 | headers['x-amz-copy-source-if-unmodified-since'] = 192 | conditions.unmodified!; 193 | } 194 | if (conditions.matchETag != null) { 195 | headers['x-amz-copy-source-if-match'] = conditions.matchETag!; 196 | } 197 | if (conditions.matchETagExcept != null) { 198 | headers['x-amz-copy-source-if-none-match'] = 199 | conditions.matchETagExcept!; 200 | } 201 | } 202 | 203 | final resp = await _client.request( 204 | method: 'PUT', 205 | bucket: bucket, 206 | object: object, 207 | headers: headers, 208 | ); 209 | 210 | validate(resp); 211 | 212 | final node = xml.XmlDocument.parse(resp.body); 213 | final result = CopyObjectResult.fromXml(node.rootElement); 214 | result.eTag = trimDoubleQuote(result.eTag!); 215 | return result; 216 | } 217 | 218 | /// Find uploadId of an incomplete upload. 219 | Future findUploadId(String bucket, String object) async { 220 | MinioInvalidBucketNameError.check(bucket); 221 | MinioInvalidObjectNameError.check(object); 222 | 223 | MultipartUpload? latestUpload; 224 | String? keyMarker; 225 | String? uploadIdMarker; 226 | bool? isTruncated = false; 227 | 228 | do { 229 | final result = await listIncompleteUploadsQuery( 230 | bucket, 231 | object, 232 | keyMarker, 233 | uploadIdMarker, 234 | '', 235 | ); 236 | for (var upload in result.uploads) { 237 | if (upload.key != object) continue; 238 | if (latestUpload == null || 239 | upload.initiated!.isAfter(latestUpload.initiated!)) { 240 | latestUpload = upload; 241 | } 242 | } 243 | keyMarker = result.nextKeyMarker; 244 | uploadIdMarker = result.nextUploadIdMarker; 245 | isTruncated = result.isTruncated; 246 | } while (isTruncated!); 247 | 248 | return latestUpload?.uploadId; 249 | } 250 | 251 | /// Return the list of notification configurations stored 252 | /// in the S3 provider 253 | Future getBucketNotification(String bucket) async { 254 | MinioInvalidBucketNameError.check(bucket); 255 | 256 | final resp = await _client.request( 257 | method: 'GET', 258 | bucket: bucket, 259 | resource: 'notification', 260 | ); 261 | 262 | validate(resp, expect: 200); 263 | 264 | final node = xml.XmlDocument.parse(resp.body); 265 | return NotificationConfiguration.fromXml(node.rootElement); 266 | } 267 | 268 | /// Get the bucket policy associated with the specified bucket. If `objectPrefix` 269 | /// is not empty, the bucket policy will be filtered based on object permissions 270 | /// as well. 271 | Future?> getBucketPolicy(bucket) async { 272 | MinioInvalidBucketNameError.check(bucket); 273 | 274 | final resp = await _client.request( 275 | method: 'GET', 276 | bucket: bucket, 277 | resource: 'policy', 278 | ); 279 | 280 | validate(resp, expect: 200); 281 | 282 | return json.decode(resp.body); 283 | } 284 | 285 | /// Gets the region of [bucket]. The region is cached for subsequent calls. 286 | Future getBucketRegion(String bucket) async { 287 | MinioInvalidBucketNameError.check(bucket); 288 | 289 | if (region != null) { 290 | return region!; 291 | } 292 | 293 | if (_regionMap.containsKey(bucket)) { 294 | return _regionMap[bucket]!; 295 | } 296 | 297 | final resp = await _client.request( 298 | method: 'GET', 299 | bucket: bucket, 300 | region: 'us-east-1', 301 | queries: {'location': null}, 302 | ); 303 | 304 | validate(resp); 305 | 306 | final node = xml.XmlDocument.parse(resp.body); 307 | 308 | var location = node.findAllElements('LocationConstraint').first.text; 309 | // if (location == null || location.isEmpty) { 310 | if (location.isEmpty) { 311 | location = 'us-east-1'; 312 | } 313 | 314 | _regionMap[bucket] = location; 315 | return location; 316 | } 317 | 318 | /// get a readable stream of the object content. 319 | Future getObject(String bucket, String object) { 320 | return getPartialObject(bucket, object, null, null); 321 | } 322 | 323 | /// get a readable stream of the partial object content. 324 | Future getPartialObject( 325 | String bucket, 326 | String object, [ 327 | int? offset, 328 | int? length, 329 | ]) async { 330 | assert(offset == null || offset >= 0); 331 | assert(length == null || length > 0); 332 | 333 | MinioInvalidBucketNameError.check(bucket); 334 | MinioInvalidObjectNameError.check(object); 335 | 336 | String? range; 337 | if (offset != null || length != null) { 338 | if (offset != null) { 339 | range = 'bytes=$offset-'; 340 | } else { 341 | range = 'bytes=0-'; 342 | offset = 0; 343 | } 344 | if (length != null) { 345 | range += '${(length + offset) - 1}'; 346 | } 347 | } 348 | 349 | final headers = range != null ? {'range': range} : null; 350 | final expectedStatus = range != null ? 206 : 200; 351 | 352 | final resp = await _client.requestStream( 353 | method: 'GET', 354 | bucket: bucket, 355 | object: object, 356 | headers: headers, 357 | ); 358 | 359 | await validateStreamed(resp, expect: expectedStatus); 360 | 361 | return MinioByteStream.fromStream( 362 | stream: resp.stream, 363 | contentLength: resp.contentLength, 364 | ); 365 | } 366 | 367 | /// Initiate a new multipart upload. 368 | Future initiateNewMultipartUpload( 369 | String bucket, 370 | String object, 371 | Map? metaData, 372 | ) async { 373 | MinioInvalidBucketNameError.check(bucket); 374 | MinioInvalidObjectNameError.check(object); 375 | 376 | final resp = await _client.request( 377 | method: 'POST', 378 | bucket: bucket, 379 | object: object, 380 | headers: metaData, 381 | resource: 'uploads', 382 | ); 383 | 384 | validate(resp, expect: 200); 385 | 386 | final node = xml.XmlDocument.parse(resp.body); 387 | return node.findAllElements('UploadId').first.text; 388 | } 389 | 390 | /// Returns a stream that emits objects that are partially uploaded. 391 | Stream listIncompleteUploads( 392 | String bucket, 393 | String prefix, [ 394 | bool recursive = false, 395 | ]) async* { 396 | MinioInvalidBucketNameError.check(bucket); 397 | MinioInvalidPrefixError.check(prefix); 398 | 399 | final delimiter = recursive ? '' : '/'; 400 | 401 | String? keyMarker; 402 | String? uploadIdMarker; 403 | var isTruncated = false; 404 | 405 | do { 406 | final result = await listIncompleteUploadsQuery( 407 | bucket, 408 | prefix, 409 | keyMarker, 410 | uploadIdMarker, 411 | delimiter, 412 | ); 413 | for (var upload in result.uploads) { 414 | final parts = listParts(bucket, upload.key!, upload.uploadId!); 415 | final size = 416 | await parts.fold(0, (dynamic acc, item) => acc + item.size); 417 | yield IncompleteUpload(upload: upload, size: size); 418 | } 419 | keyMarker = result.nextKeyMarker; 420 | uploadIdMarker = result.nextUploadIdMarker; 421 | isTruncated = result.isTruncated!; 422 | } while (isTruncated); 423 | } 424 | 425 | /// Called by listIncompleteUploads to fetch a batch of incomplete uploads. 426 | Future listIncompleteUploadsQuery( 427 | String bucket, 428 | String prefix, 429 | String? keyMarker, 430 | String? uploadIdMarker, 431 | String delimiter, 432 | ) async { 433 | MinioInvalidBucketNameError.check(bucket); 434 | MinioInvalidPrefixError.check(prefix); 435 | 436 | var queries = { 437 | 'uploads': null, 438 | 'prefix': prefix, 439 | 'delimiter': delimiter, 440 | }; 441 | 442 | if (keyMarker != null) { 443 | queries['key-marker'] = keyMarker; 444 | } 445 | if (uploadIdMarker != null) { 446 | queries['upload-id-marker'] = uploadIdMarker; 447 | } 448 | 449 | final resp = await _client.request( 450 | method: 'GET', 451 | bucket: bucket, 452 | resource: 'uploads', 453 | queries: queries, 454 | ); 455 | 456 | validate(resp); 457 | 458 | final node = xml.XmlDocument.parse(resp.body); 459 | return ListMultipartUploadsOutput.fromXml(node.root as XmlElement); 460 | } 461 | 462 | /// Listen for notifications on a bucket. Additionally one can provider 463 | /// filters for prefix, suffix and events. There is no prior set bucket notification 464 | /// needed to use this API. **This is an MinIO extension API** where unique identifiers 465 | /// are regitered and unregistered by the server automatically based on incoming requests. 466 | NotificationPoller listenBucketNotification( 467 | String bucket, { 468 | String? prefix, 469 | String? suffix, 470 | List? events, 471 | }) { 472 | MinioInvalidBucketNameError.check(bucket); 473 | 474 | final poller = NotificationPoller(_client, bucket, prefix, suffix, events); 475 | 476 | poller.start(); 477 | 478 | return poller; 479 | } 480 | 481 | /// List of buckets created. 482 | Future> listBuckets() async { 483 | final resp = await _client.request( 484 | method: 'GET', 485 | region: region ?? 'us-east-1', 486 | ); 487 | validate(resp); 488 | final bucketsNode = 489 | xml.XmlDocument.parse(resp.body).findAllElements('Buckets').first; 490 | return bucketsNode.children 491 | .map((n) => Bucket.fromXml(n as XmlElement)) 492 | .toList(); 493 | } 494 | 495 | /// Returns all [Object]s in a bucket. 496 | /// To list objects in a bucket with prefix, set [prefix] to the desired prefix. 497 | Stream listObjects( 498 | String bucket, { 499 | String prefix = '', 500 | bool recursive = false, 501 | }) async* { 502 | MinioInvalidBucketNameError.check(bucket); 503 | MinioInvalidPrefixError.check(prefix); 504 | final delimiter = recursive ? '' : '/'; 505 | 506 | String? marker; 507 | var isTruncated = false; 508 | 509 | do { 510 | final resp = await listObjectsQuery( 511 | bucket, 512 | prefix, 513 | marker, 514 | delimiter, 515 | 1000, 516 | ); 517 | isTruncated = resp.isTruncated!; 518 | marker = resp.nextMarker; 519 | yield ListObjectsResult( 520 | objects: resp.contents!, 521 | prefixes: resp.commonPrefixes.map((e) => e.prefix!).toList(), 522 | ); 523 | } while (isTruncated); 524 | } 525 | 526 | /// Returns all [Object]s in a bucket. This is a shortcut for [listObjects]. 527 | /// Use [listObjects] to list buckets with a large number of objects. 528 | Future listAllObjects( 529 | String bucket, { 530 | String prefix = '', 531 | bool recursive = false, 532 | }) async { 533 | final chunks = listObjects(bucket, prefix: prefix, recursive: recursive); 534 | final objects = []; 535 | final prefixes = []; 536 | await for (final chunk in chunks) { 537 | objects.addAll(chunk.objects); 538 | prefixes.addAll(chunk.prefixes); 539 | } 540 | return ListObjectsResult( 541 | objects: objects, 542 | prefixes: prefixes, 543 | ); 544 | } 545 | 546 | /// list a batch of objects 547 | Future listObjectsQuery( 548 | String bucket, 549 | String prefix, 550 | String? marker, 551 | String delimiter, 552 | int? maxKeys, 553 | ) async { 554 | MinioInvalidBucketNameError.check(bucket); 555 | MinioInvalidPrefixError.check(prefix); 556 | 557 | final queries = {}; 558 | queries['prefix'] = prefix; 559 | queries['delimiter'] = delimiter; 560 | 561 | if (marker != null) { 562 | queries['marker'] = marker; 563 | } 564 | 565 | if (maxKeys != null) { 566 | maxKeys = maxKeys >= 1000 ? 1000 : maxKeys; 567 | queries['max-keys'] = maxKeys.toString(); 568 | } 569 | 570 | final resp = await _client.request( 571 | method: 'GET', 572 | bucket: bucket, 573 | queries: queries, 574 | ); 575 | 576 | validate(resp); 577 | 578 | final node = xml.XmlDocument.parse(resp.body); 579 | final isTruncated = getNodeProp(node.rootElement, 'IsTruncated')!.text; 580 | final nextMarker = getNodeProp(node.rootElement, 'NextMarker')?.text; 581 | final objs = node.findAllElements('Contents').map((c) => Object.fromXml(c)); 582 | final prefixes = node 583 | .findAllElements('CommonPrefixes') 584 | .map((c) => CommonPrefix.fromXml(c)); 585 | 586 | return ListObjectsOutput() 587 | ..contents = objs.toList() 588 | ..commonPrefixes = prefixes.toList() 589 | ..isTruncated = isTruncated.toLowerCase() == 'true' 590 | ..nextMarker = nextMarker; 591 | } 592 | 593 | /// Returns all [Object]s in a bucket. 594 | /// To list objects in a bucket with prefix, set [prefix] to the desired prefix. 595 | /// This uses ListObjectsV2 in the S3 API. For backward compatibility, use 596 | /// [listObjects] instead. 597 | Stream listObjectsV2( 598 | String bucket, { 599 | String prefix = '', 600 | bool recursive = false, 601 | String? startAfter, 602 | }) async* { 603 | MinioInvalidBucketNameError.check(bucket); 604 | MinioInvalidPrefixError.check(prefix); 605 | final delimiter = recursive ? '' : '/'; 606 | 607 | bool? isTruncated = false; 608 | String? continuationToken; 609 | 610 | do { 611 | final resp = await listObjectsV2Query( 612 | bucket, 613 | prefix, 614 | continuationToken, 615 | delimiter, 616 | 1000, 617 | startAfter, 618 | ); 619 | isTruncated = resp.isTruncated; 620 | continuationToken = resp.nextContinuationToken; 621 | yield ListObjectsResult( 622 | objects: resp.contents!, 623 | prefixes: resp.commonPrefixes.map((e) => e.prefix!).toList(), 624 | ); 625 | } while (isTruncated!); 626 | } 627 | 628 | /// Returns all [Object]s in a bucket. This is a shortcut for [listObjectsV2]. 629 | /// Use [listObjects] to list buckets with a large number of objects. 630 | /// This uses ListObjectsV2 in the S3 API. For backward compatibility, use 631 | /// [listAllObjects] instead. 632 | Future listAllObjectsV2( 633 | String bucket, { 634 | String prefix = '', 635 | bool recursive = false, 636 | }) async { 637 | final chunks = listObjects(bucket, prefix: prefix, recursive: recursive); 638 | final objects = []; 639 | final prefixes = []; 640 | await for (final chunk in chunks) { 641 | objects.addAll(chunk.objects); 642 | prefixes.addAll(chunk.prefixes); 643 | } 644 | return ListObjectsResult( 645 | objects: objects, 646 | prefixes: prefixes, 647 | ); 648 | } 649 | 650 | /// listObjectsV2Query - (List Objects V2) - List some or all (up to 1000) of the objects in a bucket. 651 | Future listObjectsV2Query( 652 | String bucket, 653 | String prefix, 654 | String? continuationToken, 655 | String delimiter, 656 | int? maxKeys, 657 | String? startAfter, 658 | ) async { 659 | MinioInvalidBucketNameError.check(bucket); 660 | MinioInvalidPrefixError.check(prefix); 661 | 662 | final queries = {}; 663 | queries['prefix'] = prefix; 664 | queries['delimiter'] = delimiter; 665 | queries['list-type'] = '2'; 666 | 667 | if (continuationToken != null) { 668 | queries['continuation-token'] = continuationToken; 669 | } 670 | 671 | if (startAfter != null) { 672 | queries['start-after'] = startAfter; 673 | } 674 | 675 | if (maxKeys != null) { 676 | maxKeys = maxKeys >= 1000 ? 1000 : maxKeys; 677 | queries['max-keys'] = maxKeys.toString(); 678 | } 679 | 680 | final resp = await _client.request( 681 | method: 'GET', 682 | bucket: bucket, 683 | queries: queries, 684 | ); 685 | 686 | validate(resp); 687 | 688 | final node = xml.XmlDocument.parse(resp.body); 689 | final isTruncated = getNodeProp(node.rootElement, 'IsTruncated')!.text; 690 | final nextContinuationToken = 691 | getNodeProp(node.rootElement, 'NextContinuationToken')?.text; 692 | final objs = node.findAllElements('Contents').map((c) => Object.fromXml(c)); 693 | final prefixes = node 694 | .findAllElements('CommonPrefixes') 695 | .map((c) => CommonPrefix.fromXml(c)); 696 | 697 | return ListObjectsV2Output() 698 | ..contents = objs.toList() 699 | ..commonPrefixes = prefixes.toList() 700 | ..isTruncated = isTruncated.toLowerCase() == 'true' 701 | ..nextContinuationToken = nextContinuationToken; 702 | } 703 | 704 | /// Get part-info of all parts of an incomplete upload specified by uploadId. 705 | Stream listParts( 706 | String bucket, 707 | String object, 708 | String uploadId, 709 | ) async* { 710 | MinioInvalidBucketNameError.check(bucket); 711 | MinioInvalidObjectNameError.check(object); 712 | 713 | var marker = 0; 714 | var isTruncated = false; 715 | do { 716 | final result = await listPartsQuery(bucket, object, uploadId, marker); 717 | marker = result.nextPartNumberMarker; 718 | isTruncated = result.isTruncated; 719 | yield* Stream.fromIterable(result.parts); 720 | } while (isTruncated); 721 | } 722 | 723 | /// Called by listParts to fetch a batch of part-info 724 | Future listPartsQuery( 725 | String? bucket, 726 | String? object, 727 | String? uploadId, 728 | int? marker, 729 | ) async { 730 | var queries = {'uploadId': uploadId}; 731 | 732 | if (marker != null && marker != 0) { 733 | queries['part-number-marker'] = marker.toString(); 734 | } 735 | 736 | final resp = await _client.request( 737 | method: 'GET', 738 | bucket: bucket, 739 | object: object, 740 | queries: queries, 741 | ); 742 | 743 | validate(resp); 744 | 745 | final node = xml.XmlDocument.parse(resp.body); 746 | return ListPartsOutput.fromXml(node.root as XmlElement); 747 | } 748 | 749 | /// Creates the bucket [bucket]. 750 | Future makeBucket(String bucket, [String? region]) async { 751 | MinioInvalidBucketNameError.check(bucket); 752 | if (this.region != null && region != null && this.region != region) { 753 | throw MinioInvalidArgumentError( 754 | 'Configured region ${this.region}, requested $region', 755 | ); 756 | } 757 | 758 | region ??= this.region ?? 'us-east-1'; 759 | final payload = region == 'us-east-1' 760 | ? '' 761 | : CreateBucketConfiguration(region).toXml().toString(); 762 | 763 | final resp = await _client.request( 764 | method: 'PUT', 765 | bucket: bucket, 766 | region: region, 767 | payload: payload, 768 | ); 769 | 770 | validate(resp); 771 | // return resp.body; 772 | } 773 | 774 | /// Generate a presigned URL for GET 775 | /// 776 | /// - [bucketName]: name of the bucket 777 | /// - [objectName]: name of the object 778 | /// - [expires]: expiry in seconds (optional, default 7 days) 779 | /// - [respHeaders]: response headers to override (optional) 780 | /// - [requestDate]: A date object, the url will be issued at (optional) 781 | Future presignedGetObject( 782 | String bucket, 783 | String object, { 784 | int? expires, 785 | Map? respHeaders, 786 | DateTime? requestDate, 787 | }) { 788 | MinioInvalidBucketNameError.check(bucket); 789 | MinioInvalidObjectNameError.check(object); 790 | 791 | return presignedUrl( 792 | 'GET', 793 | bucket, 794 | object, 795 | expires: expires, 796 | reqParams: respHeaders, 797 | requestDate: requestDate, 798 | ); 799 | } 800 | 801 | /// presignedPostPolicy can be used in situations where we want more control on the upload than what 802 | /// presignedPutObject() provides. i.e Using presignedPostPolicy we will be able to put policy restrictions 803 | /// on the object's `name` `bucket` `expiry` `Content-Type` 804 | Future presignedPostPolicy(PostPolicy postPolicy) async { 805 | if (_client.anonymous) { 806 | throw MinioAnonymousRequestError( 807 | 'Presigned POST policy cannot be generated for anonymous requests', 808 | ); 809 | } 810 | 811 | final region = await getBucketRegion(postPolicy.formData['bucket']!); 812 | var date = DateTime.now().toUtc(); 813 | var dateStr = makeDateLong(date); 814 | 815 | if (postPolicy.policy['expiration'] == null) { 816 | // 'expiration' is mandatory field for S3. 817 | // Set default expiration date of 7 days. 818 | var expires = DateTime.now().toUtc(); 819 | expires.add(const Duration(days: 7)); 820 | postPolicy.setExpires(expires); 821 | } 822 | 823 | postPolicy.policy['conditions'].add(['eq', r'$x-amz-date', dateStr]); 824 | postPolicy.formData['x-amz-date'] = dateStr; 825 | 826 | postPolicy.policy['conditions'] 827 | .add(['eq', r'$x-amz-algorithm', 'AWS4-HMAC-SHA256']); 828 | postPolicy.formData['x-amz-algorithm'] = 'AWS4-HMAC-SHA256'; 829 | 830 | postPolicy.policy['conditions'].add( 831 | ['eq', r'$x-amz-credential', '$accessKey/${getScope(region, date)}'], 832 | ); 833 | 834 | postPolicy.formData['x-amz-credential'] = 835 | '$accessKey/${getScope(region, date)}'; 836 | 837 | if (sessionToken != null) { 838 | postPolicy.policy['conditions'] 839 | .add(['eq', r'$x-amz-security-token', sessionToken]); 840 | } 841 | 842 | final policyBase64 = jsonBase64(postPolicy.policy); 843 | postPolicy.formData['policy'] = policyBase64; 844 | 845 | final signature = 846 | postPresignSignatureV4(region, date, secretKey, policyBase64); 847 | 848 | postPolicy.formData['x-amz-signature'] = signature; 849 | final url = _client 850 | .getBaseRequest( 851 | 'POST', 852 | postPolicy.formData['bucket'], 853 | null, 854 | region, 855 | null, 856 | null, 857 | null, 858 | null, 859 | ) 860 | .url; 861 | var portStr = (port == 80 || port == 443) ? '' : ':$port'; 862 | var urlStr = '${url.scheme}://${url.host}$portStr${url.path}'; 863 | return PostPolicyResult(postURL: urlStr, formData: postPolicy.formData); 864 | } 865 | 866 | /// Generate a presigned URL for PUT. 867 | /// Using this URL, the browser can upload to S3 only with the specified object name. 868 | /// 869 | /// - [bucketName]: name of the bucket 870 | /// - [objectName]: name of the object 871 | /// - [expires]: expiry in seconds (optional, default 7 days) 872 | Future presignedPutObject( 873 | String bucket, 874 | String object, { 875 | int? expires, 876 | }) { 877 | MinioInvalidBucketNameError.check(bucket); 878 | MinioInvalidObjectNameError.check(object); 879 | return presignedUrl('PUT', bucket, object, expires: expires); 880 | } 881 | 882 | /// Generate a generic presigned URL which can be 883 | /// used for HTTP methods GET, PUT, HEAD and DELETE 884 | /// 885 | /// - [method]: name of the HTTP method 886 | /// - [bucketName]: name of the bucket 887 | /// - [objectName]: name of the object 888 | /// - [expires]: expiry in seconds (optional, default 7 days) 889 | /// - [reqParams]: request parameters (optional) 890 | /// - [requestDate]: A date object, the url will be issued at (optional) 891 | Future presignedUrl( 892 | String method, 893 | String bucket, 894 | String object, { 895 | int? expires, 896 | String? resource, 897 | Map? reqParams, 898 | DateTime? requestDate, 899 | }) async { 900 | MinioInvalidBucketNameError.check(bucket); 901 | MinioInvalidObjectNameError.check(object); 902 | 903 | if (expires != null && expires < 0) { 904 | throw MinioInvalidArgumentError('invalid expire time value: $expires'); 905 | } 906 | 907 | expires ??= expires = 24 * 60 * 60 * 7; // 7 days in seconds 908 | reqParams ??= {}; 909 | requestDate ??= DateTime.now().toUtc(); 910 | 911 | final region = await getBucketRegion(bucket); 912 | final request = _client.getBaseRequest( 913 | method, 914 | bucket, 915 | object, 916 | region, 917 | resource, 918 | reqParams, 919 | {}, 920 | null, 921 | ); 922 | return presignSignatureV4(this, request, region, requestDate, expires); 923 | } 924 | 925 | /// Uploads the object. Returns the ETag of the uploaded object. 926 | Future putObject( 927 | String bucket, 928 | String object, 929 | Stream data, { 930 | int? size, 931 | int? chunkSize, 932 | Map? metadata, 933 | void Function(int)? onProgress, 934 | }) async { 935 | MinioInvalidBucketNameError.check(bucket); 936 | MinioInvalidObjectNameError.check(object); 937 | 938 | if (size != null && size < 0) { 939 | throw MinioInvalidArgumentError('invalid size value: $size'); 940 | } 941 | 942 | if (chunkSize != null && chunkSize < 5 * 1024 * 1024) { 943 | throw MinioInvalidArgumentError('Minimum chunk size is 5MB'); 944 | } 945 | 946 | metadata = prependXAMZMeta(metadata ?? {}); 947 | 948 | final partSize = chunkSize ?? _calculatePartSize(size ?? maxObjectSize); 949 | 950 | final uploader = MinioUploader( 951 | this, 952 | _client, 953 | bucket, 954 | object, 955 | partSize, 956 | metadata, 957 | onProgress, 958 | ); 959 | final chunker = MinChunkSize(partSize); 960 | final etag = await data.transform(chunker).pipe(uploader); 961 | return etag.toString(); 962 | } 963 | 964 | /// Remove all bucket notification 965 | Future removeAllBucketNotification(String bucket) async { 966 | await setBucketNotification( 967 | bucket, 968 | NotificationConfiguration(null, null, null), 969 | ); 970 | } 971 | 972 | /// Remove a bucket. 973 | Future removeBucket(String bucket) async { 974 | MinioInvalidBucketNameError.check(bucket); 975 | 976 | final resp = await _client.request( 977 | method: 'DELETE', 978 | bucket: bucket, 979 | ); 980 | 981 | validate(resp, expect: 204); 982 | _regionMap.remove(bucket); 983 | } 984 | 985 | /// Remove the partially uploaded object. 986 | Future removeIncompleteUpload(String bucket, String object) async { 987 | MinioInvalidBucketNameError.check(bucket); 988 | MinioInvalidObjectNameError.check(object); 989 | 990 | final uploadId = await findUploadId(bucket, object); 991 | if (uploadId == null) return; 992 | 993 | final resp = await _client.request( 994 | method: 'DELETE', 995 | bucket: bucket, 996 | object: object, 997 | queries: {'uploadId': uploadId}, 998 | ); 999 | 1000 | validate(resp, expect: 204); 1001 | } 1002 | 1003 | /// Remove the specified object. 1004 | Future removeObject(String bucket, String object) async { 1005 | MinioInvalidBucketNameError.check(bucket); 1006 | MinioInvalidObjectNameError.check(object); 1007 | 1008 | final resp = await _client.request( 1009 | method: 'DELETE', 1010 | bucket: bucket, 1011 | object: object, 1012 | ); 1013 | 1014 | validate(resp, expect: 204); 1015 | } 1016 | 1017 | /// Remove all the objects residing in the objectsList. 1018 | Future removeObjects(String bucket, List objects) async { 1019 | MinioInvalidBucketNameError.check(bucket); 1020 | 1021 | final bunches = groupList(objects, 1000); 1022 | 1023 | for (var bunch in bunches) { 1024 | final payload = Delete( 1025 | bunch.map((key) => ObjectIdentifier(key, null)).toList(), 1026 | true, 1027 | ).toXml().toString(); 1028 | 1029 | final headers = {'Content-MD5': md5Base64(payload)}; 1030 | 1031 | await _client.request( 1032 | method: 'POST', 1033 | bucket: bucket, 1034 | resource: 'delete', 1035 | headers: headers, 1036 | payload: payload, 1037 | ); 1038 | } 1039 | } 1040 | 1041 | // Remove all the notification configurations in the S3 provider 1042 | Future setBucketNotification( 1043 | String bucket, 1044 | NotificationConfiguration config, 1045 | ) async { 1046 | MinioInvalidBucketNameError.check(bucket); 1047 | 1048 | final resp = await _client.request( 1049 | method: 'PUT', 1050 | bucket: bucket, 1051 | resource: 'notification', 1052 | payload: config.toXml().toString(), 1053 | ); 1054 | 1055 | validate(resp, expect: 200); 1056 | } 1057 | 1058 | /// Set the bucket policy on the specified bucket. 1059 | /// 1060 | /// [policy] is detailed [here](https://docs.aws.amazon.com/AmazonS3/latest/dev/example-bucket-policies.html). 1061 | Future setBucketPolicy( 1062 | String bucket, [ 1063 | Map? policy, 1064 | ]) async { 1065 | MinioInvalidBucketNameError.check(bucket); 1066 | 1067 | final method = policy != null ? 'PUT' : 'DELETE'; 1068 | final payload = policy != null ? json.encode(policy) : ''; 1069 | 1070 | final resp = await _client.request( 1071 | method: method, 1072 | bucket: bucket, 1073 | resource: 'policy', 1074 | payload: payload, 1075 | ); 1076 | 1077 | validate(resp, expect: 204); 1078 | } 1079 | 1080 | Future setObjectACL(String bucket, String object, String policy) async { 1081 | MinioInvalidBucketNameError.check(bucket); 1082 | MinioInvalidObjectNameError.check(object); 1083 | 1084 | await _client.request( 1085 | method: 'PUT', 1086 | bucket: bucket, 1087 | object: object, 1088 | queries: {'acl': policy}, 1089 | ); 1090 | } 1091 | 1092 | Future getObjectACL(String bucket, String object) async { 1093 | MinioInvalidBucketNameError.check(bucket); 1094 | MinioInvalidObjectNameError.check(object); 1095 | 1096 | final resp = await _client.request( 1097 | method: 'GET', 1098 | bucket: bucket, 1099 | object: object, 1100 | queries: {'acl': ''}, 1101 | ); 1102 | 1103 | validate(resp, expect: 200); 1104 | 1105 | return AccessControlPolicy.fromXml( 1106 | xml.XmlDocument.parse(resp.body) 1107 | .findElements('AccessControlPolicy') 1108 | .first, 1109 | ); 1110 | } 1111 | 1112 | /// Stat information of the object. 1113 | Future statObject( 1114 | String bucket, 1115 | String object, { 1116 | bool retrieveAcls = true, 1117 | }) async { 1118 | MinioInvalidBucketNameError.check(bucket); 1119 | MinioInvalidObjectNameError.check(object); 1120 | 1121 | final resp = await _client.request( 1122 | method: 'HEAD', 1123 | bucket: bucket, 1124 | object: object, 1125 | ); 1126 | 1127 | validate(resp, expect: 200); 1128 | 1129 | var etag = resp.headers['etag']; 1130 | if (etag != null) { 1131 | etag = trimDoubleQuote(etag); 1132 | } 1133 | 1134 | return StatObjectResult( 1135 | etag: etag, 1136 | size: int.parse(resp.headers['content-length']!), 1137 | metaData: extractMetadata(resp.headers), 1138 | lastModified: parseRfc7231Time(resp.headers['last-modified']!), 1139 | acl: retrieveAcls ? await getObjectACL(bucket, object) : null, 1140 | ); 1141 | } 1142 | } 1143 | --------------------------------------------------------------------------------