Files
TtxPageAdmin/lib/backend/studio_backend_client.dart
T
2026-05-16 19:03:01 +08:00

1051 lines
29 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:pointycastle/export.dart';
part 'studio_backend_support.dart';
part 'studio_backend_models.dart';
class StudioBackendClient {
// 登录与会话相关接口。
StudioBackendClient({
required String baseUrl,
String languageCode = 'zh',
Dio? dio,
}) : languageCode = languageCode,
_dio =
dio ??
Dio(
BaseOptions(
baseUrl: _normalizeBaseUrl(baseUrl),
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 20),
sendTimeout: const Duration(seconds: 20),
responseType: ResponseType.json,
followRedirects: true,
validateStatus: (_) => true,
),
) {
_dio.options.baseUrl = _normalizeBaseUrl(baseUrl);
}
final Dio _dio;
final String languageCode;
final Map<String, String> _cookies = <String, String>{};
String get baseUrl => _dio.options.baseUrl;
Future<StudioBackendLoginResult> login({
required String workspace,
required String username,
required String password,
String authType = 'normal',
}) async {
final normalizedAuthType = authType.trim().toLowerCase();
final usePasswordHash =
normalizedAuthType.isEmpty || normalizedAuthType == 'normal';
final loginPayload = <String, dynamic>{
'customer': workspace,
'username': username,
'authType': normalizedAuthType == 'normal' ? '' : authType,
'encryption': usePasswordHash ? '1' : '0',
'password': usePasswordHash
? md5.convert(utf8.encode(password)).toString()
: password,
};
final encryptedPayload = _encryptLoginPayload(loginPayload);
final response = await _postJson(
'rest/auth/login',
encryptedPayload,
session: StudioBackendSession.initial(
baseUrl: baseUrl,
workspace: workspace,
locale: languageCode,
),
includeSessionCookies: true,
forceRawBody: true,
followRedirects: false,
);
final json = response.json;
final code = _extractCode(json);
final sessionMap = _extractMap(
json['session'] ?? json['context'] ?? json['data'] ?? <String, dynamic>{},
);
final context = _extractMap(json['context']);
return StudioBackendLoginResult(
success: _isSuccess(code),
code: code,
message: _extractMessage(json),
leftDays: _extractInt(json['leftDays']),
session: sessionMap.isEmpty
? null
: StudioBackendSession.fromJson(
baseUrl: baseUrl,
raw: sessionMap,
fallbackUser: username,
fallbackWorkspace: workspace,
fallbackLocale: languageCode,
),
appConfig: _extractMap(json['appConfig'] ?? context['appConfig']),
systemConfig: _extractMap(
json['systemConfig'] ?? context['systemConfig'],
),
raw: json,
);
}
Future<StudioBackendLoginStatusResult> checkLogin() async {
final response = await _getJson(
'rest/auth/loginStatus',
includeSessionCookies: true,
);
final json = response.json;
final code = _extractCode(json);
final context = _extractMap(json['context']);
final sessionMap = _extractMap(
json['session'] ?? context['session'] ?? context,
);
return StudioBackendLoginStatusResult(
success: _isSuccess(code),
code: code,
message: _extractMessage(json),
leftDays: _extractInt(json['leftDays']),
session: sessionMap.isEmpty
? null
: StudioBackendSession.fromJson(
baseUrl: baseUrl,
raw: sessionMap,
fallbackUser: _extractString(sessionMap['user']),
fallbackWorkspace: _extractString(sessionMap['db']),
fallbackLocale: languageCode,
),
appConfig: _extractMap(json['appConfig'] ?? context['appConfig']),
systemConfig: _extractMap(
json['systemConfig'] ?? context['systemConfig'],
),
raw: json,
);
}
Future<Map<String, dynamic>?> fetchLoginPageConfig() async {
final response = await _getJson('rest/auth/loginPageConfig');
final json = response.json;
if (_isSuccess(_extractCode(json)) && json['data'] is Map) {
return _extractMap(json['data']);
}
if (json is Map<String, dynamic>) {
return json;
}
return null;
}
Future<StudioBackendUserProfile?> fetchUserProfile(
StudioBackendSession session,
) async {
final response = await _getJson('/user/getUser', session: session);
final json = response.json;
final rawUser = _extractMap(json['user'] ?? json['data'] ?? json);
if (rawUser.isEmpty) {
return null;
}
return StudioBackendUserProfile.fromJson(rawUser);
}
Future<StudioBackendOrg?> fetchOrgByUser(StudioBackendSession session) async {
final response = await _postJson('/cbt/org/byUser', {
'userCode': session.user,
}, session: session);
final json = response.json;
final rawOrg = _extractMap(json['data'] ?? json);
if (rawOrg.isEmpty) {
return null;
}
return StudioBackendOrg.fromJson(rawOrg);
}
Future<Set<String>> fetchPermissionIds(StudioBackendSession session) async {
final response = await _postJson('studio/access/permissions', {
'user': session.user,
}, session: session);
final json = response.json;
final candidates = <dynamic>[];
final map = Map<String, dynamic>.from(json);
for (final key in [
'menus',
'navs',
'data',
'result',
'items',
'permissions',
'nodes',
'children',
]) {
final value = map[key];
if (value is List) {
candidates.addAll(value);
} else if (value is Map) {
candidates.add(value);
}
}
if (candidates.isEmpty && map['id'] != null) {
candidates.add(map);
}
final ids = <String>{};
void walk(dynamic node) {
if (node is Map) {
final map = Map<String, dynamic>.from(node);
final id = _extractString(map['id']);
if (id.isNotEmpty) {
ids.add(id);
}
for (final key in ['children', 'navs', 'menus', 'items']) {
final value = map[key];
if (value is List) {
for (final child in value) {
walk(child);
}
}
}
} else if (node is List) {
for (final child in node) {
walk(child);
}
}
}
for (final item in candidates) {
walk(item);
}
return ids;
}
Future<List<Map<String, dynamic>>> fetchUpgradeLogs({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'/upgrade/log/loadAll',
session: session,
includeSessionCookies: true,
);
return _extractListOfMaps(response.json);
}
Future<List<Map<String, dynamic>>> fetchSqlTemplateIndex({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/sqlTemplate/tidOidList',
session: session,
);
return _extractListOfMaps(response.json);
}
Future<Map<String, dynamic>?> fetchSqlTemplate({
required String tid,
required String oid,
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/sqlTemplate/$tid/$oid',
session: session,
);
final json = response.json;
final data = _extractMap(json['data'] ?? json);
return data.isEmpty ? null : data;
}
Future<StudioBackendActionResult> createSqlTemplate({
required String tid,
required String oid,
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/sqlTemplate/$tid/$oid',
payload,
session: session,
);
}
Future<StudioBackendActionResult> updateSqlTemplate({
required String tid,
required String oid,
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/sqlTemplate/update/$tid/$oid',
payload,
session: session,
);
}
Future<List<Map<String, dynamic>>> fetchCreationNavigator({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/navigator',
session: session,
);
final json = response.json;
final structure = json['structure'];
if (structure is List) {
return structure
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
return _extractListOfMaps(json['data'] ?? json);
}
Future<Map<String, dynamic>> fetchCreationNavigatorRecord({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/navigator',
session: session,
);
return _extractMap(response.json);
}
Future<StudioBackendActionResult> saveCreationNavigator({
required List<Map<String, dynamic>> data,
int? version,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/navigator',
<String, dynamic>{
'data': data,
if (version != null) 'version': version,
},
session: session,
);
}
Future<List<Map<String, dynamic>>> fetchTableModels({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/tableModels',
session: session,
);
return _extractListOfMaps(response.json);
}
Future<Map<String, dynamic>?> fetchTableModel({
required String code,
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/tableModels/$code',
session: session,
);
final data = _extractMap(response.json['data'] ?? response.json);
return data.isEmpty ? null : data;
}
Future<StudioBackendActionResult> createTableModel({
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/tableModels',
payload,
session: session,
);
}
Future<StudioBackendActionResult> updateTableModel({
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/update/tableModels',
payload,
session: session,
);
}
Future<StudioBackendActionResult> deleteTableModel({
required String code,
StudioBackendSession? session,
}) async {
return _deleteAction('rest/creation/tableModels/$code', session: session);
}
Future<List<Map<String, dynamic>>> fetchBillModels({
StudioBackendSession? session,
}) async {
final response = await _getJson('rest/creation/billModels', session: session);
return _extractListOfMaps(response.json);
}
Future<Map<String, dynamic>?> fetchBillModel({
required String code,
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/billModels/$code',
session: session,
);
final data = _extractMap(response.json['data'] ?? response.json);
return data.isEmpty ? null : data;
}
Future<StudioBackendActionResult> createBillModel({
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/billModels',
payload,
session: session,
);
}
Future<StudioBackendActionResult> updateBillModel({
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/update/billModels',
payload,
session: session,
);
}
Future<StudioBackendActionResult> deleteBillModel({
required String code,
StudioBackendSession? session,
}) async {
return _deleteAction('rest/creation/billModels/$code', session: session);
}
Future<List<Map<String, dynamic>>> fetchViewModelOptions({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/filteringSelect/viewModel',
session: session,
);
return _extractListOfMaps(response.json);
}
Future<Map<String, dynamic>?> fetchViewModel({
required String tid,
required String oid,
StudioBackendSession? session,
}) async {
final response = await _getJson(
'rest/creation/viewModels/$tid/$oid',
session: session,
);
final list = _extractListOfMaps(response.json);
if (list.isNotEmpty) {
return list.first;
}
final data = _extractMap(response.json['data'] ?? response.json);
return data.isEmpty ? null : data;
}
Future<StudioBackendActionResult> createViewModel({
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/viewModels',
payload,
session: session,
);
}
Future<StudioBackendActionResult> updateViewModel({
required Map<String, dynamic> payload,
StudioBackendSession? session,
}) async {
return _postAction(
'rest/creation/update/viewModels',
payload,
session: session,
);
}
Future<StudioBackendActionResult> deleteViewModel({
required String tid,
required String oid,
StudioBackendSession? session,
}) async {
return _deleteAction('rest/creation/viewModels/$tid/$oid', session: session);
}
Future<StudioBackendActionResult> deleteSqlTemplate({
required String tid,
required String oid,
StudioBackendSession? session,
}) async {
return _deleteAction('rest/sqlTemplate/$tid/$oid', session: session);
}
Future<List<Map<String, dynamic>>> fetchPortalConfigs({
StudioBackendSession? session,
}) async {
final response = await _getJson('rest/portal/loadAll', session: session);
return _extractListOfMaps(response.json);
}
Future<StudioBackendActionResult> repairDepartment({
StudioBackendSession? session,
}) async {
final response = await _getJson(
'/cbt/department/repair',
session: session,
);
final raw = response.json;
final code = _extractCode(raw);
return StudioBackendActionResult(
success: _isSuccess(code),
code: code,
message: _extractMessage(raw),
data: _extractData(raw),
raw: raw,
);
}
Future<StudioBackendDownloadResult> downloadModelConfigs({
StudioBackendSession? session,
}) async {
final normalizedPath = _normalizePath('rest/modelSync/downloadConfigs');
final response = await _dio.get<List<int>>(
normalizedPath,
options: Options(
headers: _headers(
requestPath: normalizedPath,
session: session,
includeSessionCookies: true,
),
responseType: ResponseType.bytes,
followRedirects: true,
receiveTimeout: const Duration(seconds: 60),
extra: const <String, dynamic>{'withCredentials': true},
),
);
_captureCookies(response);
final bytes = response.data ?? <int>[];
final filename = _filenameFromContentDisposition(
response.headers.value('content-disposition') ??
response.headers.value('Content-Disposition') ??
'',
fallback: 'download-configs.zip',
);
return StudioBackendDownloadResult(
bytes: Uint8List.fromList(bytes),
filename: filename,
contentType: response.headers.value('content-type') ?? '',
statusCode: response.statusCode ?? 0,
rawHeaders: response.headers.map,
);
}
Future<StudioBackendActionResult> runSql({
required String sql,
int limit = 100,
List<Object?> params = const <Object?>[],
StudioBackendSession? session,
}) async {
return _postAction(
'cbt/sqlrunner/run',
<String, dynamic>{
'sql': sql,
'limit': limit,
'params': params,
},
session: session,
);
}
Future<void> logout({StudioBackendSession? session}) async {
await _getJson(
'/rest/auth/logout',
session: session,
includeSessionCookies: true,
);
_cookies.clear();
}
Future<_BackendJsonResponse> _getJson(
String path, {
StudioBackendSession? session,
bool includeSessionCookies = false,
}) async {
final normalizedPath = _normalizePath(path);
final response = await _dio.get<dynamic>(
normalizedPath,
options: Options(
headers: _headers(
requestPath: normalizedPath,
session: session,
includeSessionCookies: includeSessionCookies,
),
// Web 端需要浏览器带上 cookie,避免登录态和会话丢失。
extra: const <String, dynamic>{'withCredentials': true},
),
);
_captureCookies(response);
return _BackendJsonResponse.fromResponse(response);
}
Future<_BackendJsonResponse> _postJson(
String path,
Object data, {
StudioBackendSession? session,
bool includeSessionCookies = false,
bool forceRawBody = false,
bool followRedirects = true,
}) async {
final normalizedPath = _normalizePath(path);
final response = await _dio.post<dynamic>(
normalizedPath,
data: data,
options: Options(
headers: _headers(
requestPath: normalizedPath,
session: session,
includeSessionCookies: includeSessionCookies,
json: true,
),
requestEncoder: forceRawBody && data is String
? (request, options) => utf8.encode(request)
: null,
followRedirects: followRedirects,
extra: const <String, dynamic>{'withCredentials': true},
),
);
_captureCookies(response);
return _BackendJsonResponse.fromResponse(response);
}
Future<StudioBackendActionResult> _postAction(
String path,
Map<String, dynamic> data, {
StudioBackendSession? session,
bool includeSessionCookies = false,
bool json = true,
}) async {
final response = await _postJson(
path,
data,
session: session,
includeSessionCookies: includeSessionCookies,
followRedirects: true,
);
final raw = response.json;
final code = _extractCode(raw);
return StudioBackendActionResult(
success: _isSuccess(code),
code: code,
message: _extractMessage(raw),
data: _extractData(raw),
raw: raw,
);
}
Future<StudioBackendActionResult> _deleteAction(
String path, {
StudioBackendSession? session,
}) async {
final normalizedPath = _normalizePath(path);
final response = await _dio.delete<dynamic>(
normalizedPath,
options: Options(
headers: _headers(
requestPath: normalizedPath,
session: session,
includeSessionCookies: true,
),
followRedirects: true,
extra: const <String, dynamic>{'withCredentials': true},
),
);
_captureCookies(response);
final rawResponse = _BackendJsonResponse.fromResponse(response).json;
final code = _extractCode(rawResponse);
return StudioBackendActionResult(
success: _isSuccess(code),
code: code,
message: _extractMessage(rawResponse),
data: _extractData(rawResponse),
raw: rawResponse,
);
}
Map<String, dynamic> _headers({
required String requestPath,
StudioBackendSession? session,
bool includeSessionCookies = false,
bool json = false,
}) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final headers = <String, dynamic>{
'X-Client-Language': languageCode,
'x-locale': languageCode,
'x-Language': languageCode,
'x-Timezone': _localTimezoneCode(),
'X-cbtVersion': '2.9.x',
'X-device': 'browser',
'X-t': '$timestamp',
'X-v': _buildFab(requestPath, timestamp),
'Accept': 'application/json',
};
if (json) {
headers['Content-Type'] = 'application/json';
}
if (session != null) {
headers.addAll(session.toHeaders());
}
final cookieHeader = _cookieHeader(
includeSessionCookies: includeSessionCookies,
);
if (!kIsWeb && cookieHeader.isNotEmpty) {
headers['Cookie'] = cookieHeader;
}
return headers;
}
String _cookieHeader({required bool includeSessionCookies}) {
if (_cookies.isEmpty) {
return '';
}
if (!includeSessionCookies && _cookies.isEmpty) {
return '';
}
return _cookies.entries
.map((entry) => '${entry.key}=${entry.value}')
.join('; ');
}
void _captureCookies(Response<dynamic> response) {
final values = response.headers.map['set-cookie'];
if (values == null || values.isEmpty) {
return;
}
for (final headerValue in values) {
final parts = headerValue.split(';');
if (parts.isEmpty) {
continue;
}
final pair = parts.first.trim();
final index = pair.indexOf('=');
if (index <= 0) {
continue;
}
final name = pair.substring(0, index).trim();
final value = pair.substring(index + 1).trim();
if (name.isNotEmpty) {
_cookies[name] = value;
}
}
}
String _normalizePath(String path) {
if (path.startsWith('/')) {
return path.substring(1);
}
return path;
}
static String _normalizeBaseUrl(String baseUrl) {
final normalized = baseUrl.trim().isEmpty
? 'http://localhost:9001/'
: baseUrl.trim();
return normalized.endsWith('/') ? normalized : '$normalized/';
}
static bool _isSuccess(String code) {
return code == '0' || code == '200' || code == 'true';
}
static String _extractCode(Map<String, dynamic> json) {
for (final key in ['code', 'status', 'errorCode']) {
final value = json[key];
if (value != null && value.toString().isNotEmpty) {
return value.toString();
}
}
final httpStatus = json['_httpStatus'];
if (httpStatus != null && httpStatus.toString() != '200') {
return 'HTTP $httpStatus';
}
return '';
}
static String _extractMessage(Map<String, dynamic> json) {
final redirectMessage = _extractRedirectMessage(json);
if (redirectMessage.isNotEmpty) {
return redirectMessage;
}
for (final key in ['msg', 'message', 'errorMsg', 'error', 'raw']) {
final value = json[key];
if (value != null && value.toString().isNotEmpty) {
return _truncateMessage(value.toString());
}
}
final httpStatus = json['_httpStatus'];
if (httpStatus != null && httpStatus.toString() != '200') {
return 'HTTP $httpStatus';
}
return '';
}
static String _extractRedirectMessage(Map<String, dynamic> json) {
final httpStatus = int.tryParse(json['_httpStatus']?.toString() ?? '');
if (httpStatus == null ||
!const <int>{301, 302, 303, 307, 308}.contains(httpStatus)) {
return '';
}
final location = json['_location']?.toString() ?? '';
if (location.isEmpty) {
return 'HTTP $httpStatus,后端返回重定向,但没有 Location';
}
final uri = Uri.tryParse(location);
final path = uri?.path ?? location;
final db = uri?.queryParameters['db'] ?? '';
if (path == '/api/customerError') {
final suffix = db.isEmpty ? '' : '$db';
return '工作区无效或后端未配置该客户$suffix';
}
if (path == '/init/admin') {
return '工作区尚未完成初始化:$location';
}
if (path == '/rest/redirect/noLicense') {
return '后端未安装 License';
}
if (path == '/rest/redirect/expire') {
return '后端 License 已过期';
}
if (path == '/rest/redirect/onlineExceed' ||
path == '/rest/redirect/onlineOver') {
return '后端在线人数超过授权限制';
}
if (path == '/rest/redirect/403') {
return '后端拒绝访问,可能是权限或 IP 风控拦截';
}
if (path == '/rest/redirect/timeout') {
return '登录会话已超时,请重试';
}
return 'HTTP $httpStatus,后端重定向到:$location';
}
static String _truncateMessage(String message) {
const maxLength = 600;
if (message.length <= maxLength) {
return message;
}
return '${message.substring(0, maxLength)}...';
}
static Map<String, dynamic> _extractMap(dynamic value) {
if (value is Map) {
return Map<String, dynamic>.from(value);
}
return <String, dynamic>{};
}
static List<Map<String, dynamic>> _extractListOfMaps(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
}
if (value is Map) {
final map = Map<String, dynamic>.from(value);
for (final key in ['data', 'list', 'items', 'rows', 'result']) {
final candidate = map[key];
if (candidate is List) {
return _extractListOfMaps(candidate);
}
}
}
return <Map<String, dynamic>>[];
}
static dynamic _extractData(Map<String, dynamic> json) {
for (final key in ['data', 'result', 'items', 'list']) {
final value = json[key];
if (value != null) {
return value;
}
}
return json;
}
static String _filenameFromContentDisposition(
String contentDisposition, {
required String fallback,
}) {
if (contentDisposition.isEmpty) {
return fallback;
}
final match = RegExp(
r'''filename\*?=(?:UTF-8'')?"?([^";]+)"?''',
).firstMatch(contentDisposition);
if (match != null) {
return Uri.decodeFull(match.group(1) ?? fallback);
}
return fallback;
}
static String _extractString(dynamic value) {
if (value == null) {
return '';
}
return value.toString();
}
static int? _extractInt(dynamic value) {
if (value == null) {
return null;
}
if (value is int) {
return value;
}
return int.tryParse(value.toString());
}
String _encryptLoginPayload(Map<String, dynamic> payload) {
final plainText = jsonEncode(payload);
final seed = _keyGen();
final keyHex = _sha1PrngSecureRandoms(seed);
final keyBytes = _hexToUnsignedBytes(keyHex);
final cipher = PaddedBlockCipher('AES/ECB/PKCS7')
..init(
true,
PaddedBlockCipherParameters<KeyParameter, Null>(
KeyParameter(Uint8List.fromList(keyBytes)),
null,
),
);
final encryptedBytes = cipher.process(
Uint8List.fromList(utf8.encode(plainText)),
);
return base64Encode(encryptedBytes);
}
String _keyGen() {
final seed = DateTime.now().millisecondsSinceEpoch ~/ 60000;
final head = sha1
.convert(utf8.encode(seed.toRadixString(16)))
.toString()
.substring(0, 12)
.toUpperCase();
final middle = md5
.convert(utf8.encode(seed.toRadixString(24)))
.toString()
.substring(0, 6)
.toLowerCase();
final tail = sha256
.convert(utf8.encode(seed.toRadixString(36)))
.toString()
.substring(0, 14)
.toUpperCase();
return '$head$middle$tail';
}
String _sha1PrngSecureRandoms(String seed, {int digestHexSize = 64}) {
var state = sha1.convert(utf8.encode(seed)).toString();
var output = sha1.convert(_hexToUnsignedBytes(state)).toString();
var key = output;
while (key.length < digestHexSize) {
state = _updateHexState(state, output);
output = sha1.convert(_hexToUnsignedBytes(state)).toString();
key += output;
}
return key.substring(0, digestHexSize);
}
String _updateHexState(String stateHex, String outputHex) {
final state = _hexToSignedBytes(stateHex);
final output = _hexToSignedBytes(outputHex);
var last = 1;
var changed = false;
for (var index = 0; index < state.length; index++) {
final raw = state[index] + output[index] + last;
changed = changed || state[index] != raw;
var normalized = raw;
if (normalized > 127) {
normalized -= 256;
} else if (normalized < -128) {
normalized += 256;
}
state[index] = normalized;
last = raw >> 8;
}
if (!changed && state.isNotEmpty) {
state[0] = state[0] + 1;
}
return _signedBytesToHex(state);
}
Uint8List _hexToUnsignedBytes(String hexText) {
if (hexText.length.isOdd) {
throw const FormatException('Invalid hex length');
}
final bytes = Uint8List(hexText.length ~/ 2);
for (var index = 0; index < bytes.length; index++) {
final pair = hexText.substring(index * 2, index * 2 + 2);
bytes[index] = int.parse(pair, radix: 16);
}
return bytes;
}
List<int> _hexToSignedBytes(String hexText) {
final unsigned = _hexToUnsignedBytes(hexText);
return unsigned.map((value) => value > 127 ? value - 256 : value).toList();
}
String _signedBytesToHex(List<int> signedBytes) {
final buffer = StringBuffer();
for (final value in signedBytes) {
final unsigned = value < 0 ? value + 256 : value;
buffer.write(unsigned.toRadixString(16).padLeft(2, '0'));
}
return buffer.toString();
}
String _buildFab(String requestPath, int timestamp, {String button = ''}) {
String uriPath = requestPath;
if (uriPath.toLowerCase().startsWith('http://') ||
uriPath.toLowerCase().startsWith('https://')) {
uriPath = Uri.parse(uriPath).path;
} else {
final fixed = uriPath.startsWith('/') ? uriPath : '/$uriPath';
uriPath = Uri.parse('http://x$fixed').path;
}
final encodedUri = Uri.encodeComponent(uriPath);
final seed =
'cyber$timestamp$encodedUri$timestamp$encodedUri${button}trans';
final base64Seed = base64Encode(utf8.encode(seed));
final digest = sha256.convert(utf8.encode(base64Seed));
return Uri.encodeComponent(base64Encode(digest.bytes));
}
}