diff --git a/lib/src/models/yust_file.dart b/lib/src/models/yust_file.dart index bb41a388..fa772ae8 100644 --- a/lib/src/models/yust_file.dart +++ b/lib/src/models/yust_file.dart @@ -1,13 +1,32 @@ +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; +import 'package:collection/collection.dart'; import 'package:json_annotation/json_annotation.dart'; +import '../../yust.dart'; + part 'yust_file.g.dart'; typedef YustFileJson = Map; typedef YustFilesJson = List; +/// The size of the thumbnail. +enum YustFileThumbnailSize { + small; + + /// Converts a JSON string to a [YustFileThumbnailSize]. + /// + /// If the size is not found, [YustFileThumbnailSize.small] is returned. + static YustFileThumbnailSize fromJson(String size) => + YustFileThumbnailSize.values.firstWhereOrNull((e) => e.name == size) ?? + YustFileThumbnailSize.small; + + /// Converts a [YustFileThumbnailSize] to a JSON string. + String toJson() => name; +} + /// A binary file handled by database and file storage. /// A file is stored in Firebase Storage and linked to a document in the database. /// For offline caching a file can also be stored on the device. @@ -31,6 +50,14 @@ class YustFile { /// On mobile devices, this can also be the time the file was uploaded into device cache. DateTime? createdAt; + /// The path to the file in the storage. + String? path; + + /// The thumbnails of the file. + /// + /// Map of thumbnail size to path in the storage. + Map? thumbnails; + /// The binary file. This attribute is used for iOS and Android. For web [bytes] is used instead. @JsonKey(includeFromJson: false, includeToJson: false) File? file; @@ -59,6 +86,10 @@ class YustFile { @JsonKey(includeFromJson: false, includeToJson: false) String? lastError; + /// True if a thumbnail should be created. + @JsonKey(includeFromJson: false, includeToJson: false) + bool? createThumbnail; + /// True if the files should be stored as a Map of hash and file /// inside the linked document. /// @@ -98,8 +129,11 @@ class YustFile { this.linkedDocAttribute, this.processing = false, this.lastError, + this.createThumbnail, this.linkedDocStoresFilesAsMap, this.createdAt, + this.path, + this.thumbnails, bool setCreatedAtToNow = true, }) { if (setCreatedAtToNow) { @@ -124,6 +158,11 @@ class YustFile { : json['createdAt'] is DateTime ? json['createdAt'] as DateTime : DateTime.parse(json['createdAt'] as String), + path: json['path'] as String?, + thumbnails: (json['thumbnails'] as Map?)?.map( + (key, value) => + MapEntry(YustFileThumbnailSize.fromJson(key), value as String), + ), setCreatedAtToNow: false, ); } @@ -141,6 +180,8 @@ class YustFile { name = file.name; hash = file.hash; createdAt = file.createdAt; + path = file.path; + thumbnails = file.thumbnails; } /// Converts the file to JSON for local device. Only relevant attributes are converted. @@ -154,6 +195,7 @@ class YustFile { linkedDocPath: json['linkedDocPath'] as String, linkedDocAttribute: json['linkedDocAttribute'] as String, lastError: json['lastError'] as String?, + createThumbnail: json['createThumbnail'] == 'true', linkedDocStoresFilesAsMap: json['linkedDocStoresFilesAsMap'] == 'true', modifiedAt: json['modifiedAt'] != null ? DateTime.parse(json['modifiedAt'] as String) @@ -161,6 +203,16 @@ class YustFile { createdAt: json['createdAt'] != null ? DateTime.parse(json['createdAt'] as String) : null, + path: json['path'] as String?, + thumbnails: json['thumbnails'] != null + ? (jsonDecode(json['thumbnails'] as String) as Map).map( + (key, value) => MapEntry( + YustFileThumbnailSize.fromJson(key), + value as String, + ), + ) + : null, + setCreatedAtToNow: false, ); } @@ -170,16 +222,22 @@ class YustFile { /// This is used for offline file handling only (Caching on mobile devices) Map toLocalJson() { if (name == null) { - throw ('Error: Each cached file needs a name. Should be unique for each path!'); + throw YustException( + 'Error: Each cached file needs a name. Should be unique for each path!', + ); } if (devicePath == null) { - throw ('Error: Device Path has to be a String.'); + throw YustException('Error: Device Path has to be a String.'); } if (storageFolderPath == null) { - throw ('Error: StorageFolderPath has to be set for a successful upload.'); + throw YustException( + 'Error: StorageFolderPath has to be set for a successful upload.', + ); } if (linkedDocPath == null || linkedDocAttribute == null) { - throw ('Error: linkedDocPath and linkedDocAttribute have to be set for a successful upload.'); + throw YustException( + 'Error: linkedDocPath and linkedDocAttribute have to be set for a successful upload.', + ); } return { 'name': name, @@ -188,14 +246,39 @@ class YustFile { 'linkedDocAttribute': linkedDocAttribute, 'devicePath': devicePath, 'lastError': lastError, + 'createThumbnail': (createThumbnail ?? false).toString(), 'linkedDocStoresFilesAsMap': (linkedDocStoresFilesAsMap ?? false) .toString(), 'modifiedAt': modifiedAt?.toIso8601String(), 'createdAt': createdAt?.toIso8601String(), 'type': type, + 'path': path, + 'thumbnails': thumbnails != null ? jsonEncode(thumbnails) : null, }; } + /// Creates a new file with the same properties but with a new URL. + YustFile copyWithUrl(String? url) => YustFile( + key: key, + name: name, + modifiedAt: modifiedAt, + url: url, + hash: hash, + file: file, + bytes: bytes, + devicePath: devicePath, + storageFolderPath: storageFolderPath, + linkedDocPath: linkedDocPath, + linkedDocAttribute: linkedDocAttribute, + processing: processing, + lastError: lastError, + createThumbnail: createThumbnail, + createdAt: createdAt, + path: path, + thumbnails: thumbnails, + setCreatedAtToNow: false, + ); + dynamic operator [](String key) { switch (key) { case 'name': @@ -206,6 +289,9 @@ class YustFile { return url; case 'createdAt': return createdAt; + case 'path': + return path; + case 'thumbnails': // Thumbnails should not be accessible default: throw ArgumentError(); } @@ -234,4 +320,28 @@ class YustFile { return name!.contains('.') ? name!.split('.').last : ''; } + + bool hasThumbnail() => thumbnails?.isNotEmpty ?? false; + + String? getOriginalUrl() { + final baseUrl = Yust.fileAccessService.originalCdnBaseUrl; + final grant = Yust.fileAccessService.getGrantForFile(this); + + if (baseUrl == null || grant == null || path == null) return url; + + return '${_tryAppendSlash(baseUrl)}$path?${grant.originalSignedUrlPart}'; + } + + String? getThumbnailUrl() { + final baseUrl = Yust.fileAccessService.thumbnailCdnBaseUrl; + final grant = Yust.fileAccessService.getGrantForFile(this); + + if (baseUrl == null || grant == null || !hasThumbnail()) return null; + final thumbnailPath = thumbnails![YustFileThumbnailSize.small]; + + return '${_tryAppendSlash(baseUrl)}$thumbnailPath?${grant.thumbnailSignedUrlPart}'; + } + + String? _tryAppendSlash(String baseUrl) => + baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; } diff --git a/lib/src/models/yust_file.g.dart b/lib/src/models/yust_file.g.dart index 6882baab..61dba742 100644 --- a/lib/src/models/yust_file.g.dart +++ b/lib/src/models/yust_file.g.dart @@ -12,4 +12,6 @@ Map _$YustFileToJson(YustFile instance) => { 'url': instance.url, 'hash': instance.hash, 'createdAt': instance.createdAt?.toIso8601String(), + 'path': instance.path, + 'thumbnails': instance.thumbnails?.map((k, e) => MapEntry(k.toJson(), e)), }; diff --git a/lib/src/models/yust_image.dart b/lib/src/models/yust_image.dart index 9689146d..20b33575 100644 --- a/lib/src/models/yust_image.dart +++ b/lib/src/models/yust_image.dart @@ -22,13 +22,17 @@ class YustImage extends YustFile { super.linkedDocAttribute, super.processing = false, super.lastError, + super.createThumbnail, super.linkedDocStoresFilesAsMap, super.createdAt, + super.path, + super.thumbnails, this.location, }); YustGeoLocation? location; + /// Creates a new image from a file. factory YustImage.fromYustFile(YustFile file) => file is YustImage ? file : YustImage( @@ -45,8 +49,11 @@ class YustImage extends YustFile { linkedDocAttribute: file.linkedDocAttribute, processing: file.processing, lastError: file.lastError, + createThumbnail: file.createThumbnail, linkedDocStoresFilesAsMap: file.linkedDocStoresFilesAsMap, createdAt: file.createdAt, + path: file.path, + thumbnails: file.thumbnails, ); /// Create a list of images from a list of files diff --git a/lib/src/models/yust_image.g.dart b/lib/src/models/yust_image.g.dart index df10ccd3..de3710de 100644 --- a/lib/src/models/yust_image.g.dart +++ b/lib/src/models/yust_image.g.dart @@ -16,6 +16,11 @@ YustImage _$YustImageFromJson(Map json) => YustImage( createdAt: json['createdAt'] == null ? null : DateTime.parse(json['createdAt'] as String), + path: json['path'] as String?, + thumbnails: (json['thumbnails'] as Map?)?.map( + (k, e) => + MapEntry($enumDecode(_$YustFileThumbnailSizeEnumMap, k), e as String), + ), location: json['location'] == null ? null : YustGeoLocation.fromJson( @@ -29,5 +34,9 @@ Map _$YustImageToJson(YustImage instance) => { 'url': instance.url, 'hash': instance.hash, 'createdAt': instance.createdAt?.toIso8601String(), + 'path': instance.path, + 'thumbnails': instance.thumbnails?.map((k, e) => MapEntry(k.toJson(), e)), 'location': instance.location?.toJson(), }; + +const _$YustFileThumbnailSizeEnumMap = {YustFileThumbnailSize.small: 'small'}; diff --git a/lib/src/services/yust_file_access_service.dart b/lib/src/services/yust_file_access_service.dart new file mode 100644 index 00000000..b0674568 --- /dev/null +++ b/lib/src/services/yust_file_access_service.dart @@ -0,0 +1,3 @@ +export './yust_file_access_service_dart.dart' + if (dart.library.ui) './yust_file_access_service_flutter.dart'; +export './yust_file_access_service_interface.dart'; diff --git a/lib/src/services/yust_file_access_service_dart.dart b/lib/src/services/yust_file_access_service_dart.dart new file mode 100644 index 00000000..dd4213be --- /dev/null +++ b/lib/src/services/yust_file_access_service_dart.dart @@ -0,0 +1,72 @@ +import 'package:collection/collection.dart'; + +import '../models/yust_file.dart'; +import '../util/file_access/yust_cdn_configuration.dart'; +import '../util/file_access/yust_file_access_grant.dart'; +import '../util/google_cloud_cdn_helper.dart'; +import 'yust_file_access_service_interface.dart'; + +class YustFileAccessService implements IYustFileAccessService { + @override + final String? originalCdnBaseUrl; + + @override + final String? thumbnailCdnBaseUrl; + + @override + List grants = []; + + @override + Future Function(YustFile)? generateDownloadUrl; + + YustFileAccessService({ + required this.originalCdnBaseUrl, + required this.thumbnailCdnBaseUrl, + }); + + @override + String createSignedUrlForFile({ + required String path, + required String name, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + Map? additionalQueryParams, + }) { + final helper = GoogleCloudCdnHelper( + baseUrl: cdnConfiguration.baseUrl, + keyName: cdnConfiguration.keyName, + keyBase64: cdnConfiguration.keyBase64, + ); + return helper.signFilePath( + objectPath: '$path/$name', + validFor: validFor, + additionalQueryParams: additionalQueryParams, + ); + } + + @override + String createSignedUrlPartForFolder({ + required String path, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + }) { + final helper = GoogleCloudCdnHelper( + baseUrl: cdnConfiguration.baseUrl, + keyName: cdnConfiguration.keyName, + keyBase64: cdnConfiguration.keyBase64, + ); + return helper.signPrefix(prefixPath: path, validFor: validFor); + } + + @override + void setGrants(List grants) { + this.grants = grants; + } + + @override + YustFileAccessGrant? getGrantForFile(YustFile file) { + return grants.firstWhereOrNull( + (grant) => file.path?.startsWith(grant.pathPrefix) ?? false, + ); + } +} diff --git a/lib/src/services/yust_file_access_service_flutter.dart b/lib/src/services/yust_file_access_service_flutter.dart new file mode 100644 index 00000000..2396757a --- /dev/null +++ b/lib/src/services/yust_file_access_service_flutter.dart @@ -0,0 +1,58 @@ +import 'package:collection/collection.dart'; + +import '../models/yust_file.dart'; +import '../util/file_access/yust_cdn_configuration.dart'; +import '../util/file_access/yust_file_access_grant.dart'; +import '../util/yust_exception.dart'; +import 'yust_file_access_service_interface.dart'; + +class YustFileAccessService implements IYustFileAccessService { + @override + final String? originalCdnBaseUrl; + + @override + final String? thumbnailCdnBaseUrl; + + @override + List grants = []; + + @override + Future Function(YustFile)? generateDownloadUrl; + + YustFileAccessService({ + required this.originalCdnBaseUrl, + required this.thumbnailCdnBaseUrl, + }); + + @override + String createSignedUrlForFile({ + required String path, + required String name, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + Map? additionalQueryParams, + }) { + throw YustException('Not implemented for flutter'); + } + + @override + String createSignedUrlPartForFolder({ + required String path, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + }) { + throw YustException('Not implemented for flutter'); + } + + @override + void setGrants(List grants) { + this.grants = grants; + } + + @override + YustFileAccessGrant? getGrantForFile(YustFile file) { + return grants.firstWhereOrNull( + (grant) => file.path?.startsWith(grant.pathPrefix) ?? false, + ); + } +} diff --git a/lib/src/services/yust_file_access_service_interface.dart b/lib/src/services/yust_file_access_service_interface.dart new file mode 100644 index 00000000..06874e15 --- /dev/null +++ b/lib/src/services/yust_file_access_service_interface.dart @@ -0,0 +1,45 @@ +import '../models/yust_file.dart'; +import '../util/file_access/yust_cdn_configuration.dart'; +import '../util/file_access/yust_file_access_grant.dart'; + +/// Handles file access URL signing requests. +abstract interface class IYustFileAccessService { + /// Base URL for the original files. + String? get originalCdnBaseUrl; + + /// Base URL for the thumbnail files. + String? get thumbnailCdnBaseUrl; + + /// List of file access grants + List get grants; + + Future Function(YustFile)? generateDownloadUrl; + + /// Creates a signed URL for a file at the given [path] and [name]. + /// The [validFor] parameter limits the validity of the URL. + String createSignedUrlForFile({ + required String path, + required String name, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + Map? additionalQueryParams, + }); + + /// Creates a signed URL Part for a folder at the given [path], using URLPrefix signing. + /// + /// Returns only the query string to append to the requested file url. + /// e.g. `URLPrefix=...` + String createSignedUrlPartForFolder({ + required String path, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + }); + + /// Sets the list of file access grants. + void setGrants(List grants); + + /// Returns the file access grant for a given file. + /// + /// Tries to match the file path to a grant path prefix. + YustFileAccessGrant? getGrantForFile(YustFile file); +} diff --git a/lib/src/services/yust_file_access_service_mocked.dart b/lib/src/services/yust_file_access_service_mocked.dart new file mode 100644 index 00000000..fef3907a --- /dev/null +++ b/lib/src/services/yust_file_access_service_mocked.dart @@ -0,0 +1,61 @@ +import 'package:collection/collection.dart'; + +import '../models/yust_file.dart'; +import '../util/file_access/yust_cdn_configuration.dart'; +import '../util/file_access/yust_file_access_grant.dart'; +import '../util/yust_exception.dart'; +import 'yust_file_access_service_interface.dart'; + +/// Mocked file access service. +/// +/// Provides basic implementation to prevent errors when not initialized. +class YustFileAccessServiceMocked implements IYustFileAccessService { + @override + final String? originalCdnBaseUrl; + + @override + final String? thumbnailCdnBaseUrl; + + @override + List grants = []; + + @override + Future Function(YustFile)? generateDownloadUrl; + + YustFileAccessServiceMocked({ + required this.originalCdnBaseUrl, + required this.thumbnailCdnBaseUrl, + }); + + @override + String createSignedUrlForFile({ + required String path, + required String name, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + Map? additionalQueryParams, + }) { + throw YustException('Not implemented in mocked service'); + } + + @override + String createSignedUrlPartForFolder({ + required String path, + required Duration validFor, + required YustCdnConfiguration cdnConfiguration, + }) { + throw YustException('Not implemented in mocked service'); + } + + @override + void setGrants(List grants) { + this.grants = grants; + } + + @override + YustFileAccessGrant? getGrantForFile(YustFile file) { + return grants.firstWhereOrNull( + (grant) => file.path?.startsWith(grant.pathPrefix) ?? false, + ); + } +} diff --git a/lib/src/services/yust_file_service_dart.dart b/lib/src/services/yust_file_service_dart.dart index 759a6241..4613c56c 100644 --- a/lib/src/services/yust_file_service_dart.dart +++ b/lib/src/services/yust_file_service_dart.dart @@ -49,6 +49,9 @@ class YustFileService implements IYustFileService { Map? metadata, String? contentDisposition, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }) async { // Check if either a file or bytes are provided if (file == null && bytes == null) { @@ -62,9 +65,13 @@ class YustFileService implements IYustFileService { ? file.openRead() : Stream>.value(bytes!.toList()); final token = Uuid().v4(); + final hasLinkedDoc = linkedDocPath != null && linkedDocAttribute != null; final fileMetadata = { ...?metadata, 'firebaseStorageDownloadTokens': token, + if (createThumbnail == true && hasLinkedDoc) 'thumbnail': 'true', + if (hasLinkedDoc) 'linkedDocPath': linkedDocPath, + if (hasLinkedDoc) 'linkedDocAttribute': linkedDocAttribute, }; final object = Object( @@ -105,12 +112,19 @@ class YustFileService implements IYustFileService { String? contentDisposition, Map? metadata, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }) async { final effectiveBucketName = bucketName ?? defaultBucketName; final token = Uuid().v4(); + final hasLinkedDoc = linkedDocPath != null && linkedDocAttribute != null; final fileMetadata = { ...?metadata, 'firebaseStorageDownloadTokens': token, + if (createThumbnail == true && hasLinkedDoc) 'thumbnail': 'true', + if (hasLinkedDoc) 'linkedDocPath': linkedDocPath, + if (hasLinkedDoc) 'linkedDocAttribute': linkedDocAttribute, }; final object = Object( diff --git a/lib/src/services/yust_file_service_flutter.dart b/lib/src/services/yust_file_service_flutter.dart index 2ef8fbf6..a9a647b7 100644 --- a/lib/src/services/yust_file_service_flutter.dart +++ b/lib/src/services/yust_file_service_flutter.dart @@ -51,6 +51,9 @@ class YustFileService implements IYustFileService { String? contentDisposition, Map? metadata, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }) async { final collected = []; await for (final chunk in stream) { @@ -65,6 +68,9 @@ class YustFileService implements IYustFileService { metadata: metadata, contentDisposition: contentDisposition, bucketName: bucketName, + createThumbnail: createThumbnail, + linkedDocPath: linkedDocPath, + linkedDocAttribute: linkedDocAttribute, ); } @@ -77,6 +83,9 @@ class YustFileService implements IYustFileService { Map? metadata, String? contentDisposition, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }) async { try { final storage = _getStorageForBucket(bucketName); @@ -88,11 +97,17 @@ class YustFileService implements IYustFileService { ); UploadTask uploadTask; + final hasLinkedDoc = linkedDocPath != null && linkedDocAttribute != null; if (file != null) { // For file uploads, create metadata with custom metadata if provided var fileMetadata = SettableMetadata( contentType: lookupMimeType(name), - customMetadata: metadata, + customMetadata: { + ...?metadata, + if (createThumbnail == true) 'thumbnail': 'true', + if (hasLinkedDoc) 'linkedDocPath': linkedDocPath, + if (hasLinkedDoc) 'linkedDocAttribute': linkedDocAttribute, + }, contentDisposition: contentDisposition ?? Yust.helpers.createContentDisposition(name), ); @@ -100,7 +115,12 @@ class YustFileService implements IYustFileService { } else { var fileMetadata = SettableMetadata( contentType: lookupMimeType(name), - customMetadata: metadata, + customMetadata: { + ...?metadata, + if (createThumbnail == true && hasLinkedDoc) 'thumbnail': 'true', + if (hasLinkedDoc) 'linkedDocPath': linkedDocPath, + if (hasLinkedDoc) 'linkedDocAttribute': linkedDocAttribute, + }, contentDisposition: contentDisposition ?? Yust.helpers.createContentDisposition(name), ); diff --git a/lib/src/services/yust_file_service_interface.dart b/lib/src/services/yust_file_service_interface.dart index 82095e0b..2a591eb0 100644 --- a/lib/src/services/yust_file_service_interface.dart +++ b/lib/src/services/yust_file_service_interface.dart @@ -14,6 +14,9 @@ abstract interface class IYustFileService { /// Optionally accepts [bucketName] to override the default bucket. /// Optionally accepts [contentDisposition] to override the default Content-Disposition header. /// + /// If [createThumbnail] is set to true and both [linkedDocPath] and [linkedDocAttribute] are provided, + /// a 'thumbnail': true metadata entry will be set on the uploaded file. + /// /// It returns the download url of the uploaded file. Future uploadFile({ required String path, @@ -23,6 +26,9 @@ abstract interface class IYustFileService { Map? metadata, String? contentDisposition, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }); /// Uploads a file from a [Stream] of [List] @@ -32,6 +38,9 @@ abstract interface class IYustFileService { /// Optionally accepts [bucketName] to override the default bucket. /// Optionally accepts [contentDisposition] to override the default Content-Disposition header. /// + /// If [createThumbnail] is set to true and both [linkedDocPath] and [linkedDocAttribute] are provided, + /// a 'thumbnail': true metadata entry will be set on the uploaded file. + /// /// Returns the download url of the uploaded file. Future uploadStream({ required String path, @@ -40,6 +49,9 @@ abstract interface class IYustFileService { String? contentDisposition, Map? metadata, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }); /// Downloads a file from a given [path] and [name] and returns it as [Uint8List]. diff --git a/lib/src/services/yust_file_service_mocked.dart b/lib/src/services/yust_file_service_mocked.dart index da451b37..36ef1407 100644 --- a/lib/src/services/yust_file_service_mocked.dart +++ b/lib/src/services/yust_file_service_mocked.dart @@ -45,6 +45,9 @@ class YustFileServiceMocked extends YustFileService { String? contentDisposition, Map? metadata, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }) async { final collected = []; await for (final chunk in stream) { @@ -59,6 +62,9 @@ class YustFileServiceMocked extends YustFileService { metadata: metadata, contentDisposition: contentDisposition, bucketName: bucketName, + createThumbnail: createThumbnail, + linkedDocPath: linkedDocPath, + linkedDocAttribute: linkedDocAttribute, ); } @@ -75,6 +81,9 @@ class YustFileServiceMocked extends YustFileService { Map? metadata, String? contentDisposition, String? bucketName, + bool? createThumbnail, + String? linkedDocPath, + String? linkedDocAttribute, }) async { if (file == null && bytes == null) { throw Exception('No file or bytes provided'); @@ -85,12 +94,16 @@ class YustFileServiceMocked extends YustFileService { final bucketStorage = _getStorageForBucket(bucketName); bucketStorage.putIfAbsent(path, () => {}); + final hasLinkedDoc = linkedDocPath != null && linkedDocAttribute != null; final fileMetadata = { ...?metadata, 'firebaseStorageDownloadTokens': token, 'contentDisposition': contentDisposition ?? Yust.helpers.createContentDisposition(name), + if (createThumbnail == true && hasLinkedDoc) 'thumbnail': 'true', + if (hasLinkedDoc) 'linkedDocPath': linkedDocPath, + if (hasLinkedDoc) 'linkedDocAttribute': linkedDocAttribute, }; bucketStorage[path]![name] = MockedFile( diff --git a/lib/src/util/file_access/yust_cdn_configuration.dart b/lib/src/util/file_access/yust_cdn_configuration.dart new file mode 100644 index 00000000..789ead05 --- /dev/null +++ b/lib/src/util/file_access/yust_cdn_configuration.dart @@ -0,0 +1,17 @@ +/// Configuration for a CDN. +class YustCdnConfiguration { + YustCdnConfiguration({ + required this.baseUrl, + required this.keyName, + required this.keyBase64, + }); + + /// The base URL of the CDN. + final String baseUrl; + + /// The public key name of the CDN. + final String keyName; + + /// The private key base64 of the CDN. + final String keyBase64; +} diff --git a/lib/src/util/file_access/yust_file_access_grant.dart b/lib/src/util/file_access/yust_file_access_grant.dart new file mode 100644 index 00000000..8b5755b7 --- /dev/null +++ b/lib/src/util/file_access/yust_file_access_grant.dart @@ -0,0 +1,32 @@ +import 'package:json_annotation/json_annotation.dart'; +part 'yust_file_access_grant.g.dart'; + +/// Access grant which allows access to all files below a given path prefix. +@JsonSerializable() +class YustFileAccessGrant { + const YustFileAccessGrant({ + required this.pathPrefix, + required this.originalSignedUrlPart, + required this.thumbnailSignedUrlPart, + }); + + /// Creates a new file access grant from a JSON map. + factory YustFileAccessGrant.fromJson(Map json) => + _$YustFileAccessGrantFromJson(json); + + /// The path prefix of the grant. + final String pathPrefix; + + /// The signed URL part for the original files. + /// + /// Must be appended to the file url + final String originalSignedUrlPart; + + /// The signed URL part for the thumbnail files. + /// + /// Must be appended to the thumbnail url + final String thumbnailSignedUrlPart; + + /// Converts the file access grant to a JSON map. + Map toJson() => _$YustFileAccessGrantToJson(this); +} diff --git a/lib/src/util/file_access/yust_file_access_grant.g.dart b/lib/src/util/file_access/yust_file_access_grant.g.dart new file mode 100644 index 00000000..b62a6d4d --- /dev/null +++ b/lib/src/util/file_access/yust_file_access_grant.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'yust_file_access_grant.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +YustFileAccessGrant _$YustFileAccessGrantFromJson(Map json) => + YustFileAccessGrant( + pathPrefix: json['pathPrefix'] as String, + originalSignedUrlPart: json['originalSignedUrlPart'] as String, + thumbnailSignedUrlPart: json['thumbnailSignedUrlPart'] as String, + ); + +Map _$YustFileAccessGrantToJson( + YustFileAccessGrant instance, +) => { + 'pathPrefix': instance.pathPrefix, + 'originalSignedUrlPart': instance.originalSignedUrlPart, + 'thumbnailSignedUrlPart': instance.thumbnailSignedUrlPart, +}; diff --git a/lib/src/util/google_cloud_cdn_helper.dart b/lib/src/util/google_cloud_cdn_helper.dart new file mode 100644 index 00000000..b24eb496 --- /dev/null +++ b/lib/src/util/google_cloud_cdn_helper.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart' as crypto; + +/// Helper for creating Cloud CDN Signed URLs (file + prefix). +class GoogleCloudCdnHelper { + GoogleCloudCdnHelper({ + required this.baseUrl, + required this.keyName, + required this.keyBase64, + }) : _keyBytes = base64Url.decode(keyBase64); + + /// The base URL of the CDN. + final String baseUrl; + + /// The key name of the CDN. + final String keyName; + + /// The key base64 of the CDN. + final String keyBase64; + + /// Decoded key bytes + final List _keyBytes; + + /// Creates a signed URL for a file at the given [objectPath]. + /// + /// [additionalQueryParams] are additional query parameters to be added to the URL. + /// These will be signed and must exist in the same order in the signed URL. + String signFilePath({ + required String objectPath, + required Duration validFor, + Map? additionalQueryParams, + }) { + final fullUrlWithPort = _join(baseUrl, objectPath); + var uriWithPort = Uri.parse(fullUrlWithPort); + + // Add optional query parameters to the url that will be signed + final queryParams = Map.from(uriWithPort.queryParameters); + if (additionalQueryParams != null) { + queryParams.addAll(additionalQueryParams); + } + uriWithPort = uriWithPort.replace(queryParameters: queryParams); + + // Strip the port because cdn otherwise rejects it + final fullUrl = _stripPort(uriWithPort.toString()); + final expires = _unix(validFor); + + final uri = Uri.parse(fullUrl); + final separator = uri.hasQuery ? '&' : '?'; + final keyNameEncoded = Uri.encodeQueryComponent(keyName); + final stringToSign = + '$fullUrl${separator}Expires=$expires&KeyName=$keyNameEncoded'; + + final signature = _sign(stringToSign); + + return '$stringToSign&Signature=$signature'; + } + + /// Returns only the query string + /// + /// "URLPrefix=...&Expires=...&KeyName=...&Signature=..." + String signPrefix({required String prefixPath, required Duration validFor}) { + final normalized = _normalizePrefix(prefixPath); + final baseWithoutPort = _stripPort(baseUrl); + final fullPrefix = _join(baseWithoutPort, normalized); + + final expires = _unix(validFor); + + final urlPrefixEncoded = base64UrlEncode(utf8.encode(fullPrefix)); + + final signedValue = + 'URLPrefix=$urlPrefixEncoded&Expires=$expires&KeyName=$keyName'; + + final signature = _sign(signedValue); + + return 'URLPrefix=$urlPrefixEncoded' + '&Expires=$expires' + '&KeyName=${Uri.encodeQueryComponent(keyName)}' + '&Signature=$signature'; + } + + String _stripPort(String url) { + final uri = Uri.parse(url); + if (uri.hasPort == false) return url; + + final normalizedUri = uri.replace(port: null); + return normalizedUri.toString(); + } + + int _unix(Duration validFor) { + final now = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + return now + validFor.inSeconds; + } + + String _sign(String value) { + final hmac = crypto.Hmac(crypto.sha1, _keyBytes); + final digest = hmac.convert(utf8.encode(value)); + return base64UrlEncode(digest.bytes); + } + + String _normalizePrefix(String p) { + p = p.trim(); + if (p.startsWith('/')) p = p.substring(1); + if (!p.endsWith('/')) p = '$p/'; + return p; + } + + String _join(String base, String rel) { + if (base.endsWith('/')) base = base.substring(0, base.length - 1); + if (rel.startsWith('/')) rel = rel.substring(1); + return '$base/$rel'; + } +} diff --git a/lib/src/yust.dart b/lib/src/yust.dart index b2ea20fb..40b281d0 100644 --- a/lib/src/yust.dart +++ b/lib/src/yust.dart @@ -8,6 +8,8 @@ import 'services/yust_auth_service.dart'; import 'services/yust_auth_service_mocked.dart'; import 'services/yust_database_service.dart'; import 'services/yust_database_service_mocked.dart'; +import 'services/yust_file_access_service.dart'; +import 'services/yust_file_access_service_mocked.dart'; import 'services/yust_file_service.dart'; import 'services/yust_file_service_mocked.dart'; import 'services/yust_push_service.dart'; @@ -73,6 +75,7 @@ class Yust { static late YustAuthService authService; static late YustFileService fileService; + static late IYustFileAccessService fileAccessService; static late YustDocSetup userSetup; static YustHelpers helpers = YustHelpers(); static late String projectId; @@ -128,6 +131,8 @@ class Yust { YustDocSetup? userSetup, DatabaseLogCallback? dbLogCallback, AccessCredentials? credentials, + String? originalCdnBaseUrl, + String? thumbnailCdnBaseUrl, }) async { if (forUI) _instance = this; @@ -139,6 +144,10 @@ class Yust { pushService = YustPushServiceMocked(); Yust.authService = YustAuthServiceMocked(this); Yust.fileService = YustFileServiceMocked(); + Yust.fileAccessService = YustFileAccessServiceMocked( + originalCdnBaseUrl: originalCdnBaseUrl, + thumbnailCdnBaseUrl: thumbnailCdnBaseUrl, + ); return; } @@ -164,6 +173,10 @@ class Yust { emulatorAddress: emulatorAddress, projectId: projectId, ); + Yust.fileAccessService = YustFileAccessService( + originalCdnBaseUrl: originalCdnBaseUrl, + thumbnailCdnBaseUrl: thumbnailCdnBaseUrl, + ); pushService = YustPushService(); } diff --git a/lib/yust.dart b/lib/yust.dart index 04e7a92b..18c1d78f 100644 --- a/lib/yust.dart +++ b/lib/yust.dart @@ -22,8 +22,12 @@ export 'src/services/yust_database_service.dart'; export 'src/services/yust_database_service_mocked.dart'; export 'src/services/yust_database_service_shared.dart' show AggregationResult, AggregationType; +export 'src/services/yust_file_access_service.dart'; +export 'src/services/yust_file_access_service_mocked.dart'; export 'src/services/yust_push_service.dart'; export 'src/services/yust_push_service_mocked.dart'; +export 'src/util/file_access/yust_cdn_configuration.dart'; +export 'src/util/file_access/yust_file_access_grant.dart'; export 'src/util/google_cloud_helpers.dart'; export 'src/util/google_cloud_helpers_shared.dart'; export 'src/util/object_helper.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 745c0e1e..88b426c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: uuid: ^4.5.1 meta: '>=1.15.0 <2.0.0' dart_jsonwebtoken: ^3.2.0 + crypto: ^3.0.7 dev_dependencies: build_runner: ^2.2.0