diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/lib/src/misc/cookieManager.dart b/lib/src/misc/cookieManager.dart new file mode 100644 index 0000000..b6cfda3 --- /dev/null +++ b/lib/src/misc/cookieManager.dart @@ -0,0 +1,81 @@ +/* + * Our own implementation of Dio cookie manger + * Modified from: https://github.com/flutterchina/dio/blob/master/plugins/cookie_manager/lib/src/cookie_mgr.dart + * + * What changed? We store cookie in baseUrl instead of full Uri to allow using + * cookie across the app (with requests many different routes) + */ + +import 'dart:async'; +import 'dart:io'; + +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; + +/// Don't use this class in Browser environment +class CookieManager extends Interceptor { + /// Cookie manager for http requests。Learn more details about + /// CookieJar please refer to [cookie_jar](https://github.com/flutterchina/cookie_jar) + final CookieJar cookieJar; + + CookieManager(this.cookieJar); + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + cookieJar.loadForRequest(options.uri).then((cookies) { + var cookie = getCookies(cookies); + if (cookie.isNotEmpty) { + options.headers[HttpHeaders.cookieHeader] = cookie; + } + handler.next(options); + }).catchError((e, stackTrace) { + var err = DioError(requestOptions: options, error: e); + err.stackTrace = stackTrace; + handler.reject(err, true); + }); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + _saveCookies(response) + .then((_) => handler.next(response)) + .catchError((e, stackTrace) { + var err = DioError(requestOptions: response.requestOptions, error: e); + err.stackTrace = stackTrace; + handler.reject(err, true); + }); + } + + @override + void onError(DioError err, ErrorInterceptorHandler handler) { + if (err.response != null) { + _saveCookies(err.response!) + .then((_) => handler.next(err)) + .catchError((e, stackTrace) { + var _err = DioError( + requestOptions: err.response!.requestOptions, + error: e, + ); + _err.stackTrace = stackTrace; + handler.next(_err); + }); + } else { + handler.next(err); + } + } + + Future _saveCookies(Response response) async { + var cookies = response.headers[HttpHeaders.setCookieHeader]; + + if (cookies != null) { + await cookieJar.saveFromResponse( + response.requestOptions.uri, + cookies.map((str) => Cookie.fromSetCookieValue(str)).toList(), + ); + } + } + + static String getCookies(List cookies) { + return cookies.map((cookie) => '${cookie.name}=${cookie.value}').join('; '); + } +} diff --git a/lib/src/providers/feedApi.dart b/lib/src/providers/feedApi.dart index b259eec..f0d7178 100644 --- a/lib/src/providers/feedApi.dart +++ b/lib/src/providers/feedApi.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:oneplace_illinois/src/misc/config.dart'; import 'package:oneplace_illinois/src/misc/exceptions.dart'; import 'package:oneplace_illinois/src/models/feedItem.dart'; @@ -13,17 +14,10 @@ class FeedAPI { Future> getFeed(ApiService api) async { Uri uri = Uri.http(Config.baseEndpoint!, feedListPath); - final response = await api.get(uri); - if (response.statusCode != 200) { - throw HttpException( - response.reasonPhrase ?? response.statusCode.toString()); - } - Map data = jsonDecode(response.body); - if (data['error'] != null) { - log(data.toString()); - throw ApiException(data['error']); - } + final client = await api.getClientWithAuth(); + final data = await client.get('/feed/list', queryParameters: {}); // qs for later + // NOTE: Does this catch case of empty feed list? List feedItems = data['payload'].map((e) => FeedItem.fromJSON(e)).toList(); feedItems.sort((a, b) => a.postDate.compareTo(b.postDate)); diff --git a/lib/src/services/api.dart b/lib/src/services/api.dart index 52fad65..e2cd5ce 100644 --- a/lib/src/services/api.dart +++ b/lib/src/services/api.dart @@ -1,45 +1,121 @@ import 'dart:convert'; import 'dart:developer'; +import 'dart:io'; -import 'package:http/http.dart'; -import 'package:http/io_client.dart'; +import 'package:cookie_jar/cookie_jar.dart'; +import 'package:dio/dio.dart'; +//import 'package:dio_cookie_manager/dio_cookie_manager.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:oneplace_illinois/src/misc/config.dart'; import 'package:oneplace_illinois/src/misc/exceptions.dart'; +import 'package:oneplace_illinois/src/misc/cookieManager.dart'; import 'package:oneplace_illinois/src/services/firebaseAuth.dart'; -class ApiService extends IOClient { - static final _loginUri = Uri.http(Config.baseEndpoint!, '/api/v1/user/login'); - static const _refreshCookieName = 'refresh_jwt'; +class ApiService { + // NOTE: Signed cookie doesn't work with HTTP request. Only works with HTTPS. + static final _baseUrl = Uri.https(Config.baseEndpoint!, ''); + + static PersistCookieJar _cookieJar = new PersistCookieJar(ignoreExpires: false); + /* // use default settings for now + static Future get cookieJar async { + if (_cookieJar == null) { + Directory appDocDir = await getApplicationDocumentsDirectory(); + String appDocPath = appDocDir.path; + _cookieJar = new PersistCookieJar({ + ignoreExpires: false, // don't save/load expired cookies + storage: appDocPath + '/.cookies/' + }); + } + return _cookieJar; + } + */ + + final storage = new FlutterSecureStorage(); // for storing access token FirebaseAuthService _firebaseAuth; - String? _refreshToken; - String? _accessToken; ApiService({required firebaseAuth}) : this._firebaseAuth = firebaseAuth; - @override - Future send(BaseRequest request) async { - if (_refreshToken == null || _accessToken == null) { + + Future getClient() async { + final Dio _dio = Dio(); + + _dio.options.baseUrl = '$_baseUrl/api/v1'; + _dio.interceptors.clear(); + + _dio.interceptors.add(InterceptorsWrapper( + onResponse: (Response response, handler) async { + var data = response.data; + + if (data['error'] != null) { + log(data.toString()); + throw ApiException(data['error']); + } + + // save access token (refresh token is auto-saved by CookieManager above) + if (data['payload']?['accessToken'] != null && data['payload']?['accessToken'] != '') { + await storage.write(key: 'jwt_access', value: data['payload']?['accessToken']); + } + + return data; + }, + )); + + return _dio; + } + + Future _getClientWithAuth() async { + // retrieve access token + String? _accessToken = await storage.read(key: 'jwt_access'); + + if (_accessToken == null) { + // interceptor from _getClient() used by _login() will auto-save + // the new access & refresh token (for later requests) await _login(); } - request.headers.addAll({ - 'Authorization': 'Bearer ${_accessToken!}', - 'Cookie': '$_refreshCookieName=${_refreshToken!}', - }); + final Dio _dio = Dio(); + + _dio.options.baseUrl = '$_baseUrl/api/v1'; + _dio.interceptors.clear(); + + // Add cookie (refresh token) to interceptor + _dio.interceptors.add(CookieManager(_cookieJar)); + + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (RequestOptions options, handler) { + // Add access token to interceptor + options.headers['Authorization'] = 'Bearer ' + _accessToken!; + handler.next(options); + }, + onResponse: (Response response, handler) async { + var data = response.data; - return super.send(request); + if (data['error'] != null) { + log(data.toString()); + throw ApiException(data['error']); + } + + // Save access token if new one is issued from the server + if (data['payload'] && data['payload']['accessToken']) { + await storage.write(key: 'jwt_access', value: data['payload']['accessToken']); + } + + // return response; + return data; + } + )); + return _dio; } Future _login() async { var user = await _firebaseAuth.userStream.first; - Response response = await post(_loginUri, body: { + + Dio client = await getClient(); + + Response response = await client.post('/user/login', data: { 'email': user!.email, 'token': await user.getIdToken(), }); - - var cookies = response.headers['set-cookie']!; - _refreshToken = cookies.split(';')[0].split('=')[1]; - _accessToken = jsonDecode(response.body)['payload']['accessToken']; } } diff --git a/lib/src/services/courseApi.dart b/lib/src/services/courseApi.dart index aa89dc1..18f7760 100644 --- a/lib/src/services/courseApi.dart +++ b/lib/src/services/courseApi.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:developer'; import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:oneplace_illinois/src/misc/config.dart'; import 'package:oneplace_illinois/src/models/courseItem.dart'; import 'package:oneplace_illinois/src/models/sectionItem.dart'; @@ -10,15 +11,15 @@ import 'package:oneplace_illinois/src/services/api.dart'; class CourseAPI { Future?> getCourses(ApiService api, String query, {bool onlyCourses = false}) async { - Uri uri = Uri.http(Config.baseEndpoint!, '/api/v1/course/search', - {"keyword": query, "only_courses": onlyCourses.toString()}); - final response = await api.get(uri); - - if (response.statusCode != 200) { - throw HttpException( - response.reasonPhrase ?? response.statusCode.toString()); - } - List data = jsonDecode(response.body)['payload']['courses']; + + Dio client = await api.getClient(); + + // NOTE: only_courses accept boolean (prev was onlyCourses.toString()) + final body = await client.get('/course/search', queryParameters: { + 'keyword': query, 'only_courses': onlyCourses + }); + + List data = body['courses']; List courses = data.map((e) => CourseItem.fromJSON(e, onlyCourses)).toList(); courses.sort((a, b) => a.compareTo([query, b])); @@ -29,16 +30,15 @@ class CourseAPI { List codeSections = fullCode.split('_'); String code = codeSections[0]; String crn = codeSections[1]; - Uri uri = Uri.http(Config.baseEndpoint!, '/api/v1/section/search', - {'code': code, 'CRN': crn}); - final response = await api.get(uri); - if (response.statusCode != 200) { - throw HttpException( - response.reasonPhrase ?? response.statusCode.toString()); - } + Dio client = await api.getClient(); + + final body = await client.get('/section/search', queryParameters: { + 'code': code, 'CRN': crn, + }); - dynamic data = jsonDecode(response.body)['payload']['sections'][0]; + // Since we provided CRN, only one section is returned + dynamic data = body['sections'][0]; SectionItem section = SectionItem.fromJSON(data); return section; } diff --git a/pubspec.lock b/pubspec.lock index 367bd9e..f6b5a23 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -78,6 +78,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.15.0" + cookie_jar: + dependency: "direct main" + description: + name: cookie_jar + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.1" csslib: dependency: transitive description: @@ -92,6 +99,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.3" + dio: + dependency: "direct main" + description: + name: dio + url: "https://pub.dartlang.org" + source: hosted + version: "4.0.0" fake_async: dependency: transitive description: @@ -181,6 +195,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.9.5" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.1" flutter_spinkit: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 38ff5c5..086d031 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,10 @@ dependencies: permission_handler: ^8.1.4+2 path_provider: ^2.0.2 tuple: ^2.0.0 + dio: ^4.0.0 + cookie_jar: ^3.0.1 + #dio_cookie_manager: ^1.0.0 # depends on dio: ^3.0.0 + flutter_secure_storage: ^4.2.1 dev_dependencies: flutter_test: