partial logon support
All checks were successful
BlockStorage/repertory/pipeline/head This commit looks good

This commit is contained in:
2025-03-22 01:25:30 -05:00
parent 40e57f3262
commit 5b09333f0d
12 changed files with 526 additions and 199 deletions

View File

@@ -246,8 +246,8 @@ bool validateSettings(
Future<Map<String, dynamic>> convertAllToString(
Map<String, dynamic> settings,
SecureKey key,
) async {
String? password;
Future<Map<String, dynamic>> convert(Map<String, dynamic> settings) async {
for (var entry in settings.entries) {
if (entry.value is Map<String, dynamic>) {
@@ -262,14 +262,7 @@ Future<Map<String, dynamic>> convertAllToString(
continue;
}
if (password == null) {
password = await promptPassword();
if (password == null) {
throw NullPasswordException();
}
}
settings[entry.key] = encryptValue(entry.value, password!);
settings[entry.key] = encryptValue(entry.value, key);
continue;
}
@@ -286,7 +279,7 @@ Future<Map<String, dynamic>> convertAllToString(
return convert(settings);
}
String encryptValue(String value, String password) {
String encryptValue(String value, SecureKey key) {
if (value.isEmpty) {
return value;
}
@@ -296,17 +289,12 @@ String encryptValue(String value, String password) {
return value;
}
final keyHash = sodium.crypto.genericHash(
outLen: sodium.crypto.aeadXChaCha20Poly1305IETF.keyBytes,
message: Uint8List.fromList(password.toCharArray()),
);
final crypto = sodium.crypto.aeadXChaCha20Poly1305IETF;
final nonce = sodium.secureRandom(crypto.nonceBytes).extractBytes();
final data = crypto.encrypt(
additionalData: Uint8List.fromList('repertory'.toCharArray()),
key: SecureKey.fromList(sodium, keyHash),
key: key,
message: Uint8List.fromList(value.toCharArray()),
nonce: nonce,
);

View File

@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:repertory/constants.dart' as constants;
import 'package:repertory/helpers.dart';
import 'package:repertory/models/auth.dart';
import 'package:repertory/models/mount.dart';
import 'package:repertory/models/mount_list.dart';
import 'package:repertory/screens/add_mount_screen.dart';
import 'package:repertory/screens/auth_screen.dart';
import 'package:repertory/screens/edit_mount_screen.dart';
import 'package:repertory/screens/edit_settings_screen.dart';
import 'package:repertory/screens/home_screen.dart';
@@ -17,8 +19,15 @@ void main() async {
debugPrint('$e');
}
final auth = Auth();
runApp(
ChangeNotifierProvider(create: (_) => MountList(), child: const MyApp()),
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => auth),
ChangeNotifierProvider(create: (_) => MountList(auth)),
],
child: const MyApp(),
),
);
}
@@ -55,12 +64,18 @@ class _MyAppState extends State<MyApp> {
title: constants.appTitle,
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(title: constants.appTitle),
'/add':
(context) => const AddMountScreen(title: constants.addMountTitle),
'/settings':
'/':
(context) =>
const EditSettingsScreen(title: constants.appSettingsTitle),
const AuthCheck(child: HomeScreen(title: constants.appTitle)),
'/add':
(context) => const AuthCheck(
child: AddMountScreen(title: constants.addMountTitle),
),
'/auth': (context) => const AuthScreen(title: constants.appTitle),
'/settings':
(context) => const AuthCheck(
child: EditSettingsScreen(title: constants.appSettingsTitle),
),
},
onGenerateRoute: (settings) {
if (settings.name != '/edit') {
@@ -70,10 +85,12 @@ class _MyAppState extends State<MyApp> {
final mount = settings.arguments as Mount;
return MaterialPageRoute(
builder: (context) {
return EditMountScreen(
mount: mount,
title:
'${mount.provider} [${formatMountName(mount.type, mount.name)}] Settings',
return AuthCheck(
child: EditMountScreen(
mount: mount,
title:
'${mount.provider} [${formatMountName(mount.type, mount.name)}] Settings',
),
);
},
);
@@ -81,3 +98,22 @@ class _MyAppState extends State<MyApp> {
);
}
}
class AuthCheck extends StatelessWidget {
final Widget child;
const AuthCheck({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Consumer<Auth>(
builder: (context, auth, __) {
if (!auth.authenticated) {
Navigator.of(context).pushReplacementNamed('/auth');
return SizedBox.shrink();
}
return child;
},
);
}
}

View File

@@ -0,0 +1,55 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:repertory/constants.dart' as constants;
import 'package:repertory/helpers.dart';
import 'package:sodium_libs/sodium_libs.dart';
class Auth with ChangeNotifier {
bool _authenticated = false;
SecureKey? _key;
String _user = "";
bool get authenticated => _authenticated;
SecureKey get key => _key!;
Future<void> authenticate(String user, String password) async {
final sodium = constants.sodium;
if (sodium == null) {
return;
}
final keyHash = sodium.crypto.genericHash(
outLen: sodium.crypto.aeadXChaCha20Poly1305IETF.keyBytes,
message: Uint8List.fromList(password.toCharArray()),
);
_authenticated = true;
_key = SecureKey.fromList(sodium, keyHash);
_user = user;
notifyListeners();
}
Future<String> createAuth() async {
try {
final response = await http.get(
Uri.parse(Uri.encodeFull('${getBaseUri()}/api/v1/nonce')),
);
if (response.statusCode != 200) {
return "";
}
final nonce = jsonDecode(response.body)["nonce"];
debugPrint('nonce: $nonce');
return encryptValue('${_user}_$nonce', key);
} catch (e) {
debugPrint('$e');
}
return "";
}
}

View File

@@ -3,16 +3,18 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:repertory/helpers.dart';
import 'package:repertory/models/auth.dart';
import 'package:repertory/models/mount_list.dart';
import 'package:repertory/types/mount_config.dart';
class Mount with ChangeNotifier {
final Auth _auth;
final MountConfig mountConfig;
final MountList? _mountList;
bool _isMounting = false;
bool _isRefreshing = false;
Mount(this.mountConfig, this._mountList, {isAdd = false}) {
Mount(this._auth, this.mountConfig, this._mountList, {isAdd = false}) {
if (isAdd) {
return;
}
@@ -29,9 +31,12 @@ class Mount with ChangeNotifier {
Future<void> _fetch() async {
try {
final auth = await _auth.createAuth();
final response = await http.get(
Uri.parse(
Uri.encodeFull('${getBaseUri()}/api/v1/mount?name=$name&type=$type'),
Uri.encodeFull(
'${getBaseUri()}/api/v1/mount?auth=$auth&name=$name&type=$type',
),
),
);
@@ -57,10 +62,11 @@ class Mount with ChangeNotifier {
Future<void> _fetchStatus() async {
try {
final auth = await _auth.createAuth();
final response = await http.get(
Uri.parse(
Uri.encodeFull(
'${getBaseUri()}/api/v1/mount_status?name=$name&type=$type',
'${getBaseUri()}/api/v1/mount_status?auth=$auth&name=$name&type=$type',
),
),
);
@@ -87,10 +93,11 @@ class Mount with ChangeNotifier {
Future<String?> getMountLocation() async {
try {
final auth = await _auth.createAuth();
final response = await http.get(
Uri.parse(
Uri.encodeFull(
'${getBaseUri()}/api/v1/mount_location?name=$name&type=$type',
'${getBaseUri()}/api/v1/mount_location?auth=$auth&name=$name&type=$type',
),
),
);
@@ -120,10 +127,11 @@ class Mount with ChangeNotifier {
await Future.delayed(Duration(seconds: 1));
}
final auth = await _auth.createAuth();
final response = await http.post(
Uri.parse(
Uri.encodeFull(
'${getBaseUri()}/api/v1/mount?unmount=$unmount&name=$name&type=$type&location=$location',
'${getBaseUri()}/api/v1/mount?auth=$auth&unmount=$unmount&name=$name&type=$type&location=$location',
),
),
);
@@ -167,10 +175,11 @@ class Mount with ChangeNotifier {
Future<void> setValue(String key, String value) async {
try {
final auth = await _auth.createAuth();
final response = await http.put(
Uri.parse(
Uri.encodeFull(
'${getBaseUri()}/api/v1/set_value_by_name?name=$name&type=$type&key=$key&value=$value',
'${getBaseUri()}/api/v1/set_value_by_name?auth=$auth&name=$name&type=$type&key=$key&value=$value',
),
),
);

View File

@@ -6,16 +6,21 @@ import 'package:flutter/material.dart' show ModalRoute;
import 'package:http/http.dart' as http;
import 'package:repertory/constants.dart' as constants;
import 'package:repertory/helpers.dart';
import 'package:repertory/models/auth.dart';
import 'package:repertory/models/mount.dart';
import 'package:repertory/types/mount_config.dart';
class MountList with ChangeNotifier {
MountList() {
final Auth _auth;
MountList(this._auth) {
_fetch();
}
List<Mount> _mountList = [];
Auth get auth => _auth;
UnmodifiableListView<Mount> get items =>
UnmodifiableListView<Mount>(_mountList);
@@ -46,8 +51,9 @@ class MountList with ChangeNotifier {
Future<void> _fetch() async {
try {
final auth = await _auth.createAuth();
final response = await http.get(
Uri.parse('${getBaseUri()}/api/v1/mount_list'),
Uri.parse('${getBaseUri()}/api/v1/mount_list?auth=$auth'),
);
if (response.statusCode == 404) {
@@ -64,7 +70,10 @@ class MountList with ChangeNotifier {
jsonDecode(response.body).forEach((type, value) {
nextList.addAll(
value
.map((name) => Mount(MountConfig(type: type, name: name), this))
.map(
(name) =>
Mount(_auth, MountConfig(type: type, name: name), this),
)
.toList(),
);
});
@@ -107,11 +116,15 @@ class MountList with ChangeNotifier {
}
try {
final map = await convertAllToString(jsonDecode(jsonEncode(mountConfig)));
final auth = await _auth.createAuth();
final map = await convertAllToString(
jsonDecode(jsonEncode(mountConfig)),
_auth.key,
);
final response = await http.post(
Uri.parse(
Uri.encodeFull(
'${getBaseUri()}/api/v1/add_mount?name=$name&type=$type&config=${jsonEncode(map)}',
'${getBaseUri()}/api/v1/add_mount?auth=$auth&name=$name&type=$type&config=${jsonEncode(map)}',
),
),
);

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:repertory/constants.dart' as constants;
import 'package:repertory/helpers.dart';
import 'package:repertory/models/auth.dart';
import 'package:repertory/models/mount.dart';
import 'package:repertory/models/mount_list.dart';
import 'package:repertory/types/mount_config.dart';
@@ -49,147 +50,158 @@ class _AddMountScreenState extends State<AddMountScreen> {
),
body: Padding(
padding: const EdgeInsets.all(constants.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Card(
margin: EdgeInsets.all(0.0),
child: Padding(
padding: const EdgeInsets.all(constants.padding),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Provider Type'),
const SizedBox(width: constants.padding),
DropdownButton<String>(
autofocus: true,
value: _mountType,
onChanged: (mountType) => _handleChange(mountType ?? ''),
items:
constants.providerTypeList
.map<DropdownMenuItem<String>>((item) {
return DropdownMenuItem<String>(
value: item,
child: Text(item),
);
})
.toList(),
),
],
),
),
),
if (_mountType.isNotEmpty && _mountType != 'Remote')
const SizedBox(height: constants.padding),
if (_mountType.isNotEmpty && _mountType != 'Remote')
Card(
margin: EdgeInsets.all(0.0),
child: Padding(
padding: const EdgeInsets.all(constants.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Configuration Name'),
const SizedBox(width: constants.padding),
TextField(
autofocus: true,
controller: _mountNameController,
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
],
onChanged: (_) => _handleChange(_mountType),
),
],
),
),
),
if (_mount != null) const SizedBox(height: constants.padding),
if (_mount != null)
Expanded(
child: Card(
child: Consumer<Auth>(
builder: (context, auth, _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Card(
margin: EdgeInsets.all(0.0),
child: Padding(
padding: const EdgeInsets.all(constants.padding),
child: MountSettingsWidget(
isAdd: true,
mount: _mount!,
settings: _settings[_mountType]!,
showAdvanced: _showAdvanced,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Provider Type'),
const SizedBox(width: constants.padding),
DropdownButton<String>(
autofocus: true,
value: _mountType,
onChanged:
(mountType) =>
_handleChange(auth, mountType ?? ''),
items:
constants.providerTypeList
.map<DropdownMenuItem<String>>((item) {
return DropdownMenuItem<String>(
value: item,
child: Text(item),
);
})
.toList(),
),
],
),
),
),
),
if (_mount != null) const SizedBox(height: constants.padding),
if (_mount != null)
Builder(
builder: (context) {
return ElevatedButton.icon(
onPressed: () async {
final mountList = Provider.of<MountList>(context);
if (_mountType.isNotEmpty && _mountType != 'Remote')
const SizedBox(height: constants.padding),
if (_mountType.isNotEmpty && _mountType != 'Remote')
Card(
margin: EdgeInsets.all(0.0),
child: Padding(
padding: const EdgeInsets.all(constants.padding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Configuration Name'),
const SizedBox(width: constants.padding),
TextField(
autofocus: true,
controller: _mountNameController,
keyboardType: TextInputType.text,
inputFormatters: [
FilteringTextInputFormatter.deny(RegExp(r'\s')),
],
onChanged: (_) => _handleChange(auth, _mountType),
),
],
),
),
),
if (_mount != null) const SizedBox(height: constants.padding),
if (_mount != null)
Expanded(
child: Card(
margin: EdgeInsets.all(0.0),
child: Padding(
padding: const EdgeInsets.all(constants.padding),
child: MountSettingsWidget(
isAdd: true,
mount: _mount!,
settings: _settings[_mountType]!,
showAdvanced: _showAdvanced,
),
),
),
),
if (_mount != null) const SizedBox(height: constants.padding),
if (_mount != null)
Builder(
builder: (context) {
return ElevatedButton.icon(
onPressed: () async {
final mountList = Provider.of<MountList>(context);
List<String> failed = [];
if (!validateSettings(_settings[_mountType]!, failed)) {
for (var key in failed) {
displayErrorMessage(
context,
"Setting '$key' is not valid",
List<String> failed = [];
if (!validateSettings(
_settings[_mountType]!,
failed,
)) {
for (var key in failed) {
displayErrorMessage(
context,
"Setting '$key' is not valid",
);
}
return;
}
if (mountList.hasConfigName(
_mountNameController.text,
)) {
return displayErrorMessage(
context,
"Configuration name '${_mountNameController.text}' already exists",
);
}
if (_mountType == "Sia" || _mountType == "S3") {
final bucket =
_settings[_mountType]!["${_mountType}Config"]["Bucket"]
as String;
if (mountList.hasBucketName(_mountType, bucket)) {
return displayErrorMessage(
context,
"Bucket '$bucket' already exists",
);
}
}
final success = await mountList.add(
_mountType,
_mountType == 'Remote'
? '${_settings[_mountType]!['RemoteConfig']['HostNameOrIp']}_${_settings[_mountType]!['RemoteConfig']['ApiPort']}'
: _mountNameController.text,
_settings[_mountType]!,
);
}
return;
}
if (mountList.hasConfigName(_mountNameController.text)) {
return displayErrorMessage(
context,
"Configuration name '${_mountNameController.text}' already exists",
);
}
if (!success || !context.mounted) {
return;
}
if (_mountType == "Sia" || _mountType == "S3") {
final bucket =
_settings[_mountType]!["${_mountType}Config"]["Bucket"]
as String;
if (mountList.hasBucketName(_mountType, bucket)) {
return displayErrorMessage(
context,
"Bucket '$bucket' already exists",
);
}
}
final success = await mountList.add(
_mountType,
_mountType == 'Remote'
? '${_settings[_mountType]!['RemoteConfig']['HostNameOrIp']}_${_settings[_mountType]!['RemoteConfig']['ApiPort']}'
: _mountNameController.text,
_settings[_mountType]!,
Navigator.pop(context);
},
label: const Text('Add'),
icon: const Icon(Icons.add),
);
if (!success || !context.mounted) {
return;
}
Navigator.pop(context);
},
label: const Text('Add'),
icon: const Icon(Icons.add),
);
},
),
],
),
],
);
},
),
),
);
}
void _handleChange(String mountType) {
void _handleChange(Auth auth, String mountType) {
setState(() {
final changed = _mountType != mountType;
@@ -204,6 +216,7 @@ class _AddMountScreenState extends State<AddMountScreen> {
(_mountNameController.text.isEmpty)
? null
: Mount(
auth,
MountConfig(
name: _mountNameController.text,
settings: _settings[mountType],

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:repertory/constants.dart' as constants;
import 'package:repertory/models/auth.dart';
class AuthScreen extends StatefulWidget {
final String title;
const AuthScreen({super.key, required this.title});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
bool _enabled = true;
final _passwordController = TextEditingController();
final _userController = TextEditingController();
@override
Widget build(context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Consumer<Auth>(
builder: (context, auth, _) {
if (auth.authenticated) {
Navigator.of(context).pushReplacementNamed('/');
return SizedBox.shrink();
}
return Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(constants.padding),
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Logon to Repertory Portal',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: constants.padding),
TextField(
decoration: InputDecoration(labelText: 'Username'),
controller: _userController,
),
const SizedBox(height: constants.padding),
TextField(
obscureText: true,
decoration: InputDecoration(labelText: 'Password'),
controller: _passwordController,
),
const SizedBox(height: constants.padding),
ElevatedButton(
onPressed:
_enabled
? () async {
setState(() => _enabled = false);
await auth.authenticate(
_userController.text,
_passwordController.text,
);
setState(() => _enabled = true);
}
: null,
child: const Text('Login'),
),
],
),
),
),
),
);
},
),
);
}
@override
void setState(VoidCallback fn) {
if (!mounted) {
return;
}
super.setState(fn);
}
}

View File

@@ -2,7 +2,9 @@ import 'dart:convert' show jsonDecode, jsonEncode;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'package:repertory/helpers.dart';
import 'package:repertory/models/auth.dart';
import 'package:repertory/widgets/ui_settings.dart';
class EditSettingsScreen extends StatefulWidget {
@@ -41,8 +43,9 @@ class _EditSettingsScreenState extends State<EditSettingsScreen> {
Future<Map<String, dynamic>> _grabSettings() async {
try {
final auth = await Provider.of<Auth>(context, listen: false).createAuth();
final response = await http.get(
Uri.parse('${getBaseUri()}/api/v1/settings'),
Uri.parse('${getBaseUri()}/api/v1/settings?auth=$auth'),
);
if (response.statusCode != 200) {

View File

@@ -7,6 +7,7 @@ import 'package:repertory/helpers.dart'
getChanged,
getSettingDescription,
getSettingValidators;
import 'package:repertory/models/auth.dart';
import 'package:repertory/models/mount.dart';
import 'package:repertory/models/mount_list.dart';
import 'package:repertory/settings.dart';
@@ -622,7 +623,8 @@ class _MountSettingsWidgetState extends State<MountSettingsWidget> {
widget.settings,
);
if (settings.isNotEmpty) {
convertAllToString(settings).then((map) {
final authProvider = Provider.of<Auth>(context, listen: false);
convertAllToString(settings, authProvider.key).then((map) {
map.forEach((key, value) {
if (value is Map<String, dynamic>) {
value.forEach((subKey, subValue) {

View File

@@ -2,6 +2,7 @@ import 'dart:convert' show jsonEncode;
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import 'package:repertory/helpers.dart'
show
convertAllToString,
@@ -11,6 +12,7 @@ import 'package:repertory/helpers.dart'
getSettingDescription,
getSettingValidators,
trimNotEmptyValidator;
import 'package:repertory/models/auth.dart';
import 'package:repertory/settings.dart';
import 'package:settings_ui/settings_ui.dart';
@@ -103,13 +105,15 @@ class _UISettingsWidgetState extends State<UISettingsWidget> {
void dispose() {
final settings = getChanged(widget.origSettings, widget.settings);
if (settings.isNotEmpty) {
convertAllToString(settings)
final authProvider = Provider.of<Auth>(context, listen: false);
convertAllToString(settings, authProvider.key)
.then((map) async {
try {
final auth = await authProvider.createAuth();
final response = await http.put(
Uri.parse(
Uri.encodeFull(
'${getBaseUri()}/api/v1/settings?data=${jsonEncode(map)}',
'${getBaseUri()}/api/v1/settings?auth=$auth&data=${jsonEncode(map)}',
),
),
);