Fixed login and error handling

This commit is contained in:
Anton Stubenbord
2022-12-30 01:28:43 +01:00
parent bf0e186646
commit 2326c9d1d6
43 changed files with 502 additions and 447 deletions

View File

@@ -1,38 +0,0 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:http_interceptor/http_interceptor.dart';
class AuthenticationInterceptor implements InterceptorContract {
String? serverUrl;
String? token;
AuthenticationInterceptor({this.serverUrl, this.token});
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
if (kDebugMode) {
log("Intercepted ${request.method} request to ${request.url.toString()}");
}
return request.copyWith(
url: Uri.parse((serverUrl ?? '') + request.url.toString()),
headers: token?.isEmpty ?? true
? request.headers
: {
...request.headers,
'Authorization': 'Token $token',
},
);
}
@override
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -1,31 +0,0 @@
import 'package:http_interceptor/http_interceptor.dart';
import 'package:paperless_mobile/core/store/local_vault.dart';
class BaseUrlInterceptor implements InterceptorContract {
final LocalVault _localVault;
BaseUrlInterceptor(this._localVault);
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
final auth = await _localVault.loadAuthenticationInformation();
if (auth == null) {
throw Exception(
"Authentication information not available, cannot perform request!",
);
}
return request.copyWith(
url: Uri.parse(auth.serverUrl + request.url.toString()),
);
}
@override
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -1,38 +1,62 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/type/types.dart';
class DioHttpErrorInterceptor implements InterceptorsWrapper {
class DioHttpErrorInterceptor extends Interceptor {
@override
void onError(DioError e, ErrorInterceptorHandler handler) {
//TODO: Implement and debug how error handling works, or if request has to be resolved.
if (e.response?.statusCode == 400) {
void onError(DioError err, ErrorInterceptorHandler handler) {
if (err.response?.statusCode == 400) {
// try to parse contained error message, otherwise return response
final Map<String, dynamic> json = jsonDecode(e.response?.data);
final PaperlessValidationErrors errorMessages = {};
for (final entry in json.entries) {
if (entry.value is List) {
errorMessages.putIfAbsent(
entry.key, () => (entry.value as List).cast<String>().first);
} else if (entry.value is String) {
errorMessages.putIfAbsent(entry.key, () => entry.value);
} else {
errorMessages.putIfAbsent(entry.key, () => entry.value.toString());
}
final dynamic data = err.response?.data;
if (data is Map<String, dynamic>) {
return _handlePaperlessValidationError(data, handler, err);
} else if (data is String) {
return _handlePlainError(data, handler, err);
}
throw errorMessages;
}
handler.next(e);
handler.reject(err);
}
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
handler.next(options);
void _handlePaperlessValidationError(
Map<String, dynamic> json,
ErrorInterceptorHandler handler,
DioError err,
) {
final PaperlessValidationErrors errorMessages = {};
for (final entry in json.entries) {
if (entry.value is List) {
errorMessages.putIfAbsent(
entry.key,
() => (entry.value as List).cast<String>().first,
);
} else if (entry.value is String) {
errorMessages.putIfAbsent(entry.key, () => entry.value);
} else {
errorMessages.putIfAbsent(entry.key, () => entry.value.toString());
}
}
return handler.reject(
DioError(
error: errorMessages,
requestOptions: err.requestOptions,
type: DioErrorType.response,
),
);
}
@override
void onResponse(Response response, ResponseInterceptorHandler handler) {
handler.next(response);
void _handlePlainError(
String data,
ErrorInterceptorHandler handler,
DioError err,
) {
if (data.contains("No required SSL certificate was sent")) {
handler.reject(
DioError(
requestOptions: err.requestOptions,
type: DioErrorType.response,
error: ErrorCode.missingClientCertificate,
),
);
}
}
}

View File

@@ -1,32 +1,19 @@
import 'package:dio/dio.dart';
import 'package:paperless_mobile/features/settings/bloc/application_settings_cubit.dart';
import 'package:http_interceptor/http_interceptor.dart';
class LanguageHeaderInterceptor implements InterceptorContract {
final ApplicationSettingsCubit appSettingsCubit;
LanguageHeaderInterceptor(this.appSettingsCubit);
class LanguageHeaderInterceptor extends Interceptor {
String preferredLocaleSubtag;
LanguageHeaderInterceptor(this.preferredLocaleSubtag);
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async {
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
late String languages;
if (appSettingsCubit.state.preferredLocaleSubtag == "en") {
if (preferredLocaleSubtag == "en") {
languages = "en";
} else {
languages = appSettingsCubit.state.preferredLocaleSubtag +
",en;q=0.7,en-US;q=0.6";
languages = "$preferredLocaleSubtag,en;q=0.7,en-US;q=0.6";
}
request.headers.addAll({"Accept-Language": languages});
return request;
options.headers.addAll({"Accept-Language": languages});
handler.next(options);
}
@override
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async =>
response;
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -1,38 +0,0 @@
import 'package:http_interceptor/http_interceptor.dart';
const interceptedRoutes = ['thumb/'];
class ResponseConversionInterceptor implements InterceptorContract {
@override
Future<BaseRequest> interceptRequest({required BaseRequest request}) async =>
request;
@override
Future<BaseResponse> interceptResponse(
{required BaseResponse response}) async {
final String requestUrl =
response.request?.url.toString().split("?").first ?? '';
if (response.request?.method == "GET" &&
interceptedRoutes.any((element) => requestUrl.endsWith(element))) {
final resp = response as Response;
return StreamedResponse(
Stream.value(resp.bodyBytes.toList()).asBroadcastStream(),
resp.statusCode,
contentLength: resp.contentLength,
headers: resp.headers,
isRedirect: resp.isRedirect,
persistentConnection: false,
reasonPhrase: resp.reasonPhrase,
request: resp.request,
);
}
return response;
}
@override
Future<bool> shouldInterceptRequest() async => true;
@override
Future<bool> shouldInterceptResponse() async => true;
}

View File

@@ -0,0 +1,71 @@
import 'dart:io';
import 'package:dio/dio.dart';
class RetryOnConnectionChangeInterceptor extends Interceptor {
final Dio dio;
RetryOnConnectionChangeInterceptor({
required this.dio,
});
@override
void onError(DioError err, ErrorInterceptorHandler handler) async {
if (_shouldRetryOnHttpException(err)) {
try {
handler.resolve(await DioHttpRequestRetrier(dio: dio)
.requestRetry(err.requestOptions)
.catchError((e) {
handler.next(err);
}));
} catch (e) {
handler.next(err);
}
} else {
handler.next(err);
}
}
bool _shouldRetryOnHttpException(DioError err) {
return err.type == DioErrorType.other &&
((err.error is HttpException &&
err.message.contains(
'Connection closed before full header was received')));
}
}
/// Retrier
class DioHttpRequestRetrier {
final Dio dio;
DioHttpRequestRetrier({
required this.dio,
});
Future<Response> requestRetry(RequestOptions requestOptions) async {
return dio.request(
requestOptions.path,
cancelToken: requestOptions.cancelToken,
data: requestOptions.data,
onReceiveProgress: requestOptions.onReceiveProgress,
onSendProgress: requestOptions.onSendProgress,
queryParameters: requestOptions.queryParameters,
options: Options(
contentType: requestOptions.contentType,
headers: requestOptions.headers,
sendTimeout: requestOptions.sendTimeout,
receiveTimeout: requestOptions.receiveTimeout,
extra: requestOptions.extra,
followRedirects: requestOptions.followRedirects,
listFormat: requestOptions.listFormat,
maxRedirects: requestOptions.maxRedirects,
method: requestOptions.method,
receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError,
requestEncoder: requestOptions.requestEncoder,
responseDecoder: requestOptions.responseDecoder,
responseType: requestOptions.responseType,
validateStatus: requestOptions.validateStatus,
),
);
}
}

View File

@@ -1,4 +1,4 @@
import 'package:flutter/src/widgets/framework.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:paperless_api/paperless_api.dart';
import 'package:paperless_mobile/core/repository/label_repository.dart';

View File

@@ -3,36 +3,26 @@ import 'dart:io';
import 'package:dio/adapter.dart';
import 'package:dio/dio.dart';
import 'package:paperless_mobile/core/interceptor/dio_http_error_interceptor.dart';
import 'package:paperless_mobile/extensions/security_context_extension.dart';
import 'package:paperless_mobile/features/login/model/client_certificate.dart';
///
/// Convenience http client handling timeouts.
///
class AuthenticationAwareDioManager {
final Dio _dio;
final Dio client;
final List<Interceptor> interceptors;
/// Some dependencies require an [HttpClient], therefore this is also maintained here.
AuthenticationAwareDioManager() : _dio = _initDio();
AuthenticationAwareDioManager([this.interceptors = const []])
: client = _initDio(interceptors);
Dio get client => _dio;
Stream<SecurityContext> get securityContextChanges =>
_securityContextStreamController.stream.asBroadcastStream();
final StreamController<SecurityContext> _securityContextStreamController =
StreamController.broadcast();
static Dio _initDio() {
static Dio _initDio(List<Interceptor> interceptors) {
//en- and decoded by utf8 by default
final Dio dio = Dio(BaseOptions());
dio.options.receiveTimeout = const Duration(seconds: 25).inMilliseconds;
dio.options.responseType = ResponseType.json;
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(client) => client..badCertificateCallback = (cert, host, port) => true;
dio.interceptors.add(DioHttpErrorInterceptor());
dio.interceptors.addAll(interceptors);
return dio;
}
@@ -42,19 +32,39 @@ class AuthenticationAwareDioManager {
ClientCertificate? clientCertificate,
}) {
if (clientCertificate != null) {
final context =
SecurityContext().withClientCertificate(clientCertificate);
(_dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate =
(client) => HttpClient(context: context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
_securityContextStreamController.add(context);
final context = SecurityContext()
..usePrivateKeyBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
)
..useCertificateChainBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
)
..setTrustedCertificatesBytes(
clientCertificate.bytes,
password: clientCertificate.passphrase,
);
final adapter = DefaultHttpClientAdapter()
..onHttpClientCreate = (client) => HttpClient(context: context)
..badCertificateCallback =
(X509Certificate cert, String host, int port) => true;
client.httpClientAdapter = adapter;
}
if (baseUrl != null) {
_dio.options.baseUrl = baseUrl;
client.options.baseUrl = baseUrl;
}
if (authToken != null) {
_dio.options.headers.addAll({'Authorization': 'Token $authToken'});
client.options.headers.addAll({'Authorization': 'Token $authToken'});
}
}
void resetSettings() {
client.httpClientAdapter = DefaultHttpClientAdapter();
client.options.baseUrl = '';
client.options.headers.remove('Authorization');
}
}

View File

@@ -0,0 +1,82 @@
import 'dart:io';
// ignore: implementation_imports
import 'package:flutter_cache_manager/src/web/mime_converter.dart';
import 'package:dio/dio.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
class DioFileService extends FileService {
final Dio dio;
DioFileService(this.dio);
@override
Future<FileServiceResponse> get(String url,
{Map<String, String>? headers}) async {
final response = await dio.get<ResponseBody>(
url,
options: Options(
headers: headers,
responseType: ResponseType.stream,
),
);
return DioGetResponse(response);
}
}
class DioGetResponse implements FileServiceResponse {
final Response<ResponseBody> _response;
final DateTime _receivedTime = DateTime.now();
DioGetResponse(this._response);
@override
Stream<List<int>> get content => _response.data!.stream;
@override
int? get contentLength => int.tryParse(
_response.headers.value(HttpHeaders.contentLengthHeader) ?? '-1');
@override
String? get eTag => _response.headers.value(HttpHeaders.etagHeader);
@override
String get fileExtension {
var fileExtension = '';
final contentTypeHeader =
_response.headers.value(HttpHeaders.contentTypeHeader);
if (contentTypeHeader != null) {
final contentType = ContentType.parse(contentTypeHeader);
fileExtension = contentType.fileExtension;
}
return fileExtension;
}
@override
int get statusCode => _response.statusCode ?? 200;
@override
DateTime get validTill {
// Without a cache-control header we keep the file for a week
var ageDuration = const Duration(days: 7);
final controlHeader =
_response.headers.value(HttpHeaders.cacheControlHeader);
if (controlHeader != null) {
final controlSettings = controlHeader.split(',');
for (final setting in controlSettings) {
final sanitizedSetting = setting.trim().toLowerCase();
if (sanitizedSetting == 'no-cache') {
ageDuration = const Duration();
}
if (sanitizedSetting.startsWith('max-age=')) {
var validSeconds = int.tryParse(sanitizedSetting.split('=')[1]) ?? 0;
if (validSeconds > 0) {
ageDuration = Duration(seconds: validSeconds);
}
}
}
}
return _receivedTime.add(ageDuration);
}
}