This commit is contained in:
2023-08-28 22:05:10 +08:00
parent 73f40a30e6
commit 3c2201d00b
14 changed files with 471 additions and 49 deletions

View File

@@ -1,19 +1,75 @@
import 'dart:convert';
import 'package:cryptography/cryptography.dart';
import 'package:dio/dio.dart';
import 'package:retrofit/retrofit.dart';
import 'api_result.dart';
import 'status.dart';
import 'token.dart';
import 'user.dart';
part 'client.g.dart';
@RestApi(parser: Parser.FlutterCompute)
final _pbkdf2a = Pbkdf2(
macAlgorithm: Hmac.sha512(),
iterations: 210000,
bits: 512,
);
final _pbkdf2b = Pbkdf2(
macAlgorithm: Hmac.sha512(),
iterations: 1000,
bits: 512,
);
const _utf8Encoder = Utf8Encoder();
final _salt = _utf8Encoder.convert("eh-downloader-salt");
@RestApi()
abstract class EHApi {
factory EHApi(Dio dio, {String baseUrl}) = _EHApi;
@PUT('/user')
Future<ApiResult<int>> createUser(
@Query("name") String name, @Query("password") String password,
{@Query("is_admin") bool? isAdmin,
@Query("permissions") int? permissions});
@GET('/user')
Future<ApiResult<BUser>> getUser();
@GET('/user')
Future<ApiResult<BUser>> getUserById(@Query("id") int id);
@GET('/user')
Future<ApiResult<BUser>> getUserByUsername(
@Query("username") String username);
Future<ApiResult<BUser>> getUser(
{@Query("id") int? id, @Query("username") String? username});
@GET('/status')
Future<ApiResult<ServerStatus>> getStatus();
@PUT('/token')
Future<ApiResult<Token>> _createToken(
{@Query("username") String username,
@Query("password") String password,
@Query("t") int t,
@Query("set_cookie") bool? setCookie,
@Query("http_only") bool? httpOnly,
@Query("secure") bool? secure});
Future<ApiResult<Token>> createToken(
{required String username,
required String password,
bool? setCookie,
bool? httpOnly,
bool? secure}) async {
int t = DateTime.now().millisecondsSinceEpoch;
final p =
await _pbkdf2a.deriveKeyFromPassword(password: password, nonce: _salt);
final p2 = await _pbkdf2b.deriveKey(
secretKey: p, nonce: _utf8Encoder.convert(t.toString()));
final p3 = base64Encode(await p2.extractBytes());
return await _createToken(
username: username,
password: p3,
t: t,
setCookie: setCookie,
httpOnly: httpOnly,
secure: secure);
}
@DELETE('/token')
Future<ApiResult<bool>> deleteToken({@Query("token") String? token});
@GET('/token')
Future<ApiResult<Token>> getToken({@Query("token") String? token});
}

View File

@@ -19,20 +19,98 @@ class _EHApi implements EHApi {
String? baseUrl;
@override
Future<ApiResult<BUser>> getUser() async {
Future<ApiResult<int>> createUser(
String name,
String password, {
bool? isAdmin,
int? permissions,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{
r'name': name,
r'password': password,
r'is_admin': isAdmin,
r'permissions': permissions,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<int>>(Options(
method: 'PUT',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/user',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final value = ApiResult<int>.fromJson(
_result.data!,
(json) => json as int,
);
return value;
}
@override
Future<ApiResult<BUser>> getUser({
int? id,
String? username,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{
r'id': id,
r'username': username,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<BUser>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/user',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final value = ApiResult<BUser>.fromJson(
_result.data!,
(json) => BUser.fromJson(json as Map<String, dynamic>),
);
return value;
}
@override
Future<ApiResult<ServerStatus>> getStatus() async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{};
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<BUser>>(Options(
final _result = await _dio.fetch<Map<String, dynamic>>(
_setStreamType<ApiResult<ServerStatus>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/user',
'/status',
queryParameters: queryParameters,
data: _data,
)
@@ -41,25 +119,43 @@ class _EHApi implements EHApi {
_dio.options.baseUrl,
baseUrl,
))));
final value = await compute(deserializeApiResult<BUser>, _result.data!);
final value = ApiResult<ServerStatus>.fromJson(
_result.data!,
(json) => ServerStatus.fromJson(json as Map<String, dynamic>),
);
return value;
}
@override
Future<ApiResult<BUser>> getUserById(int id) async {
Future<ApiResult<Token>> _createToken({
required String username,
required String password,
required int t,
bool? setCookie,
bool? httpOnly,
bool? secure,
}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'id': id};
final queryParameters = <String, dynamic>{
r'username': username,
r'password': password,
r't': t,
r'set_cookie': setCookie,
r'http_only': httpOnly,
r'secure': secure,
};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<BUser>>(Options(
method: 'GET',
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<Token>>(Options(
method: 'PUT',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/user',
'/token',
queryParameters: queryParameters,
data: _data,
)
@@ -68,25 +164,29 @@ class _EHApi implements EHApi {
_dio.options.baseUrl,
baseUrl,
))));
final value = await compute(deserializeApiResult<BUser>, _result.data!);
final value = ApiResult<Token>.fromJson(
_result.data!,
(json) => Token.fromJson(json as Map<String, dynamic>),
);
return value;
}
@override
Future<ApiResult<BUser>> getUserByUsername(String username) async {
Future<ApiResult<bool>> deleteToken({String? token}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'username': username};
final queryParameters = <String, dynamic>{r'token': token};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<BUser>>(Options(
method: 'GET',
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<bool>>(Options(
method: 'DELETE',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/user',
'/token',
queryParameters: queryParameters,
data: _data,
)
@@ -95,7 +195,41 @@ class _EHApi implements EHApi {
_dio.options.baseUrl,
baseUrl,
))));
final value = await compute(deserializeApiResult<BUser>, _result.data!);
final value = ApiResult<bool>.fromJson(
_result.data!,
(json) => json as bool,
);
return value;
}
@override
Future<ApiResult<Token>> getToken({String? token}) async {
const _extra = <String, dynamic>{};
final queryParameters = <String, dynamic>{r'token': token};
queryParameters.removeWhere((k, v) => v == null);
final _headers = <String, dynamic>{};
final Map<String, dynamic>? _data = null;
final _result = await _dio
.fetch<Map<String, dynamic>>(_setStreamType<ApiResult<Token>>(Options(
method: 'GET',
headers: _headers,
extra: _extra,
)
.compose(
_dio.options,
'/token',
queryParameters: queryParameters,
data: _data,
)
.copyWith(
baseUrl: _combineBaseUrls(
_dio.options.baseUrl,
baseUrl,
))));
final value = ApiResult<Token>.fromJson(
_result.data!,
(json) => Token.fromJson(json as Map<String, dynamic>),
);
return value;
}

37
lib/api/status.dart Normal file
View File

@@ -0,0 +1,37 @@
import 'package:json_annotation/json_annotation.dart';
part 'status.g.dart';
@JsonSerializable()
class MeilisearchInfo {
const MeilisearchInfo({
required this.host,
required this.key,
});
final String host;
final String key;
factory MeilisearchInfo.fromJson(Map<String, dynamic> json) => _$MeilisearchInfoFromJson(json);
Map<String, dynamic> toJson() => _$MeilisearchInfoToJson(this);
}
@JsonSerializable()
class ServerStatus {
const ServerStatus({
required this.ffmpegApiEnabled,
required this.ffmpegBinaryEnabled,
required this.meilisearchEnabled,
this.meilisearch,
required this.noUser,
});
@JsonKey(name: 'ffmpeg_api_enabled')
final bool ffmpegApiEnabled;
@JsonKey(name: 'ffmpeg_binary_enabled')
final bool ffmpegBinaryEnabled;
@JsonKey(name: 'meilisearch_enabled')
final bool meilisearchEnabled;
final MeilisearchInfo? meilisearch;
@JsonKey(name: 'no_user')
final bool noUser;
factory ServerStatus.fromJson(Map<String, dynamic> json) => _$ServerStatusFromJson(json);
Map<String, dynamic> toJson() => _$ServerStatusToJson(this);
}

39
lib/api/status.g.dart Normal file
View File

@@ -0,0 +1,39 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'status.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MeilisearchInfo _$MeilisearchInfoFromJson(Map<String, dynamic> json) =>
MeilisearchInfo(
host: json['host'] as String,
key: json['key'] as String,
);
Map<String, dynamic> _$MeilisearchInfoToJson(MeilisearchInfo instance) =>
<String, dynamic>{
'host': instance.host,
'key': instance.key,
};
ServerStatus _$ServerStatusFromJson(Map<String, dynamic> json) => ServerStatus(
ffmpegApiEnabled: json['ffmpeg_api_enabled'] as bool,
ffmpegBinaryEnabled: json['ffmpeg_binary_enabled'] as bool,
meilisearchEnabled: json['meilisearch_enabled'] as bool,
meilisearch: json['meilisearch'] == null
? null
: MeilisearchInfo.fromJson(
json['meilisearch'] as Map<String, dynamic>),
noUser: json['no_user'] as bool,
);
Map<String, dynamic> _$ServerStatusToJson(ServerStatus instance) =>
<String, dynamic>{
'ffmpeg_api_enabled': instance.ffmpegApiEnabled,
'ffmpeg_binary_enabled': instance.ffmpegBinaryEnabled,
'meilisearch_enabled': instance.meilisearchEnabled,
'meilisearch': instance.meilisearch,
'no_user': instance.noUser,
};

27
lib/api/token.dart Normal file
View File

@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
part 'token.g.dart';
@JsonSerializable()
class Token {
const Token ({
required this.id,
required this.uid,
required this.token,
required this.expired,
required this.httpOnly,
required this.secure,
});
final int id;
final int uid;
final String token;
@JsonKey(fromJson: _fromJson, toJson: _toJson)
final DateTime expired;
@JsonKey(name: 'http_only')
final bool httpOnly;
final bool secure;
static DateTime _fromJson(String d) => DateTime.parse(d);
static String _toJson(DateTime d) => d.toIso8601String();
factory Token.fromJson(Map<String, dynamic> json) => _$TokenFromJson(json);
Map<String, dynamic> toJson() => _$TokenToJson(this);
}

25
lib/api/token.g.dart Normal file
View File

@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'token.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Token _$TokenFromJson(Map<String, dynamic> json) => Token(
id: json['id'] as int,
uid: json['uid'] as int,
token: json['token'] as String,
expired: Token._fromJson(json['expired'] as String),
httpOnly: json['http_only'] as bool,
secure: json['secure'] as bool,
);
Map<String, dynamic> _$TokenToJson(Token instance) => <String, dynamic>{
'id': instance.id,
'uid': instance.uid,
'token': instance.token,
'expired': Token._toJson(instance.expired),
'http_only': instance.httpOnly,
'secure': instance.secure,
};

View File

@@ -18,12 +18,13 @@ class BUser {
const BUser({
required this.id,
required this.username,
required this.is_admin,
required this.isAdmin,
required this.permissions,
});
final int id;
final String username;
final bool is_admin;
@JsonKey(name: 'is_admin')
final bool isAdmin;
final UserPermission permissions;
factory BUser.fromJson(Map<String, dynamic> json) => _$BUserFromJson(json);
Map<String, dynamic> toJson() => _$BUserToJson(this);

View File

@@ -9,14 +9,14 @@ part of 'user.dart';
BUser _$BUserFromJson(Map<String, dynamic> json) => BUser(
id: json['id'] as int,
username: json['username'] as String,
is_admin: json['is_admin'] as bool,
isAdmin: json['is_admin'] as bool,
permissions: $enumDecode(_$UserPermissionEnumMap, json['permissions']),
);
Map<String, dynamic> _$BUserToJson(BUser instance) => <String, dynamic>{
'id': instance.id,
'username': instance.username,
'is_admin': instance.is_admin,
'is_admin': instance.isAdmin,
'permissions': _$UserPermissionEnumMap[instance.permissions]!,
};

View File

@@ -4,9 +4,11 @@ import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'api/client.dart';
final dio = Dio();
final dio = Dio()..options.validateStatus = (int? _) {return true;};
SharedPreferences? _prefs;
EHApi? _api;
Future<void> prepareJar() async {
final Directory appDocDir = await getApplicationDocumentsDirectory();
@@ -27,3 +29,18 @@ SharedPreferences get prefs {
}
return _prefs!;
}
void initApi(String baseUrl) {
_api = EHApi(dio, baseUrl: baseUrl);
}
bool get apiInited {
return _api != null;
}
EHApi get api {
if (_api == null) {
throw Exception('EHApi not initialized');
}
return _api!;
}

View File

@@ -19,6 +19,7 @@ class HomePage extends HookWidget {
});
return;
}
initApi(baseUrl);
return;
}, []);
return const Scaffold(

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'globals.dart';
class SetServerPage extends StatefulWidget {
const SetServerPage({Key? key}) : super(key: key);
@@ -10,29 +12,92 @@ class SetServerPage extends StatefulWidget {
}
class _SetServerPageState extends State<SetServerPage> {
String _serverUrl = "";
String _apiPath = "/api/";
bool _isValid = false;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
String? baseUrl = prefs.getString('baseUrl');
if (baseUrl != null) {
try {
Uri url = Uri.parse(baseUrl);
_serverUrl = url.origin;
_apiPath = url.path;
_isValid = true;
} catch (e) {
// Do nothing.
}
}
}
static bool _checkIsValid(String serverUrl, String apiPath) {
try {
Uri url = Uri.parse(serverUrl + apiPath);
return url.isAbsolute;
} catch (e) {
return false;
}
}
void _serverUrlChanged(String value) {
bool isValid = _checkIsValid(value, _apiPath);
setState(() {
_serverUrl = value;
_isValid = isValid;
});
}
void _apiPathChanged(String value) {
bool isValid = _checkIsValid(_serverUrl, value);
setState(() {
_apiPath = value;
_isValid = isValid;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
children: [
const Text('EH Downloader server url:'),
const TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
labelText: 'Server URL',
),
),
const Text('API path'),
TextField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'API Path',
),
controller: TextEditingController(text: "/api"),
),
],
)),
body: Container(
padding: const EdgeInsets.symmetric(horizontal: 100),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'EH Downloader Server URL',
),
initialValue: _serverUrl,
onChanged: _serverUrlChanged,
)),
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'API Path',
),
initialValue: _apiPath,
onChanged: _apiPathChanged,
)),
ElevatedButton(
onPressed: _isValid
? () {
prefs.setString('baseUrl', _serverUrl + _apiPath);
context.go('/');
}
: null,
child: const Text("Save")),
],
))),
);
}
}

View File

@@ -5,10 +5,12 @@
import FlutterMacOS
import Foundation
import cryptography_flutter
import path_provider_foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
CryptographyFlutterPlugin.register(with: registry.registrar(forPlugin: "CryptographyFlutterPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

@@ -169,6 +169,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
cryptography:
dependency: "direct main"
description:
name: cryptography
sha256: df156c5109286340817d21fa7b62f9140f17915077127dd70f8bd7a2a0997a35
url: "https://pub.dev"
source: hosted
version: "2.5.0"
cryptography_flutter:
dependency: "direct main"
description:
name: cryptography_flutter
sha256: a66ce021e0b600688c2d51b0594cb156ee677ce9bfbc981b62219ad577dd302e
url: "https://pub.dev"
source: hosted
version: "2.3.0"
dart_style:
dependency: transitive
description:

View File

@@ -7,6 +7,8 @@ environment:
sdk: '>=3.0.5 <4.0.0'
dependencies:
cryptography: ^2.5.0
cryptography_flutter: ^2.3.0
dio: ^5.3.2
dio_cookie_manager: ^3.1.0+1
flutter: