diff --git a/lib/services/storage.dart b/lib/services/storage.dart index 23d09224..7b5247f0 100644 --- a/lib/services/storage.dart +++ b/lib/services/storage.dart @@ -1,215 +1,266 @@ part of appwrite; - /// The Storage service allows you to manage your project files. +/// The Storage service allows you to manage your project files. class Storage extends Service { - Storage(super.client); - - /// List Files - /// - /// Get a list of all the user files. You can use the query params to filter - /// your results. - /// - Future listFiles({required String bucketId, List? queries, String? search}) async { - final String path = '/storage/buckets/{bucketId}/files'.replaceAll('{bucketId}', bucketId); - - final Map params = { - 'queries': queries, - 'search': search, - }; - - final Map headers = { - 'content-type': 'application/json', - }; - - final res = await client.call(HttpMethod.get, path: path, params: params, headers: headers); - - return models.FileList.fromMap(res.data); - - } - - /// Create File - /// - /// Create a new file. Before using this route, you should create a new bucket - /// resource using either a [server - /// integration](/docs/server/storage#storageCreateBucket) API or directly from - /// your Appwrite console. - /// - /// Larger files should be uploaded using multiple requests with the - /// [content-range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) - /// header to send a partial request with a maximum supported chunk of `5MB`. - /// The `content-range` header values should always be in bytes. - /// - /// When the first request is sent, the server will return the **File** object, - /// and the subsequent part request must include the file's **id** in - /// `x-appwrite-id` header to allow the server to know that the partial upload - /// is for the existing file and not for a new one. - /// - /// If you're creating a new file using one of the Appwrite SDKs, all the - /// chunking logic will be managed by the SDK internally. - /// - /// - Future createFile({required String bucketId, required String fileId, required InputFile file, List? permissions, Function(UploadProgress)? onProgress}) async { - final String path = '/storage/buckets/{bucketId}/files'.replaceAll('{bucketId}', bucketId); - - final Map params = { - - - 'fileId': fileId, - 'file': file, - 'permissions': permissions, - }; - - final Map headers = { - 'content-type': 'multipart/form-data', - }; - - String idParamName = ''; - idParamName = 'fileId'; - final paramName = 'file'; - final res = await client.chunkedUpload( - path: path, - params: params, - paramName: paramName, - idParamName: idParamName, - headers: headers, - onProgress: onProgress, - ); - - return models.File.fromMap(res.data); - - } - - /// Get File - /// - /// Get a file by its unique ID. This endpoint response returns a JSON object - /// with the file metadata. - /// - Future getFile({required String bucketId, required String fileId}) async { - final String path = '/storage/buckets/{bucketId}/files/{fileId}'.replaceAll('{bucketId}', bucketId).replaceAll('{fileId}', fileId); - - final Map params = { - }; - - final Map headers = { - 'content-type': 'application/json', - }; - - final res = await client.call(HttpMethod.get, path: path, params: params, headers: headers); - - return models.File.fromMap(res.data); - - } - - /// Update File - /// - /// Update a file by its unique ID. Only users with write permissions have - /// access to update this resource. - /// - Future updateFile({required String bucketId, required String fileId, List? permissions}) async { - final String path = '/storage/buckets/{bucketId}/files/{fileId}'.replaceAll('{bucketId}', bucketId).replaceAll('{fileId}', fileId); - - final Map params = { - 'permissions': permissions, - }; - - final Map headers = { - 'content-type': 'application/json', - }; - - final res = await client.call(HttpMethod.put, path: path, params: params, headers: headers); - - return models.File.fromMap(res.data); - - } - - /// Delete File - /// - /// Delete a file by its unique ID. Only users with write permissions have - /// access to delete this resource. - /// - Future deleteFile({required String bucketId, required String fileId}) async { - final String path = '/storage/buckets/{bucketId}/files/{fileId}'.replaceAll('{bucketId}', bucketId).replaceAll('{fileId}', fileId); - - final Map params = { - }; - - final Map headers = { - 'content-type': 'application/json', - }; - - final res = await client.call(HttpMethod.delete, path: path, params: params, headers: headers); - - return res.data; - - } - - /// Get File for Download - /// - /// Get a file content by its unique ID. The endpoint response return with a - /// 'Content-Disposition: attachment' header that tells the browser to start - /// downloading the file to user downloads directory. - /// - Future getFileDownload({required String bucketId, required String fileId}) async { - final String path = '/storage/buckets/{bucketId}/files/{fileId}/download'.replaceAll('{bucketId}', bucketId).replaceAll('{fileId}', fileId); - - final Map params = { - - - 'project': client.config['project'], - }; - - final res = await client.call(HttpMethod.get, path: path, params: params, responseType: ResponseType.bytes); - return res.data; - } - - /// Get File Preview - /// - /// Get a file preview image. Currently, this method supports preview for image - /// files (jpg, png, and gif), other supported formats, like pdf, docs, slides, - /// and spreadsheets, will return the file icon image. You can also pass query - /// string arguments for cutting and resizing your preview image. Preview is - /// supported only for image files smaller than 10MB. - /// - Future getFilePreview({required String bucketId, required String fileId, int? width, int? height, String? gravity, int? quality, int? borderWidth, String? borderColor, int? borderRadius, double? opacity, int? rotation, String? background, String? output}) async { - final String path = '/storage/buckets/{bucketId}/files/{fileId}/preview'.replaceAll('{bucketId}', bucketId).replaceAll('{fileId}', fileId); - - final Map params = { - - 'width': width, - 'height': height, - 'gravity': gravity, - 'quality': quality, - 'borderWidth': borderWidth, - 'borderColor': borderColor, - 'borderRadius': borderRadius, - 'opacity': opacity, - 'rotation': rotation, - 'background': background, - 'output': output, - - 'project': client.config['project'], - }; - - final res = await client.call(HttpMethod.get, path: path, params: params, responseType: ResponseType.bytes); - return res.data; - } - - /// Get File for View - /// - /// Get a file content by its unique ID. This endpoint is similar to the - /// download method but returns with no 'Content-Disposition: attachment' - /// header. - /// - Future getFileView({required String bucketId, required String fileId}) async { - final String path = '/storage/buckets/{bucketId}/files/{fileId}/view'.replaceAll('{bucketId}', bucketId).replaceAll('{fileId}', fileId); - - final Map params = { - - - 'project': client.config['project'], - }; - - final res = await client.call(HttpMethod.get, path: path, params: params, responseType: ResponseType.bytes); - return res.data; - } -} \ No newline at end of file + Storage(super.client); + + /// List Files + /// + /// Get a list of all the user files. You can use the query params to filter + /// your results. + /// + Future listFiles( + {required String bucketId, List? queries, String? search}) async { + final String path = + '/storage/buckets/{bucketId}/files'.replaceAll('{bucketId}', bucketId); + + final Map params = { + 'queries': queries, + 'search': search, + }; + + final Map headers = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.get, + path: path, params: params, headers: headers); + + return models.FileList.fromMap(res.data); + } + + /// Create File + /// + /// Create a new file. Before using this route, you should create a new bucket + /// resource using either a [server + /// integration](/docs/server/storage#storageCreateBucket) API or directly from + /// your Appwrite console. + /// + /// Larger files should be uploaded using multiple requests with the + /// [content-range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range) + /// header to send a partial request with a maximum supported chunk of `5MB`. + /// The `content-range` header values should always be in bytes. + /// + /// When the first request is sent, the server will return the **File** object, + /// and the subsequent part request must include the file's **id** in + /// `x-appwrite-id` header to allow the server to know that the partial upload + /// is for the existing file and not for a new one. + /// + /// If you're creating a new file using one of the Appwrite SDKs, all the + /// chunking logic will be managed by the SDK internally. + /// + /// + Future createFile( + {required String bucketId, + required String fileId, + required InputFile file, + List? permissions, + Function(UploadProgress)? onProgress}) async { + final String path = + '/storage/buckets/{bucketId}/files'.replaceAll('{bucketId}', bucketId); + + final Map params = { + 'fileId': fileId, + 'file': file, + 'permissions': permissions, + }; + + final Map headers = { + 'content-type': 'multipart/form-data', + }; + + String idParamName = ''; + idParamName = 'fileId'; + final paramName = 'file'; + final res = await client.chunkedUpload( + path: path, + params: params, + paramName: paramName, + idParamName: idParamName, + headers: headers, + onProgress: onProgress, + ); + + return models.File.fromMap(res.data); + } + + /// Get File + /// + /// Get a file by its unique ID. This endpoint response returns a JSON object + /// with the file metadata. + /// + Future getFile( + {required String bucketId, required String fileId}) async { + final String path = '/storage/buckets/{bucketId}/files/{fileId}' + .replaceAll('{bucketId}', bucketId) + .replaceAll('{fileId}', fileId); + + final Map params = {}; + + final Map headers = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.get, + path: path, params: params, headers: headers); + + return models.File.fromMap(res.data); + } + + /// Update File + /// + /// Update a file by its unique ID. Only users with write permissions have + /// access to update this resource. + /// + Future updateFile( + {required String bucketId, + required String fileId, + List? permissions}) async { + final String path = '/storage/buckets/{bucketId}/files/{fileId}' + .replaceAll('{bucketId}', bucketId) + .replaceAll('{fileId}', fileId); + + final Map params = { + 'permissions': permissions, + }; + + final Map headers = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.put, + path: path, params: params, headers: headers); + + return models.File.fromMap(res.data); + } + + /// Delete File + /// + /// Delete a file by its unique ID. Only users with write permissions have + /// access to delete this resource. + /// + Future deleteFile({required String bucketId, required String fileId}) async { + final String path = '/storage/buckets/{bucketId}/files/{fileId}' + .replaceAll('{bucketId}', bucketId) + .replaceAll('{fileId}', fileId); + + final Map params = {}; + + final Map headers = { + 'content-type': 'application/json', + }; + + final res = await client.call(HttpMethod.delete, + path: path, params: params, headers: headers); + + return res.data; + } + + /// Get File for Download + /// + /// Get a file content by its unique ID. The endpoint response return with a + /// 'Content-Disposition: attachment' header that tells the browser to start + /// downloading the file to user downloads directory. + /// + Future getFileDownload( + {required String bucketId, required String fileId}) async { + final String path = '/storage/buckets/{bucketId}/files/{fileId}/download' + .replaceAll('{bucketId}', bucketId) + .replaceAll('{fileId}', fileId); + + final Map params = { + 'project': client.config['project'], + }; + + final res = await client.call(HttpMethod.get, + path: path, params: params, responseType: ResponseType.bytes); + return res.data; + } + + /// Get File Preview + /// + /// Get a file preview image. Currently, this method supports preview for image + /// files (jpg, png, and gif), other supported formats, like pdf, docs, slides, + /// and spreadsheets, will return the file icon image. You can also pass query + /// string arguments for cutting and resizing your preview image. Preview is + /// supported only for image files smaller than 10MB. + /// + Future getFilePreview( + {required String bucketId, + required String fileId, + int? width, + int? height, + String? gravity, + int? quality, + int? borderWidth, + String? borderColor, + int? borderRadius, + double? opacity, + int? rotation, + String? background, + String? output}) async { + String path = '/storage/buckets/{bucketId}/files/{fileId}/preview' + .replaceAll('{bucketId}', bucketId) + .replaceAll('{fileId}', fileId); + + final Map params = { + 'width': width, + 'height': height, + 'gravity': gravity, + 'quality': quality, + 'borderWidth': borderWidth, + 'borderColor': borderColor, + 'borderRadius': borderRadius, + 'opacity': opacity, + 'rotation': rotation, + 'background': background, + 'output': output, + 'project': client.config['project'], + }; + + params['jwt'] = await client.getJWT(); + + params.keys.forEach((key) { + if (params[key] is int || params[key] is double) { + params[key] = params[key].toString(); + } + }); + params.removeWhere((key, value) => value == null || value.isEmpty); + + Uri endpoint = Uri.parse(client.endPoint); + Uri location = new Uri( + scheme: endpoint.scheme, + host: endpoint.host, + port: endpoint.port, + path: endpoint.path + path, + queryParameters: params, + ); + + return location.toString(); + + final res = await client.call(HttpMethod.get, + path: path, params: params, responseType: ResponseType.bytes); + return res.data; + } + + /// Get File for View + /// + /// Get a file content by its unique ID. This endpoint is similar to the + /// download method but returns with no 'Content-Disposition: attachment' + /// header. + /// + Future getFileView( + {required String bucketId, required String fileId}) async { + final String path = '/storage/buckets/{bucketId}/files/{fileId}/view' + .replaceAll('{bucketId}', bucketId) + .replaceAll('{fileId}', fileId); + + final Map params = { + 'project': client.config['project'], + }; + + final res = await client.call(HttpMethod.get, + path: path, params: params, responseType: ResponseType.bytes); + return res.data; + } +} diff --git a/lib/src/client.dart b/lib/src/client.dart index 7b662f8e..dc50d948 100644 --- a/lib/src/client.dart +++ b/lib/src/client.dart @@ -6,7 +6,7 @@ import 'response.dart'; import 'upload_progress.dart'; abstract class Client { - static const int CHUNK_SIZE = 5*1024*1024; + static const int CHUNK_SIZE = 5 * 1024 * 1024; late Map config; late String _endPoint; late String? _endPointRealtime; @@ -15,8 +15,7 @@ abstract class Client { String? get endPointRealtime => _endPointRealtime; factory Client( - {String endPoint = 'https://HOSTNAME/v1', - bool selfSigned = false}) => + {String endPoint = 'https://HOSTNAME/v1', bool selfSigned = false}) => createClient(endPoint: endPoint, selfSigned: selfSigned); Future webAuth(Uri url, {String? callbackUrlScheme}); @@ -36,18 +35,22 @@ abstract class Client { Client setEndPointRealtime(String endPoint); - /// Your project ID + /// Your project ID Client setProject(value); - /// Your secret JSON Web Token + + /// Your secret JSON Web Token Client setJWT(value); Client setLocale(value); Client addHeader(String key, String value); - Future call(HttpMethod method, { + Future call( + HttpMethod method, { String path = '', Map headers = const {}, Map params = const {}, ResponseType? responseType, }); + + Future getJWT(); } diff --git a/lib/src/client_browser.dart b/lib/src/client_browser.dart index 93192d40..9d6e6256 100644 --- a/lib/src/client_browser.dart +++ b/lib/src/client_browser.dart @@ -19,7 +19,7 @@ ClientBase createClient({ ClientBrowser(endPoint: endPoint, selfSigned: selfSigned); class ClientBrowser extends ClientBase with ClientMixin { - static const int CHUNK_SIZE = 5*1024*1024; + static const int CHUNK_SIZE = 5 * 1024 * 1024; String _endPoint; Map? _headers; @override @@ -64,6 +64,7 @@ class ClientBrowser extends ClientBase with ClientMixin { addHeader('X-Appwrite-Project', value); return this; } + /// Your secret JSON Web Token @override ClientBrowser setJWT(value) { @@ -71,6 +72,7 @@ class ClientBrowser extends ClientBase with ClientMixin { addHeader('X-Appwrite-JWT', value); return this; } + @override ClientBrowser setLocale(value) { config['locale'] = value; @@ -131,7 +133,8 @@ class ClientBrowser extends ClientBase with ClientMixin { late Response res; if (size <= CHUNK_SIZE) { - params[paramName] = http.MultipartFile.fromBytes(paramName, file.bytes!, filename: file.filename); + params[paramName] = http.MultipartFile.fromBytes(paramName, file.bytes!, + filename: file.filename); return call( HttpMethod.post, path: path, @@ -158,8 +161,8 @@ class ClientBrowser extends ClientBase with ClientMixin { var chunk; final end = min(offset + CHUNK_SIZE, size); chunk = file.bytes!.getRange(offset, end).toList(); - params[paramName] = - http.MultipartFile.fromBytes(paramName, chunk, filename: file.filename); + params[paramName] = http.MultipartFile.fromBytes(paramName, chunk, + filename: file.filename); headers['content-range'] = 'bytes $offset-${min(((offset + CHUNK_SIZE) - 1), size)}/$size'; res = await call(HttpMethod.post, @@ -219,9 +222,15 @@ class ClientBrowser extends ClientBase with ClientMixin { @override Future webAuth(Uri url, {String? callbackUrlScheme}) { - return FlutterWebAuth2.authenticate( + return FlutterWebAuth2.authenticate( url: url.toString(), callbackUrlScheme: "appwrite-callback-" + config['project']!, ); } + + @override + Future getJWT() { + // On web cookie should work automatically + return Future.value(null); + } } diff --git a/lib/src/client_io.dart b/lib/src/client_io.dart index 06a8abf4..b34a5f93 100644 --- a/lib/src/client_io.dart +++ b/lib/src/client_io.dart @@ -1,5 +1,8 @@ import 'dart:io'; import 'dart:math'; +import 'package:appwrite/appwrite.dart' + show AppwriteException, Account, Response, InputFile, UploadProgress; +import 'package:appwrite/src/jwt_decoder.dart'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart' as http; @@ -7,16 +10,13 @@ import 'package:http/io_client.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'client_mixin.dart'; import 'client_base.dart'; import 'cookie_manager.dart'; import 'enums.dart'; -import 'exception.dart'; import 'interceptor.dart'; -import 'response.dart'; import 'package:flutter/foundation.dart'; -import 'input_file.dart'; -import 'upload_progress.dart'; ClientBase createClient({ required String endPoint, @@ -28,7 +28,7 @@ ClientBase createClient({ ); class ClientIO extends ClientBase with ClientMixin { - static const int CHUNK_SIZE = 5*1024*1024; + static const int CHUNK_SIZE = 5 * 1024 * 1024; String _endPoint; Map? _headers; @override @@ -47,6 +47,7 @@ class ClientIO extends ClientBase with ClientMixin { CookieJar get cookieJar => _cookieJar; @override String? get endPointRealtime => _endPointRealtime; + SharedPreferences? _preferences; ClientIO({ String endPoint = 'https://HOSTNAME/v1', @@ -65,7 +66,7 @@ class ClientIO extends ClientBase with ClientMixin { 'x-sdk-platform': 'client', 'x-sdk-language': 'flutter', 'x-sdk-version': '9.0.0', - 'X-Appwrite-Response-Format' : '1.0.0', + 'X-Appwrite-Response-Format': '1.0.0', }; config = {}; @@ -86,26 +87,28 @@ class ClientIO extends ClientBase with ClientMixin { return dir; } - /// Your project ID - @override - ClientIO setProject(value) { - config['project'] = value; - addHeader('X-Appwrite-Project', value); - return this; - } - /// Your secret JSON Web Token - @override - ClientIO setJWT(value) { - config['jWT'] = value; - addHeader('X-Appwrite-JWT', value); - return this; - } - @override - ClientIO setLocale(value) { - config['locale'] = value; - addHeader('X-Appwrite-Locale', value); - return this; - } + /// Your project ID + @override + ClientIO setProject(value) { + config['project'] = value; + addHeader('X-Appwrite-Project', value); + return this; + } + + /// Your secret JSON Web Token + @override + ClientIO setJWT(value) { + config['jWT'] = value; + addHeader('X-Appwrite-JWT', value); + return this; + } + + @override + ClientIO setLocale(value) { + config['locale'] = value; + addHeader('X-Appwrite-Locale', value); + return this; + } @override ClientIO setSelfSigned({bool status = true}) { @@ -138,7 +141,7 @@ class ClientIO extends ClientBase with ClientMixin { } Future init() async { - if(_initProgress) return; + if (_initProgress) return; _initProgress = true; final Directory cookieDir = await _getCookiePath(); _cookieJar = PersistCookieJar(storage: FileStorage(cookieDir.path)); @@ -174,13 +177,12 @@ class ClientIO extends ClientBase with ClientMixin { final macinfo = await deviceInfoPlugin.macOsInfo; device = '(Macintosh; ${macinfo.model})'; } - addHeader( - 'user-agent', '${packageInfo.packageName}/${packageInfo.version} $device'); + addHeader('user-agent', + '${packageInfo.packageName}/${packageInfo.version} $device'); } catch (e) { debugPrint('Error getting device info: $e'); device = Platform.operatingSystem; - addHeader( - 'user-agent', '$device'); + addHeader('user-agent', '$device'); } _initialized = true; @@ -283,14 +285,14 @@ class ClientIO extends ClientBase with ClientMixin { while (offset < size) { List chunk = []; if (file.bytes != null) { - final end = min(offset + CHUNK_SIZE-1, size-1); + final end = min(offset + CHUNK_SIZE - 1, size - 1); chunk = file.bytes!.getRange(offset, end).toList(); } else { raf!.setPositionSync(offset); chunk = raf.readSync(CHUNK_SIZE); } - params[paramName] = - http.MultipartFile.fromBytes(paramName, chunk, filename: file.filename); + params[paramName] = http.MultipartFile.fromBytes(paramName, chunk, + filename: file.filename); headers['content-range'] = 'bytes $offset-${min(((offset + CHUNK_SIZE) - 1), size)}/$size'; res = await call(HttpMethod.post, @@ -369,10 +371,19 @@ class ClientIO extends ClientBase with ClientMixin { res = await toResponse(streamedResponse); res = await _interceptResponse(res); - return prepareResponse( + final response = prepareResponse( res, responseType: responseType, ); + + if (method == HttpMethod.delete && + path.contains('/account/sessions/current')) { + _resetJWT(); + } + if ((method == HttpMethod.get && path == '/account') || (method == HttpMethod.post && path.contains('/account/sessions/'))) { + getJWT(true); + } + return response; } catch (e) { if (e is AppwriteException) { rethrow; @@ -380,4 +391,42 @@ class ClientIO extends ClientBase with ClientMixin { throw AppwriteException(e.toString()); } } + + void _resetJWT() async { + if (_preferences != null) { + await _preferences!.remove('jwt'); + } + } + + @override + Future getJWT([bool fresh = false]) async { + final Account account = Account(this); + try { + if(!fresh) { + + if (_preferences == null) { + _preferences = await SharedPreferences.getInstance(); + } + final savedJwt = _preferences!.getString('jwt'); + if (savedJwt != null) { + try { + if (!JwtDecoder.isExpired(savedJwt)) { + return savedJwt; + } + } catch (e) { + // Remove invalid token + await _preferences!.remove('jwt'); + } + } else { + print('no saved jwt'); + } + } + + final jwt = (await account.createJWT()).jwt; + _preferences!.setString('jwt', jwt); + return jwt; + } catch (e) { + return null; + } + } } diff --git a/lib/src/jwt_decoder.dart b/lib/src/jwt_decoder.dart new file mode 100644 index 00000000..bb6a827c --- /dev/null +++ b/lib/src/jwt_decoder.dart @@ -0,0 +1,29 @@ +library jwt_decoder; + +import 'dart:convert'; + +class JwtDecoder { + static Map _decode(String token) { + final splitToken = token.split("."); + if (splitToken.length != 3) { + throw FormatException('Invalid Token'); + } + try { + final payload = + utf8.decode(base64.decode(base64.normalize(splitToken[1]))); + return jsonDecode(payload); + } catch (error) { + throw FormatException('Invalid Token'); + } + } + + static bool isExpired(String token) { + final decodedToken = _decode(token); + final expiration = decodedToken['exp'] as int?; + if (expiration == null) { + return false; + } + final expiresOn = DateTime.fromMillisecondsSinceEpoch(expiration * 1000); + return DateTime.now().isAfter(expiresOn); + } +}