Restore legacy sign in in debug mode for quick dev access

This commit is contained in:
Ming Ming 2022-11-22 21:46:28 +08:00
parent 34066215c0
commit dbe74cf2d5
5 changed files with 842 additions and 0 deletions

View file

@ -0,0 +1,136 @@
import 'dart:io';
import 'package:bloc/bloc.dart';
import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/exception.dart';
abstract class AppPasswordExchangeBlocEvent {
const AppPasswordExchangeBlocEvent();
}
class AppPasswordExchangeBlocConnect extends AppPasswordExchangeBlocEvent {
const AppPasswordExchangeBlocConnect(this.account);
@override
toString() {
return "$runtimeType {"
"account: $account, "
"}";
}
final Account account;
}
abstract class AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocState();
}
class AppPasswordExchangeBlocInit extends AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocInit();
}
class AppPasswordExchangeBlocSuccess extends AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocSuccess(this.password);
@override
toString() {
return "$runtimeType {"
"password: ${kDebugMode ? password : '***'}, "
"}";
}
final String password;
}
class AppPasswordExchangeBlocFailure extends AppPasswordExchangeBlocState {
const AppPasswordExchangeBlocFailure(this.exception);
@override
toString() {
return "$runtimeType {"
"exception: $exception, "
"}";
}
final dynamic exception;
}
/// Legacy sign in support, may be removed any time in the future
class AppPasswordExchangeBloc
extends Bloc<AppPasswordExchangeBlocEvent, AppPasswordExchangeBlocState> {
AppPasswordExchangeBloc() : super(const AppPasswordExchangeBlocInit()) {
on<AppPasswordExchangeBlocEvent>(_onEvent);
}
Future<void> _onEvent(AppPasswordExchangeBlocEvent event,
Emitter<AppPasswordExchangeBlocState> emit) async {
_log.info("[_onEvent] $event");
if (event is AppPasswordExchangeBlocConnect) {
await _onEventConnect(event, emit);
}
}
Future<void> _onEventConnect(AppPasswordExchangeBlocConnect ev,
Emitter<AppPasswordExchangeBlocState> emit) async {
final account = ev.account;
try {
final appPwd = await _exchangePassword(account);
emit(AppPasswordExchangeBlocSuccess(appPwd));
} on InvalidBaseUrlException catch (e) {
_log.warning("[_onEventConnect] Invalid base url");
emit(AppPasswordExchangeBlocFailure(e));
} on HandshakeException catch (e) {
_log.info("[_onEventConnect] Self-signed cert");
emit(AppPasswordExchangeBlocFailure(e));
} catch (e, stacktrace) {
if (e is ApiException && e.response.statusCode == 401) {
// wrong password, normal
_log.warning("[_onEventConnect] Server response 401, wrong password?");
} else {
_log.shout("[_onEventConnect] Failed while exchanging password", e,
stacktrace);
}
emit(AppPasswordExchangeBlocFailure(e));
}
}
/// Query the app password for [account]
static Future<String> _exchangePassword(Account account) async {
final response = await Api(account).request(
"GET",
"ocs/v2.php/core/getapppassword",
header: {
"OCS-APIRequest": "true",
},
);
if (response.isGood) {
try {
final appPwdRegex = RegExp(r"<apppassword>(.*)</apppassword>");
final appPwdMatch = appPwdRegex.firstMatch(response.body);
return appPwdMatch!.group(1)!;
} catch (_) {
// this happens when the address is not the base URL and so Nextcloud
// returned the login page
throw InvalidBaseUrlException();
}
} else if (response.statusCode == 403) {
// If the client is authenticated with an app password a 403 will be
// returned
_log.info("[_exchangePassword] Already an app password");
return account.password;
} else {
_log.severe(
"[_exchangePassword] Failed while requesting app password: $response");
throw ApiException(
response: response,
message:
"Server responed with an error: HTTP ${response.statusCode}");
}
}
static final _log =
Logger("legacy.app_password_exchange_bloc.AppPasswordExchangeBloc");
}

319
app/lib/legacy/connect.dart Normal file
View file

@ -0,0 +1,319 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/help_utils.dart' as help_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/legacy/app_password_exchange_bloc.dart';
import 'package:nc_photos/mobile/self_signed_cert_manager.dart';
import 'package:nc_photos/platform/features.dart' as features;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/string_extension.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/use_case/ls_single_file.dart';
class ConnectArguments {
ConnectArguments(this.account);
final Account account;
}
/// Legacy sign in support, may be removed any time in the future
class Connect extends StatefulWidget {
static const routeName = "/connect-legacy";
static Route buildRoute(ConnectArguments args) => MaterialPageRoute<Account>(
builder: (context) => Connect.fromArgs(args),
);
const Connect({
Key? key,
required this.account,
}) : super(key: key);
Connect.fromArgs(ConnectArguments args, {Key? key})
: this(
key: key,
account: args.account,
);
@override
createState() => _ConnectState();
final Account account;
}
class _ConnectState extends State<Connect> {
@override
initState() {
super.initState();
_initBloc();
}
@override
build(BuildContext context) {
return Scaffold(
body: BlocListener<AppPasswordExchangeBloc, AppPasswordExchangeBlocState>(
bloc: _bloc,
listener: (context, state) => _onStateChange(context, state),
child: Builder(builder: (context) => _buildContent(context)),
),
);
}
void _initBloc() {
_log.info("[_initBloc] Initialize bloc");
_connect();
}
Widget _buildContent(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud,
size: 128,
color: Theme.of(context).colorScheme.primary,
),
Text(
L10n.global().connectingToServer(widget.account.url),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headline6,
)
],
),
),
);
}
void _onStateChange(
BuildContext context, AppPasswordExchangeBlocState state) {
if (state is AppPasswordExchangeBlocSuccess) {
final newAccount = widget.account.copyWith(password: state.password);
_log.info("[_onStateChange] Password exchanged: $newAccount");
_checkWebDavUrl(context, newAccount);
} else if (state is AppPasswordExchangeBlocFailure) {
if (features.isSupportSelfSignedCert &&
state.exception is HandshakeException) {
_onSelfSignedCert(context);
} else if (state.exception is ApiException &&
(state.exception as ApiException).response.statusCode == 401) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().errorWrongPassword),
duration: k.snackBarDurationNormal,
));
Navigator.of(context).pop(null);
} else {
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(state.exception)),
duration: k.snackBarDurationNormal,
));
Navigator.of(context).pop(null);
}
}
}
void _onSelfSignedCert(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(L10n.global().serverCertErrorDialogTitle),
content: Text(L10n.global().serverCertErrorDialogContent),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(MaterialLocalizations.of(context).closeButtonLabel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(L10n.global().advancedButtonLabel),
),
],
),
).then((value) {
if (value != true) {
Navigator.of(context).pop(null);
return;
}
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(L10n.global().whitelistCertDialogTitle),
content: Text(L10n.global().whitelistCertDialogContent(
SelfSignedCertManager().getLastBadCertHost(),
SelfSignedCertManager().getLastBadCertFingerprint())),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: Text(L10n.global().whitelistCertButtonLabel),
),
],
),
).then((value) {
if (value != true) {
Navigator.of(context).pop(null);
return;
}
SelfSignedCertManager().whitelistLastBadCert().then((value) {
Navigator.of(context).pop(null);
});
});
});
}
void _connect() {
_bloc.add(AppPasswordExchangeBlocConnect(widget.account));
}
Future<void> _onCheckWebDavUrlFailed(
BuildContext context, Account account) async {
final userId = await _askWebDavUrl(context, account);
if (userId != null) {
final newAccount = account.copyWith(
userId: userId.toCi(),
);
return _checkWebDavUrl(context, newAccount);
}
}
Future<void> _checkWebDavUrl(BuildContext context, Account account) async {
// check the WebDAV URL
try {
final c = KiwiContainer().resolve<DiContainer>();
await LsSingleFile(c.withRemoteFileRepo())(
account, file_util.unstripPath(account, ""));
_log.info("[_checkWebDavUrl] Account is good: $account");
Navigator.of(context).pop(account);
} on ApiException catch (e) {
if (e.response.statusCode == 404) {
return _onCheckWebDavUrlFailed(context, account);
}
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
Navigator.of(context).pop(null);
} on StateError catch (_) {
// Nextcloud for some reason doesn't return HTTP error when listing home
// dir of other users
return _onCheckWebDavUrlFailed(context, account);
} catch (e, stackTrace) {
_log.shout("[_checkWebDavUrl] Failed", e, stackTrace);
SnackBarManager().showSnackBar(SnackBar(
content: Text(exception_util.toUserString(e)),
duration: k.snackBarDurationNormal,
));
Navigator.of(context).pop(null);
}
}
Future<String?> _askWebDavUrl(BuildContext context, Account account) {
return showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => _WebDavUrlDialog(account: account),
);
}
final _bloc = AppPasswordExchangeBloc();
static final _log = Logger("widget.connect._ConnectState");
}
class _WebDavUrlDialog extends StatefulWidget {
const _WebDavUrlDialog({
Key? key,
required this.account,
}) : super(key: key);
@override
createState() => _WebDavUrlDialogState();
final Account account;
}
class _WebDavUrlDialogState extends State<_WebDavUrlDialog> {
@override
build(BuildContext context) {
return AlertDialog(
title: Text(L10n.global().homeFolderNotFoundDialogTitle),
content: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(L10n.global().homeFolderNotFoundDialogContent),
const SizedBox(height: 16),
Text("${widget.account.url}/remote.php/dav/files/"),
TextFormField(
validator: (value) {
if (value?.trimAny("/").isNotEmpty == true) {
return null;
}
return L10n.global().homeFolderInputInvalidEmpty;
},
onSaved: (value) {
_formValue.userId = value!.trimAny("/");
},
initialValue: widget.account.userId.toString(),
),
],
),
),
actions: [
TextButton(
onPressed: _onHelpPressed,
child: Text(L10n.global().helpButtonLabel),
),
TextButton(
onPressed: _onOkPressed,
child: Text(MaterialLocalizations.of(context).okButtonLabel),
),
],
);
}
void _onOkPressed() {
if (_formKey.currentState?.validate() == true) {
_formKey.currentState!.save();
Navigator.of(context).pop(_formValue.userId);
}
}
void _onHelpPressed() {
launch(help_util.homeFolderNotFoundUrl);
}
final _formKey = GlobalKey<FormState>();
final _formValue = _FormValue();
}
class _FormValue {
late String userId;
}

356
app/lib/legacy/sign_in.dart Normal file
View file

@ -0,0 +1,356 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/help_utils.dart' as help_utils;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/legacy/connect.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/pref_util.dart' as pref_util;
import 'package:nc_photos/string_extension.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/url_launcher_util.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/root_picker.dart';
class SignIn extends StatefulWidget {
static const routeName = "/sign-in-legacy";
const SignIn({
Key? key,
}) : super(key: key);
@override
createState() => _SignInState();
}
class _SignInState extends State<SignIn> {
@override
build(BuildContext context) {
return Scaffold(
body: Builder(builder: (context) => _buildContent(context)),
);
}
Widget _buildContent(BuildContext context) {
if (_isConnecting) {
return Stack(
children: const [
Positioned(
left: 0,
right: 0,
bottom: 64,
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(),
),
),
),
],
);
} else {
return SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints viewportConstraints) {
return Form(
key: _formKey,
child: SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: viewportConstraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(24),
child: Text(
L10n.global().signInHeaderText,
style: Theme.of(context).textTheme.headline5,
textAlign: TextAlign.center,
),
),
Align(
alignment: Alignment.center,
child: Container(
constraints: BoxConstraints(
maxWidth:
Theme.of(context).widthLimitedContentMaxWidth,
),
padding: const EdgeInsets.symmetric(horizontal: 32),
child: _buildForm(context),
),
),
Container(
alignment: AlignmentDirectional.centerStart,
constraints: BoxConstraints(
maxWidth:
Theme.of(context).widthLimitedContentMaxWidth,
),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: InkWell(
onTap: () {
launch(help_utils.twoFactorAuthUrl);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.help_outline, size: 16),
const SizedBox(width: 4),
Expanded(
child:
Text(L10n.global().signIn2faHintText),
),
],
),
),
),
),
if (!platform_k.isWeb) Expanded(child: Container()),
Container(
constraints: BoxConstraints(
maxWidth:
Theme.of(context).widthLimitedContentMaxWidth,
),
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (!ModalRoute.of(context)!.isFirst)
TextButton(
onPressed: () {
Navigator.pop(context);
},
child: Text(MaterialLocalizations.of(context)
.cancelButtonLabel),
)
else
Container(),
ElevatedButton(
onPressed: () {
if (_formKey.currentState?.validate() ==
true) {
_connect();
}
},
child: Text(L10n.global().connectButtonLabel),
),
],
),
),
],
),
),
),
),
);
},
),
);
}
}
Widget _buildForm(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Icon(
Icons.cloud,
color: Theme.of(context).colorScheme.primary,
size: 72,
),
),
const SizedBox(height: 8),
Row(
children: [
SizedBox(
width: 64,
child: DropdownButtonHideUnderline(
child: DropdownButtonFormField<_Scheme>(
value: _scheme,
items: [_Scheme.http, _Scheme.https]
.map((e) => DropdownMenuItem<_Scheme>(
value: e,
child: Text(e.toValueString()),
))
.toList(),
onChanged: (newValue) {
setState(() {
_scheme = newValue!;
});
},
onSaved: (value) {
_formValue.scheme = value!.toValueString();
},
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text("://"),
),
Expanded(
child: TextFormField(
decoration: InputDecoration(
hintText: L10n.global().serverAddressInputHint,
),
keyboardType: TextInputType.url,
validator: (value) {
if (value!.trim().trimRightAny("/").isEmpty) {
return L10n.global().serverAddressInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.address = value!.trim().trimRightAny("/");
},
),
),
],
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
hintText: L10n.global().usernameInputHint,
),
validator: (value) {
if (value!.trim().isEmpty) {
return L10n.global().usernameInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.username = value!;
},
),
const SizedBox(height: 8),
TextFormField(
decoration: InputDecoration(
hintText: L10n.global().passwordInputHint,
),
obscureText: true,
validator: (value) {
if (value!.trim().isEmpty) {
return L10n.global().passwordInputInvalidEmpty;
}
return null;
},
onSaved: (value) {
_formValue.password = value!;
},
),
],
);
}
Future<void> _connect() async {
_formKey.currentState!.save();
Account? account = Account(
Account.newId(),
_formValue.scheme,
_formValue.address,
_formValue.username.toCi(),
_formValue.username,
_formValue.password,
[""],
);
_log.info("[_connect] Try connecting with account: $account");
account = await Navigator.pushNamed<Account>(context, Connect.routeName,
arguments: ConnectArguments(account));
if (account == null) {
// connection failed
return;
}
account = await Navigator.pushNamed<Account>(context, RootPicker.routeName,
arguments: RootPickerArguments(account));
if (account == null) {
// ???
return;
}
// we've got a good account
setState(() {
_isConnecting = true;
});
try {
await _persistAccount(account);
unawaited(
Navigator.pushNamedAndRemoveUntil(
context, Home.routeName, (route) => false,
arguments: HomeArguments(account)),
);
} catch (_) {
setState(() {
_isConnecting = false;
});
rethrow;
}
}
Future<void> _persistAccount(Account account) async {
final c = KiwiContainer().resolve<DiContainer>();
await c.sqliteDb.use((db) async {
await db.insertAccountOf(account);
});
// only signing in with app password would trigger distinct
final accounts = (Pref().getAccounts3Or([])..add(account)).distinct();
try {
AccountPref.setGlobalInstance(
account, await pref_util.loadAccountPref(account));
} catch (e, stackTrace) {
_log.shout("[_connect] Failed reading pref for account: $account", e,
stackTrace);
}
unawaited(Pref().setAccounts3(accounts));
unawaited(Pref().setCurrentAccountIndex(accounts.indexOf(account)));
}
final _formKey = GlobalKey<FormState>();
var _scheme = _Scheme.https;
var _isConnecting = false;
final _formValue = _FormValue();
static final _log = Logger("widget.sign_in._SignInState");
}
enum _Scheme {
http,
https,
}
extension on _Scheme {
String toValueString() {
switch (this) {
case _Scheme.http:
return "http";
case _Scheme.https:
return "https";
default:
throw StateError("Unknown value: $this");
}
}
}
class _FormValue {
late String scheme;
late String address;
late String username;
late String password;
}

View file

@ -4,6 +4,8 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/legacy/connect.dart' as legacy;
import 'package:nc_photos/legacy/sign_in.dart' as legacy;
import 'package:nc_photos/navigation_manager.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
@ -137,6 +139,7 @@ class _MyAppState extends State<MyApp>
Setup.routeName: (context) => const Setup(),
SignIn.routeName: (context) => const SignIn(),
Splash.routeName: (context) => const Splash(),
legacy.SignIn.routeName: (_) => const legacy.SignIn(),
};
Route<dynamic>? _onGenerateRoute(RouteSettings settings) {
@ -145,6 +148,7 @@ class _MyAppState extends State<MyApp>
route ??= _handleBasicRoute(settings);
route ??= _handleViewerRoute(settings);
route ??= _handleConnectRoute(settings);
route ??= _handleConnectLegacyRoute(settings);
route ??= _handleHomeRoute(settings);
route ??= _handleRootPickerRoute(settings);
route ??= _handleAlbumBrowserRoute(settings);
@ -221,6 +225,19 @@ class _MyAppState extends State<MyApp>
return null;
}
Route<dynamic>? _handleConnectLegacyRoute(RouteSettings settings) {
try {
if (settings.name == legacy.Connect.routeName &&
settings.arguments != null) {
final args = settings.arguments as legacy.ConnectArguments;
return legacy.Connect.buildRoute(args);
}
} catch (e) {
_log.severe("[_handleConnectLegacyRoute] Failed while handling route", e);
}
return null;
}
Route<dynamic>? _handleHomeRoute(RouteSettings settings) {
try {
if (settings.name == Home.routeName && settings.arguments != null) {

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
@ -8,6 +9,7 @@ import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/legacy/sign_in.dart' as legacy;
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/pref_util.dart' as pref_util;
@ -191,6 +193,18 @@ class _SignInState extends State<SignIn> {
),
],
),
if (kDebugMode) ...[
const SizedBox(height: 8),
InkWell(
onTap: () {
Navigator.pushReplacementNamed(context, legacy.SignIn.routeName);
},
child: const Text(
"Legacy sign in",
style: TextStyle(decoration: TextDecoration.underline),
),
),
],
],
);
}