diff --git a/.run/file_access_legacy.run.xml b/.run/file_access_legacy.run.xml new file mode 100644 index 0000000..027686e --- /dev/null +++ b/.run/file_access_legacy.run.xml @@ -0,0 +1,7 @@ + + + + diff --git a/.vscode/launch.json b/.vscode/launch.json index 5aa1f6d..1ffc76b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,21 +6,39 @@ "configurations": [ { - "name": "pj_supermarket_cloud", + "name": "pj_supermarket_cloud(新版签名)", "request": "launch", - "type": "dart" + "type": "dart", + "toolArgs": [ + "--dart-define=FILE_ACCESS_MODE=signed", + "--dart-define-from-file=.file-access/config.json" + ] + }, + { + "name": "pj_supermarket_cloud(旧版兼容,无配置)", + "request": "launch", + "type": "dart", + "toolArgs": [ + "--dart-define=FILE_ACCESS_MODE=legacy" + ] }, { "name": "pj_supermarket_cloud (profile mode)", "request": "launch", "type": "dart", - "flutterMode": "profile" + "flutterMode": "profile", + "toolArgs": [ + "--dart-define-from-file=.file-access/config.json" + ] }, { "name": "pj_supermarket_cloud (release mode)", "request": "launch", "type": "dart", - "flutterMode": "release" + "flutterMode": "release", + "toolArgs": [ + "--dart-define-from-file=.file-access/config.json" + ] } ] -} \ No newline at end of file +} diff --git a/README.md b/README.md index 449c11d..1fecbed 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,55 @@ flutter pub run build_runner build --delete-conflicting-outputs~ ``` +## 本地运行 + +项目使用 FVM Flutter 3.27.4。在 IDE 中选择启动模式即可,无需每次填写参数: + +| 模式 | VS Code 启动项 | Android Studio 启动项 | 文件请求 | +| --- | --- | --- | --- | +| 新版签名 | `pj_supermarket_cloud(新版签名)` | `main.dart`,Additional run args 配置下述签名参数 | 读取本地配置,图片和上传携带签名 | +| 旧版兼容测试 | `pj_supermarket_cloud(旧版兼容,无配置)` | `main.dart(旧版兼容,无配置)` | 不读取配置文件,图片和上传不携带应用签名 | + +直接运行以下命令也可启动。Debug 未传任何文件签名配置时,自动走旧版兼容测试模式: + +```bash +fvm flutter run +``` + +旧版模式会在控制台打印 `[file-access] 旧版兼容测试模式`,不发起文件校时和签名续期。 +即使同时提供签名配置,显式 `FILE_ACCESS_MODE=legacy` 也会保持旧版请求行为。 +该模式仅用于 Debug;Profile/Release 仍需有效签名配置。配置不完整或密钥错误会报错。 + +新版签名模式读取 `.file-access/config.json`。该文件已被 Git 忽略,所有环境共用现有的一套配置。 +换电脑或在 CI 构建时需要同步这份配置;准备方式见 +[文件访问接入说明](../pj_service_cloud/docs/file-access/README.md)。 + +```bash +fvm flutter run --dart-define-from-file=.file-access/config.json +``` + +VS Code 的新版 Debug、Profile、Release 启动配置已包含签名参数。 +Android Studio 在 `main.dart` 的 Run/Debug Configurations 中,将 Additional run args 设置为 +`--dart-define-from-file=.file-access/config.json`。 +这些参数在编译时生效,修改后需停止调试并重新运行。 + +## 旧版兼容验证 + +1. 选择“旧版兼容,无配置”,完全停止后重新启动,确认控制台显示旧版模式。 +2. 使用未缓存的新图片,检查实际请求只有原始文件参数,没有 `appId/kid/ts/sign`。 + 主屏、副屏、人脸图片下载均使用同一编译模式;已有图片缓存不代表服务器仍允许读取。 +3. 在已部署文件校验的后端测试:`mode: enforce` 且 `legacy-dart.enabled: true` 时, + 符合 `Dart/<版本> (dart:io)` UA 的裸图片请求可走兼容;关闭兼容后应返回 403。 + 后端 `observe` 模式仍会放行,不能用于确认拒绝行为。 +4. 旧 Dart 兼容只放行图片;PDF、裸 APK、上传不会因 Dart UA 放行。 + 服务端签发的 `package` 下载链接继续可用。切回“新版签名”后,验证图片和上传请求带签名。 + +此模式模拟旧版请求,旧 APK 在实际设备上的兼容性仍需用原安装包验证。 + ## flutter打包 ``` -flutter build apk --build-name 2.2.0 --build-number 220 +fvm flutter build apk --dart-define-from-file=.file-access/config.json --build-name 2.2.0 --build-number 220 --build-name为版本号(展示) --build-number为版本号(内部比较) ``` diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart index 8e64994..3a9bba5 100644 --- a/lib/common/utils/app/app_network_tool.dart +++ b/lib/common/utils/app/app_network_tool.dart @@ -143,7 +143,7 @@ abstract class AppNetworkTool { final response = await downloadClient.send(request).timeout(const Duration(seconds: 30)); if (response.statusCode == 200) return await response.stream.toBytes().timeout(const Duration(seconds: 30)); await response.stream.drain(); - if (response.statusCode != 403 || attempt > 0 || !FileAccess.client.isProtected(url)) return null; + if (response.statusCode != 403 || attempt > 0 || !FileAccess.signingEnabled || !FileAccess.client.isProtected(url)) return null; await FileAccess.refreshClock(force: true); } return null; diff --git a/lib/common/utils/app/file_access.dart b/lib/common/utils/app/file_access.dart index 88bec5e..6c13940 100644 --- a/lib/common/utils/app/file_access.dart +++ b/lib/common/utils/app/file_access.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart' as http; import '../../values/constants.dart'; @@ -13,6 +15,45 @@ const _filePaths = { '/api/admin/sys-file/oss/file' }; +enum FileAccessMode { signed, legacy } + +/// Missing Debug credentials can simulate old clients. Invalid credentials and +/// release/profile builds still require a valid signing configuration. +FileAccessMode resolveFileAccessMode({ + required String mode, + required bool debugBuild, + required bool hasConfiguration, + required String appId, + required String kid, + required String key, +}) { + if (!{'auto', 'signed', 'legacy'}.contains(mode)) { + throw StateError('Invalid FILE_ACCESS_MODE: $mode'); + } + if (mode == 'legacy') { + if (!debugBuild) { + throw StateError('Legacy file access testing requires a Debug build'); + } + return FileAccessMode.legacy; + } + if (mode == 'auto' && debugBuild && !hasConfiguration) { + return FileAccessMode.legacy; + } + var validKey = false; + try { + validKey = base64Decode(key).length >= 32; + } on FormatException { + // A broken configuration must not silently produce unsigned requests. + } + if (appId != 'flutter' || + !RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(kid) || + !validKey) { + throw StateError( + 'Build with --dart-define-from-file=.file-access/config.json'); + } + return FileAccessMode.signed; +} + String fileAccessCanonical(String file, String app, String kid, int ts) => [ 'v1', 'oss-file-read', @@ -32,6 +73,24 @@ String fileAccessSignature( .toString(); } +String fileUploadCanonical( + String target, String app, String kid, int ts, String nonce) { + if (!{'upload', 'inner-upload', 'qn-upload'}.contains(target)) { + throw ArgumentError('Invalid file upload target'); + } + return ['v1', 'oss-file-upload', 'POST', target, app, kid, '$ts', nonce] + .join('\n'); +} + +String fileUploadSignature( + String target, String app, String kid, int ts, String nonce, String key) { + final bytes = base64Decode(key); + if (bytes.length < 32) throw StateError('Invalid file upload key'); + return Hmac(sha256, bytes) + .convert(utf8.encode(fileUploadCanonical(target, app, kid, ts, nonce))) + .toString(); +} + /// Pure URL/signature logic. No GetX, database, device or main-engine dependency. class FileAccessClient { FileAccessClient( @@ -39,14 +98,17 @@ class FileAccessClient { required this.appId, required this.kid, required this.key, + this.signingEnabled = true, this.allowedOrigins = const [], DateTime Function()? now}) : now = now ?? DateTime.now; final String baseUrl, appId, kid, key; + final bool signingEnabled; final List allowedOrigins; final DateTime Function() now; int offsetMs = 0, ttlSeconds = 1800; final _cache = {}; + final _uploadRandom = Random.secure(); String resolve(String source) { final value = source.trim(); @@ -102,6 +164,66 @@ class FileAccessClient { } bool isProtected(String source) => _owned(source) != null; + + String? uploadTarget(String source) { + var uri = Uri.tryParse(resolve(source)); + if (uri != null && !uri.hasScheme) uri = Uri.parse(baseUrl).resolveUri(uri); + if (uri == null || + !{'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty) { + return null; + } + final origins = [ + Uri.parse(baseUrl).origin, + ...allowedOrigins + .where((e) => e.isNotEmpty) + .map((e) => Uri.parse(e).origin) + ]; + if (!origins.contains(uri.origin)) return null; + final prefix = Uri.parse(baseUrl).path.replaceFirst(RegExp(r'/+$'), ''); + final uploadPath = uri.path; + for (final entry in { + 'upload': 'upload', + 'inner-upload': 'inner-upload', + 'qn-upload': 'qn/upload' + }.entries) { + if ([ + '/sys-file/', + '/admin/sys-file/', + '/api/sys-file/', + '/api/admin/sys-file/', + '$prefix/admin/sys-file/', + '$prefix/sys-file/' + ].any((start) => uploadPath == '$start${entry.value}')) { + return entry.key; + } + } + return null; + } + + Map uploadHeaders(String source, {String? nonce}) { + if (!signingEnabled) return {}; + final target = uploadTarget(source); + if (target == null) return {}; + final issued = (now().millisecondsSinceEpoch + offsetMs) ~/ 1000; + final requestNonce = nonce ?? + List.generate( + 16, + (_) => _uploadRandom + .nextInt(256) + .toRadixString(16) + .padLeft(2, '0')).join(); + return { + 'X-File-App-Id': appId, + 'X-File-Key-Id': kid, + 'X-File-Timestamp': '$issued', + 'X-File-Nonce': requestNonce, + 'X-File-Signature': + fileUploadSignature(target, appId, kid, issued, requestNonce, key), + }; + } + String original(String source) { final uri = _owned(source); if (uri == null) return resolve(source); @@ -116,6 +238,7 @@ class FileAccessClient { } String url(String source, {bool force = false}) { + if (!signingEnabled) return original(source); final uri = _owned(source); if (uri == null) return resolve(source); final names = uri.queryParametersAll['fileName']; @@ -156,15 +279,33 @@ class FileAccessClient { _cache.clear(); } + bool synchronizeInfo(Map data, int roundTripMs) { + // The Java project's Long serializer returns numeric JSON strings. + final serverTime = int.tryParse('${data['serverTime']}'); + final ttl = int.tryParse('${data['ttlSeconds']}'); + if (serverTime == null || serverTime <= 0) return false; + synchronize(serverTime, ttl ?? ttlSeconds, roundTripMs); + return true; + } + void clear() => _cache.clear(); } abstract class FileAccess { + static final mode = resolveFileAccessMode( + mode: Constants.fileAccessMode, + debugBuild: kDebugMode, + hasConfiguration: Constants.hasFileAccessConfiguration, + appId: Constants.fileAccessAppId, + kid: Constants.fileAccessKeyId, + key: Constants.fileAccessKey); + static bool get signingEnabled => mode == FileAccessMode.signed; static final client = FileAccessClient( baseUrl: Constants.ossUrl, appId: Constants.fileAccessAppId, kid: Constants.fileAccessKeyId, key: Constants.fileAccessKey, + signingEnabled: signingEnabled, allowedOrigins: Constants.fileAccessAllowedOrigins.split(',')); static final revision = ValueNotifier(0); static Timer? _timer; @@ -177,11 +318,10 @@ abstract class FileAccess { static void initialize() { if (_initialized) return; - if (Constants.fileAccessAppId != 'flutter' || - !RegExp(r'^[a-zA-Z0-9_-]{1,32}$').hasMatch(Constants.fileAccessKeyId) || - base64Decode(Constants.fileAccessKey).length < 32) { - throw StateError( - 'Build with --dart-define-from-file=.file-access/config.json'); + if (!signingEnabled) { + _initialized = true; + debugPrint('[file-access] 旧版兼容测试模式:图片和上传不携带应用签名'); + return; } _initialized = true; WidgetsBinding.instance.addObserver(_lifecycle); @@ -195,6 +335,7 @@ abstract class FileAccess { } static void resume() { + if (!signingEnabled) return; _timer ??= Timer.periodic(const Duration(minutes: 1), (_) => revision.value++); unawaited(refreshClock()); @@ -206,6 +347,7 @@ abstract class FileAccess { } static Future refreshClock({bool force = false}) { + if (!signingEnabled) return Future.value(); if (_pending != null) return _pending!; if (!force && _lastSync != null && @@ -227,11 +369,9 @@ abstract class FileAccess { .timeout(const Duration(seconds: 5)); if (response.statusCode != 200) return; final data = jsonDecode(response.body)['data']; - if (data is Map && data['serverTime'] is num) { - client.synchronize( - (data['serverTime'] as num).toInt(), - (data['ttlSeconds'] as num?)?.toInt() ?? 1800, - DateTime.now().millisecondsSinceEpoch - started); + if (data is Map && + client.synchronizeInfo( + data, DateTime.now().millisecondsSinceEpoch - started)) { _lastSync = DateTime.now(); } } catch (_) { @@ -242,7 +382,7 @@ abstract class FileAccess { } static Future retryImage(String source) async { - if (!client.isProtected(source)) return false; + if (!signingEnabled || !client.isProtected(source)) return false; final raw = client.original(source), previous = _retries[client.original(source)]; if (previous != null && diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 58912ea..bd5049a 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -9,6 +9,7 @@ import 'package:get/get.dart' hide FormData; import 'package:pj_supermarket_cloud/common/index.dart'; import 'package:pj_supermarket_cloud/common/utils/order/payment_timing_trace.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; /// 无需向操作员重复提示的服务端业务消息。 const ignoreMsg = ["账号未登录"]; @@ -71,6 +72,8 @@ class HttpManage { 'imagehash', 'localfilepath', 'evidencefilepath', + 'xfilesignature', + 'xfilenonce', }; /// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。 @@ -99,6 +102,9 @@ class HttpManage { dio.interceptors.add( InterceptorsWrapper( onRequest: (RequestOptions options, handler) async { + if (options.method.toUpperCase() == 'POST') { + options.headers.addAll(FileAccess.client.uploadHeaders(options.uri.toString())); + } options.extra[_requestStartAtKey] = DateTime.now(); _startPaymentTiming(options); options.headers["CLIENT-TOC"] = "Y"; diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index e7d5b54..54b930a 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -9,6 +9,13 @@ enum LoginVersion { } class Constants { + /// Debug 未配置签名时自动模拟旧版;signed 强制签名,legacy 显式测试旧版。 + static const fileAccessMode = String.fromEnvironment('FILE_ACCESS_MODE', defaultValue: 'auto'); + static const hasFileAccessConfiguration = + bool.hasEnvironment('FILE_ACCESS_APP_ID') || + bool.hasEnvironment('FILE_ACCESS_KEY_ID') || + bool.hasEnvironment('FILE_ACCESS_KEY') || + bool.hasEnvironment('FILE_ACCESS_ALLOWED_ORIGINS'); static const fileAccessAppId = String.fromEnvironment('FILE_ACCESS_APP_ID', defaultValue: 'flutter'); static const fileAccessKeyId = String.fromEnvironment('FILE_ACCESS_KEY_ID', defaultValue: 'v1'); static const fileAccessKey = String.fromEnvironment('FILE_ACCESS_KEY'); diff --git a/test-vectors/file-upload.json b/test-vectors/file-upload.json new file mode 100644 index 0000000..bcab051 --- /dev/null +++ b/test-vectors/file-upload.json @@ -0,0 +1,32 @@ +[ + { + "target": "upload", + "appId": "admin", + "kid": "v1", + "ts": 1793232000, + "nonce": "00112233445566778899aabbccddeeff", + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-upload\nPOST\nupload\nadmin\nv1\n1793232000\n00112233445566778899aabbccddeeff", + "sign": "75fde91ab548250139f260680aeb2e51f831d29d8e4fb3cfb28898528fd3a2a8" + }, + { + "target": "qn-upload", + "appId": "h5", + "kid": "v1", + "ts": 1793232000, + "nonce": "ffeeddccbbaa99887766554433221100", + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-upload\nPOST\nqn-upload\nh5\nv1\n1793232000\nffeeddccbbaa99887766554433221100", + "sign": "01cef848210b1bc31f5fe7679428b36d8e0f15b9f5517472a023d4d2c008086c" + }, + { + "target": "inner-upload", + "appId": "service", + "kid": "v1", + "ts": 1793232000, + "nonce": "0123456789abcdef0123456789abcdef", + "key": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", + "canonical": "v1\noss-file-upload\nPOST\ninner-upload\nservice\nv1\n1793232000\n0123456789abcdef0123456789abcdef", + "sign": "0d49bb992e5138cdb7a3f2bd7c83dfd60aedeae2b205898c4a6211c794f35aae" + } +] diff --git a/test/file_access_mode_test.dart b/test/file_access_mode_test.dart new file mode 100644 index 0000000..7bb8fa5 --- /dev/null +++ b/test/file_access_mode_test.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/app_network_tool.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; +import 'package:pj_supermarket_cloud/common/values/constants.dart'; + +void main() { + final testKey = base64Encode(List.generate(32, (index) => index)); + + FileAccessMode select({ + String mode = 'auto', + bool debug = true, + bool configured = false, + String appId = 'flutter', + String kid = 'v1', + String key = '', + }) => + resolveFileAccessMode( + mode: mode, + debugBuild: debug, + hasConfiguration: configured, + appId: appId, + kid: kid, + key: key, + ); + + test('an ordinary Debug launch without configuration simulates old clients', + () { + expect(select(), FileAccessMode.legacy); + }); + + test('valid configuration selects signing in Debug and production builds', + () { + for (final debug in [true, false]) { + for (final mode in ['auto', 'signed']) { + expect( + select(mode: mode, debug: debug, configured: true, key: testKey), + FileAccessMode.signed, + ); + } + } + }); + + test('partial or invalid configuration never silently disables signing', () { + for (final key in [ + '', + 'not-base64!', + base64Encode([1, 2, 3]) + ]) { + expect(() => select(configured: true, key: key), throwsStateError); + } + expect( + () => select(configured: true, appId: 'admin', key: testKey), + throwsStateError, + ); + expect( + () => select(configured: true, kid: 'invalid key id', key: testKey), + throwsStateError, + ); + }); + + test('production and explicit signed launches reject missing credentials', + () { + expect(() => select(debug: false), throwsStateError); + expect(() => select(mode: 'signed'), throwsStateError); + }); + + test( + 'explicit legacy testing stays unsigned even when credentials are present', + () { + expect( + select(mode: 'legacy', configured: true, key: testKey), + FileAccessMode.legacy, + ); + expect(() => select(mode: 'legacy', debug: false), throwsStateError); + expect(() => select(mode: 'typo'), throwsStateError); + }); + + test( + 'legacy image requests omit all signature fields and preserve file names', + () { + final client = FileAccessClient( + baseUrl: 'https://files.example/api', + appId: 'flutter', + kid: 'v1', + key: '', + signingEnabled: false, + ); + const source = '/admin/sys-file/oss/file?appId=flutter&kid=v1&ts=1&sign=old' + '&width=20&fileName=literal%252Fname.png'; + final url = Uri.parse(client.url(source)); + expect(url.path, '/api/admin/sys-file/oss/file'); + expect( + url.queryParameters, {'width': '20', 'fileName': 'literal%2Fname.png'}); + expect(client.url(source, force: true), url.toString()); + expect(client.url('photo.jpg'), + 'https://files.example/api/admin/sys-file/oss/file?fileName=photo.jpg'); + const external = 'https://other.example/image.png?sign=third-party'; + expect(client.url(external), external); + expect(client.url('assets/logo.png'), 'assets/logo.png'); + }); + + test('legacy uploads carry no app signature headers and need no key', () { + final client = FileAccessClient( + baseUrl: 'https://files.example/api', + appId: 'flutter', + kid: 'v1', + key: '', + signingEnabled: false, + ); + for (final target in ['upload', 'inner-upload', 'qn/upload']) { + expect(client.uploadHeaders('/admin/sys-file/$target'), isEmpty); + } + }); + + test('the compiled launch configuration controls image and upload signing', + () { + final expectedSigning = Constants.fileAccessMode != 'legacy' && + Constants.hasFileAccessConfiguration; + expect(FileAccess.signingEnabled, expectedSigning); + final uri = Uri.parse(FileAccess.client.url('photo.jpg')); + expect(uri.queryParameters.containsKey('sign'), expectedSigning); + expect( + FileAccess.client.uploadHeaders('/admin/sys-file/upload').isNotEmpty, + expectedSigning, + ); + // Old clients can still consume package links signed by the server. + const package = 'https://files.example/admin/sys-file/oss/file' + '?appId=package&kid=v1&ts=1&sign=server&fileName=app.apk'; + expect(FileAccess.packageUrl(package), package); + }); + + test( + 'legacy image downloads send bare requests and do not retry a rejected signature', + () async { + final requests = []; + final client = MockClient((request) async { + requests.add(request); + return http.Response('', 403); + }); + addTearDown(client.close); + expect(await AppNetworkTool.downloadImage('photo.jpg', client: client), + isNull); + expect(requests, hasLength(1)); + expect(requests.single.url.queryParameters, {'fileName': 'photo.jpg'}); + }, skip: FileAccess.signingEnabled); + + testWidgets( + 'unsigned startup avoids platform initialization and renewal timers', + (tester) async { + FileAccess.initialize(); + FileAccess.initialize(); + final before = FileAccess.revision.value; + FileAccess.resume(); + await FileAccess.refreshClock(force: true); + expect(await FileAccess.retryImage('photo.jpg'), isFalse); + await tester.pump(const Duration(minutes: 2)); + expect(FileAccess.revision.value, before); + FileAccess.pause(); + }, skip: FileAccess.signingEnabled); +} diff --git a/test/file_upload_test.dart b/test/file_upload_test.dart new file mode 100644 index 0000000..cd978ea --- /dev/null +++ b/test/file_upload_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pj_supermarket_cloud/common/utils/app/file_access.dart'; + +void main() { + final vectors = + jsonDecode(File('test-vectors/file-upload.json').readAsStringSync()) + as List; + for (final v in vectors) { + test('shared upload signature: ${v['target']}', () { + expect( + fileUploadCanonical( + v['target'], v['appId'], v['kid'], v['ts'], v['nonce']), + v['canonical']); + expect( + fileUploadSignature( + v['target'], v['appId'], v['kid'], v['ts'], v['nonce'], v['key']), + v['sign']); + }); + } + test( + 'upload headers are fresh, target-bound and restricted to owned endpoints', + () { + final client = FileAccessClient( + baseUrl: 'https://files.example/api', + appId: 'flutter', + kid: 'v1', + key: vectors[0]['key'], + now: () => DateTime.fromMillisecondsSinceEpoch(1793232000000), + allowedOrigins: ['https://legacy.example']); + for (final entry in { + '/admin/sys-file/upload': 'upload', + '/sys-file/upload': 'upload', + '/api/admin/sys-file/qn/upload': 'qn-upload', + 'https://legacy.example/admin/sys-file/inner-upload': 'inner-upload' + }.entries) { + final headers = + client.uploadHeaders(entry.key, nonce: vectors[0]['nonce']); + expect( + headers['X-File-Signature'], + fileUploadSignature(entry.value, 'flutter', 'v1', 1793232000, + vectors[0]['nonce'], vectors[0]['key'])); + } + final first = client.uploadHeaders('/admin/sys-file/upload'), + second = client.uploadHeaders('/admin/sys-file/upload'); + expect(first['X-File-Nonce'], isNot(second['X-File-Nonce'])); + expect(first['X-File-Signature'], isNot(second['X-File-Signature'])); + for (final source in [ + 'https://other.example/admin/sys-file/upload', + 'https:///admin/sys-file/upload', + '/admin/user/import', + '/admin/sys-file/oss/file?fileName=x.jpg', + 'file:///local.png' + ]) { + expect(client.uploadHeaders(source), isEmpty); + } + }); + test( + 'the Java numeric-string clock response corrects upload and image signatures', + () { + final client = FileAccessClient( + baseUrl: 'https://files.example/api', + appId: 'flutter', + kid: 'v1', + key: vectors[0]['key'], + now: () => DateTime.fromMillisecondsSinceEpoch(1793318400000)); + expect( + client.synchronizeInfo( + {'serverTime': '1793232000000', 'ttlSeconds': '1800'}, 0), + isTrue); + expect(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'], + '1793232000'); + expect( + Uri.parse(client.url('photo.jpg')).queryParameters['ts'], '1793232000'); + expect(client.synchronizeInfo({'serverTime': 'not-a-time'}, 0), isFalse); + expect(client.uploadHeaders('/admin/sys-file/upload')['X-File-Timestamp'], + '1793232000'); + }); +}