file upload actor
This commit is contained in:
parent
fca846eb43
commit
f55083ea1b
6 changed files with 682 additions and 0 deletions
174
lib/src/file_upload_actor.dart
Normal file
174
lib/src/file_upload_actor.dart
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:actors/actors.dart';
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'nip44.dart';
|
||||
import 'relay_connection.dart';
|
||||
|
||||
const _kServicePubkey =
|
||||
'7de7d9f8c0aad59b0e325eef5e1c7d777a3b4c1b3df8ac71c72dae763a161435';
|
||||
const _kDefaultApiBaseUrl = 'https://upload.otherwhere.app';
|
||||
const _kDefaultRelayUrl = 'wss://relay.otherwhere.app/';
|
||||
const _kMaxFileBytes = 10 * 1024 * 1024; // 10 MB server-side limit
|
||||
|
||||
sealed class FileUploadMessage {
|
||||
const FileUploadMessage();
|
||||
}
|
||||
|
||||
final class RegisterForUpload extends FileUploadMessage {
|
||||
const RegisterForUpload();
|
||||
}
|
||||
|
||||
final class UploadFile extends FileUploadMessage {
|
||||
final Uint8List bytes;
|
||||
final String contentType;
|
||||
const UploadFile({required this.bytes, required this.contentType});
|
||||
}
|
||||
|
||||
sealed class FileUploadResult {
|
||||
const FileUploadResult();
|
||||
}
|
||||
|
||||
final class UploaderRegistered extends FileUploadResult {
|
||||
const UploaderRegistered();
|
||||
}
|
||||
|
||||
final class FileUploaded extends FileUploadResult {
|
||||
final String cdnUrl;
|
||||
const FileUploaded(this.cdnUrl);
|
||||
}
|
||||
|
||||
class FileUploadActor with Handler<FileUploadMessage, FileUploadResult> {
|
||||
final String _privateKey;
|
||||
final String _publicKey;
|
||||
final String _appName;
|
||||
final String _appSecret;
|
||||
final String _apiBaseUrl;
|
||||
final String _relayUrl;
|
||||
final http.Client _httpClient;
|
||||
final RelayConnection Function(String) _connectionFactory;
|
||||
|
||||
FileUploadActor({
|
||||
required String privateKey,
|
||||
required String appName,
|
||||
required String appSecret,
|
||||
String apiBaseUrl = _kDefaultApiBaseUrl,
|
||||
String relayUrl = _kDefaultRelayUrl,
|
||||
http.Client? httpClient,
|
||||
RelayConnection Function(String)? connectionFactory,
|
||||
}) : _privateKey = privateKey,
|
||||
_publicKey = bip340.getPublicKey(privateKey),
|
||||
_appName = appName,
|
||||
_appSecret = appSecret,
|
||||
_apiBaseUrl = apiBaseUrl,
|
||||
_relayUrl = relayUrl,
|
||||
_httpClient = httpClient ?? http.Client(),
|
||||
_connectionFactory = connectionFactory ?? WebSocketRelayConnection.new;
|
||||
|
||||
@override
|
||||
Future<FileUploadResult> handle(FileUploadMessage message) => switch (message) {
|
||||
RegisterForUpload() => _register(),
|
||||
UploadFile(:final bytes, :final contentType) =>
|
||||
_upload(bytes, contentType),
|
||||
};
|
||||
|
||||
@override
|
||||
Future<void> close() async => _httpClient.close();
|
||||
|
||||
Future<UploaderRegistered> _register() async {
|
||||
final encryptedSecret =
|
||||
nip44Encrypt(_appSecret, _privateKey, _kServicePubkey);
|
||||
final eventJson = _signEvent(
|
||||
kind: 5392,
|
||||
tags: [
|
||||
['p', _kServicePubkey],
|
||||
['app', _appName],
|
||||
],
|
||||
content: encryptedSecret,
|
||||
);
|
||||
final conn = _connectionFactory(_relayUrl);
|
||||
try {
|
||||
await conn.connect();
|
||||
await conn.send(jsonEncode(['EVENT', jsonDecode(eventJson)]));
|
||||
} finally {
|
||||
await conn.close();
|
||||
}
|
||||
return const UploaderRegistered();
|
||||
}
|
||||
|
||||
Future<FileUploaded> _upload(Uint8List bytes, String contentType) async {
|
||||
if (bytes.length > _kMaxFileBytes) {
|
||||
throw Exception(
|
||||
'File exceeds 10 MB limit (${bytes.length} bytes)',
|
||||
);
|
||||
}
|
||||
final (uploadUrl, cdnUrl) = await _requestUploadUrl(contentType);
|
||||
final putResponse = await _httpClient.put(
|
||||
Uri.parse(uploadUrl),
|
||||
headers: {'Content-Type': contentType},
|
||||
body: bytes,
|
||||
);
|
||||
if (putResponse.statusCode != 200 && putResponse.statusCode != 204) {
|
||||
throw Exception('Storage PUT failed: ${putResponse.statusCode}');
|
||||
}
|
||||
return FileUploaded(cdnUrl);
|
||||
}
|
||||
|
||||
Future<(String, String)> _requestUploadUrl(String contentType) async {
|
||||
final url = '$_apiBaseUrl/upload/request';
|
||||
final authEvent = _signEvent(
|
||||
kind: 27235,
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', 'POST'],
|
||||
],
|
||||
content: '',
|
||||
);
|
||||
// NIP-98: Authorization: Nostr <base64(utf8(json(event)))>
|
||||
final token = base64.encode(utf8.encode(authEvent));
|
||||
final response = await _httpClient.post(
|
||||
Uri.parse(url),
|
||||
headers: {
|
||||
'Authorization': 'Nostr $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: jsonEncode({'app': _appName, 'content_type': contentType}),
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Upload request failed: ${response.statusCode}');
|
||||
}
|
||||
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return (body['upload_url'] as String, body['cdn_url'] as String);
|
||||
}
|
||||
|
||||
String _signEvent({
|
||||
required int kind,
|
||||
required List<List<String>> tags,
|
||||
required String content,
|
||||
}) {
|
||||
final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
final ser = jsonEncode([0, _publicKey, ts, kind, tags, content]);
|
||||
final idBytes = sha256.convert(utf8.encode(ser)).bytes;
|
||||
final id =
|
||||
idBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
final rng = Random.secure();
|
||||
final aux = List.generate(32, (_) => rng.nextInt(256))
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final sig = bip340.sign(_privateKey, id, aux);
|
||||
return jsonEncode({
|
||||
'id': id,
|
||||
'pubkey': _publicKey,
|
||||
'created_at': ts,
|
||||
'kind': kind,
|
||||
'tags': tags,
|
||||
'content': content,
|
||||
'sig': sig,
|
||||
});
|
||||
}
|
||||
}
|
||||
131
lib/src/nip44.dart
Normal file
131
lib/src/nip44.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:pointycastle/export.dart';
|
||||
|
||||
/// Derives the NIP-44 v2 conversation key.
|
||||
/// secp256k1 ECDH(privkey, pubkey) → HKDF-SHA256(ikm=sharedX, salt=32·0x00, info="nip44-v2") → 32 bytes.
|
||||
Uint8List nip44ConversationKey(String privkeyHex, String pubkeyHex) {
|
||||
final domain = ECCurve_secp256k1();
|
||||
final priv = BigInt.parse(privkeyHex, radix: 16);
|
||||
// Nostr pubkeys are x-only; always reconstruct with even-y (0x02 prefix).
|
||||
final pubBytes = Uint8List.fromList([0x02, ...hexToBytes(pubkeyHex)]);
|
||||
final pub = domain.curve.decodePoint(pubBytes)!;
|
||||
final sharedX = (pub * priv)!
|
||||
.x!
|
||||
.toBigInteger()!
|
||||
.toRadixString(16)
|
||||
.padLeft(64, '0');
|
||||
return hkdf(hexToBytes(sharedX), Uint8List(32), utf8.encode('nip44-v2'), 32);
|
||||
}
|
||||
|
||||
/// Encrypts [plaintext] with NIP-44 v2. Returns a base64 payload.
|
||||
String nip44Encrypt(String plaintext, String privkeyHex, String pubkeyHex) {
|
||||
final convKey = nip44ConversationKey(privkeyHex, pubkeyHex);
|
||||
|
||||
final nonce = Uint8List(32);
|
||||
final rng = Random.secure();
|
||||
for (var i = 0; i < 32; i++) nonce[i] = rng.nextInt(256);
|
||||
|
||||
final keys = hkdf(convKey, nonce, [], 76);
|
||||
final chachaKey = keys.sublist(0, 32);
|
||||
final chachaNonce = keys.sublist(32, 44); // 12 bytes — RFC 7539
|
||||
final hmacKey = keys.sublist(44, 76);
|
||||
|
||||
final padded = _pad(utf8.encode(plaintext));
|
||||
|
||||
final chacha = ChaCha7539Engine()
|
||||
..init(true, ParametersWithIV(KeyParameter(chachaKey), chachaNonce));
|
||||
final ciphertext = Uint8List(padded.length);
|
||||
chacha.processBytes(padded, 0, padded.length, ciphertext, 0);
|
||||
|
||||
final mac =
|
||||
Hmac(sha256, hmacKey).convert([...nonce, ...ciphertext]).bytes;
|
||||
|
||||
return base64.encode(
|
||||
Uint8List.fromList([0x02, ...nonce, ...ciphertext, ...mac]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Decrypts a NIP-44 v2 base64 [payload]. Throws on HMAC failure or unknown version.
|
||||
String nip44Decrypt(String payload, String privkeyHex, String pubkeyHex) {
|
||||
final bytes = base64.decode(payload);
|
||||
if (bytes[0] != 0x02) {
|
||||
throw Exception('Unsupported NIP-44 version: ${bytes[0]}');
|
||||
}
|
||||
final nonce = bytes.sublist(1, 33);
|
||||
final mac = bytes.sublist(bytes.length - 32);
|
||||
final ciphertext = bytes.sublist(33, bytes.length - 32);
|
||||
|
||||
final convKey = nip44ConversationKey(privkeyHex, pubkeyHex);
|
||||
final keys = hkdf(convKey, nonce, [], 76);
|
||||
final chachaKey = keys.sublist(0, 32);
|
||||
final chachaNonce = keys.sublist(32, 44);
|
||||
final hmacKey = keys.sublist(44, 76);
|
||||
|
||||
final expectedMac =
|
||||
Hmac(sha256, hmacKey).convert([...nonce, ...ciphertext]).bytes;
|
||||
if (!_constantTimeEqual(mac, expectedMac)) {
|
||||
throw Exception('NIP-44 HMAC verification failed');
|
||||
}
|
||||
|
||||
final chacha = ChaCha7539Engine()
|
||||
..init(true, ParametersWithIV(KeyParameter(chachaKey), chachaNonce));
|
||||
final plainPadded = Uint8List(ciphertext.length);
|
||||
chacha.processBytes(ciphertext, 0, ciphertext.length, plainPadded, 0);
|
||||
|
||||
return utf8.decode(_unpad(plainPadded));
|
||||
}
|
||||
|
||||
/// Minimal HKDF-SHA256: extract then expand.
|
||||
Uint8List hkdf(Uint8List ikm, List<int> salt, List<int> info, int length) {
|
||||
final prk = Hmac(sha256, salt).convert(ikm).bytes;
|
||||
final output = <int>[];
|
||||
var t = <int>[];
|
||||
var counter = 1;
|
||||
while (output.length < length) {
|
||||
t = Hmac(sha256, prk).convert([...t, ...info, counter]).bytes;
|
||||
output.addAll(t);
|
||||
counter++;
|
||||
}
|
||||
return Uint8List.fromList(output.sublist(0, length));
|
||||
}
|
||||
|
||||
/// Decodes a hex string to bytes.
|
||||
Uint8List hexToBytes(String hex) {
|
||||
final result = Uint8List(hex.length ~/ 2);
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
int _calcPaddedLen(int n) {
|
||||
if (n <= 32) return 32;
|
||||
final nextPow = 1 << (n - 1).toRadixString(2).length;
|
||||
final chunk = nextPow <= 256 ? 32 : nextPow ~/ 8;
|
||||
return chunk * ((n + chunk - 1) ~/ chunk);
|
||||
}
|
||||
|
||||
Uint8List _pad(List<int> plaintext) {
|
||||
final n = plaintext.length;
|
||||
final padded = Uint8List(_calcPaddedLen(n) + 2);
|
||||
padded[0] = (n >> 8) & 0xff;
|
||||
padded[1] = n & 0xff;
|
||||
padded.setRange(2, 2 + n, plaintext);
|
||||
return padded;
|
||||
}
|
||||
|
||||
Uint8List _unpad(Uint8List padded) {
|
||||
final n = (padded[0] << 8) | padded[1];
|
||||
return padded.sublist(2, 2 + n);
|
||||
}
|
||||
|
||||
bool _constantTimeEqual(List<int> a, List<int> b) {
|
||||
if (a.length != b.length) return false;
|
||||
var result = 0;
|
||||
for (var i = 0; i < a.length; i++) result |= a[i] ^ b[i];
|
||||
return result == 0;
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export 'src/nostr_publish_actor.dart';
|
|||
export 'src/nostr_fetch_actor.dart';
|
||||
export 'src/nostr_signer_actor.dart';
|
||||
export 'src/geo_actor.dart';
|
||||
export 'src/file_upload_actor.dart';
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ dependencies:
|
|||
bip340: ^0.3.1
|
||||
crypto: ^3.0.7
|
||||
dart_geohash: ^2.1.0
|
||||
http: ^1.6.0
|
||||
pointycastle: ^4.0.0
|
||||
retry: ^3.1.2
|
||||
web_socket_channel: ^3.0.3
|
||||
|
||||
|
|
|
|||
323
test/file_upload_actor_test.dart
Normal file
323
test/file_upload_actor_test.dart
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bip340/bip340.dart' as bip340;
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:swarm/src/nip44.dart';
|
||||
import 'package:swarm/swarm.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'fake_relay_connection.dart';
|
||||
|
||||
// Two well-known secp256k1 key pairs (k=1 and k=2).
|
||||
const _sec1 =
|
||||
'0000000000000000000000000000000000000000000000000000000000000001';
|
||||
const _sec2 =
|
||||
'0000000000000000000000000000000000000000000000000000000000000002';
|
||||
|
||||
const _kServicePubkey =
|
||||
'7de7d9f8c0aad59b0e325eef5e1c7d777a3b4c1b3df8ac71c72dae763a161435';
|
||||
const _kAppName = 'test-app';
|
||||
const _kAppSecret = 'test-secret-1234';
|
||||
const _kUploadUrl = 'https://storage.example.com/presigned/object';
|
||||
const _kCdnUrl = 'https://cdn.otherwhere.app/test/pubkey/uuid.jpg';
|
||||
|
||||
void main() {
|
||||
final pub1 = bip340.getPublicKey(_sec1);
|
||||
final pub2 = bip340.getPublicKey(_sec2);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('NIP-44', () {
|
||||
test('conversation key is symmetric', () {
|
||||
final ck1 = nip44ConversationKey(_sec1, pub2);
|
||||
final ck2 = nip44ConversationKey(_sec2, pub1);
|
||||
expect(ck1, equals(ck2));
|
||||
});
|
||||
|
||||
test('encrypt/decrypt round-trip (sec1 → pub2)', () {
|
||||
const plaintext = 'hello upload service';
|
||||
final payload = nip44Encrypt(plaintext, _sec1, pub2);
|
||||
expect(nip44Decrypt(payload, _sec2, pub1), equals(plaintext));
|
||||
});
|
||||
|
||||
test('encrypt/decrypt round-trip (sec2 → pub1)', () {
|
||||
const plaintext = 'symmetric direction test';
|
||||
final payload = nip44Encrypt(plaintext, _sec2, pub1);
|
||||
expect(nip44Decrypt(payload, _sec1, pub2), equals(plaintext));
|
||||
});
|
||||
|
||||
test('tampered MAC throws', () {
|
||||
final payload = nip44Encrypt('tamper me', _sec1, pub2);
|
||||
final bytes = base64.decode(payload);
|
||||
bytes[bytes.length - 1] ^= 0xff;
|
||||
expect(
|
||||
() => nip44Decrypt(base64.encode(bytes), _sec2, pub1),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
|
||||
test('unknown version byte throws', () {
|
||||
final payload = nip44Encrypt('v check', _sec1, pub2);
|
||||
final bytes = base64.decode(payload);
|
||||
bytes[0] = 0x01;
|
||||
expect(
|
||||
() => nip44Decrypt(base64.encode(bytes), _sec2, pub1),
|
||||
throwsException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('FileUploadActor — registration', () {
|
||||
late FakeRelayConnection fakeRelay;
|
||||
late FileUploadActor actor;
|
||||
|
||||
setUp(() {
|
||||
fakeRelay = FakeRelayConnection()..autoRespond = false;
|
||||
actor = FileUploadActor(
|
||||
privateKey: _sec1,
|
||||
appName: _kAppName,
|
||||
appSecret: _kAppSecret,
|
||||
relayUrl: 'ws://test-relay',
|
||||
connectionFactory: (_) => fakeRelay,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() => fakeRelay.dispose());
|
||||
|
||||
test('returns UploaderRegistered', () async {
|
||||
expect(
|
||||
await actor.handle(const RegisterForUpload()),
|
||||
isA<UploaderRegistered>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('sends one EVENT to the relay', () async {
|
||||
await actor.handle(const RegisterForUpload());
|
||||
final eventMsgs = fakeRelay.sentMessages
|
||||
.where((m) => (jsonDecode(m) as List)[0] == 'EVENT')
|
||||
.toList();
|
||||
expect(eventMsgs, hasLength(1));
|
||||
});
|
||||
|
||||
test('event kind is 5392', () async {
|
||||
await actor.handle(const RegisterForUpload());
|
||||
final event = _extractEvent(fakeRelay);
|
||||
expect(event['kind'], equals(5392));
|
||||
});
|
||||
|
||||
test('event has correct p and app tags', () async {
|
||||
await actor.handle(const RegisterForUpload());
|
||||
final tags = _tagsMap(_extractEvent(fakeRelay));
|
||||
expect(tags['p'], equals(_kServicePubkey));
|
||||
expect(tags['app'], equals(_kAppName));
|
||||
});
|
||||
|
||||
test('event signature verifies', () async {
|
||||
await actor.handle(const RegisterForUpload());
|
||||
final event = _extractEvent(fakeRelay);
|
||||
expect(
|
||||
bip340.verify(
|
||||
event['pubkey'] as String,
|
||||
event['id'] as String,
|
||||
event['sig'] as String,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('content decrypts to app secret', () async {
|
||||
await actor.handle(const RegisterForUpload());
|
||||
final content = _extractEvent(fakeRelay)['content'] as String;
|
||||
// Decrypt using ECDH(userPriv, servicePub) — same as server-side
|
||||
// ECDH(servicePriv, userPub) by symmetry.
|
||||
expect(
|
||||
nip44Decrypt(content, _sec1, _kServicePubkey),
|
||||
equals(_kAppSecret),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
group('FileUploadActor — upload', () {
|
||||
FileUploadActor makeActor(http.Client client) => FileUploadActor(
|
||||
privateKey: _sec1,
|
||||
appName: _kAppName,
|
||||
appSecret: _kAppSecret,
|
||||
apiBaseUrl: 'https://api.test',
|
||||
httpClient: client,
|
||||
);
|
||||
|
||||
MockClient makeClient({
|
||||
int requestStatus = 200,
|
||||
int putStatus = 200,
|
||||
}) =>
|
||||
MockClient((request) async {
|
||||
if (request.url.path == '/upload/request') {
|
||||
if (requestStatus != 200) {
|
||||
return http.Response('error', requestStatus);
|
||||
}
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'upload_url': _kUploadUrl,
|
||||
'cdn_url': _kCdnUrl,
|
||||
}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
if (request.url.toString() == _kUploadUrl) {
|
||||
return http.Response('', putStatus);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
|
||||
test('returns FileUploaded with CDN url', () async {
|
||||
final result = await makeActor(makeClient()).handle(
|
||||
UploadFile(bytes: Uint8List.fromList([1, 2, 3]), contentType: 'image/jpeg'),
|
||||
);
|
||||
expect(result, isA<FileUploaded>());
|
||||
expect((result as FileUploaded).cdnUrl, equals(_kCdnUrl));
|
||||
});
|
||||
|
||||
test('POST body contains app and content_type', () async {
|
||||
http.Request? captured;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.path == '/upload/request') {
|
||||
captured = request;
|
||||
return http.Response(
|
||||
jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('', 200);
|
||||
});
|
||||
await makeActor(client).handle(
|
||||
UploadFile(bytes: Uint8List(1), contentType: 'image/png'),
|
||||
);
|
||||
final body = jsonDecode(captured!.body) as Map<String, dynamic>;
|
||||
expect(body['app'], equals(_kAppName));
|
||||
expect(body['content_type'], equals('image/png'));
|
||||
});
|
||||
|
||||
test('NIP-98 Authorization token is valid signed kind-27235 event', () async {
|
||||
http.Request? captured;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.path == '/upload/request') {
|
||||
captured = request;
|
||||
return http.Response(
|
||||
jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
return http.Response('', 200);
|
||||
});
|
||||
await makeActor(client).handle(
|
||||
UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'),
|
||||
);
|
||||
|
||||
final authHeader = captured!.headers['Authorization']!;
|
||||
expect(authHeader, startsWith('Nostr '));
|
||||
final event = jsonDecode(
|
||||
utf8.decode(base64.decode(authHeader.substring(6))),
|
||||
) as Map<String, dynamic>;
|
||||
|
||||
expect(event['kind'], equals(27235));
|
||||
expect(event['content'], equals(''));
|
||||
final tags = _tagsMap(event);
|
||||
expect(tags['u'], equals('https://api.test/upload/request'));
|
||||
expect(tags['method'], equals('POST'));
|
||||
expect(
|
||||
bip340.verify(
|
||||
event['pubkey'] as String,
|
||||
event['id'] as String,
|
||||
event['sig'] as String,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('PUT uses presigned URL with correct headers and body', () async {
|
||||
http.Request? putRequest;
|
||||
final client = MockClient((request) async {
|
||||
if (request.url.path == '/upload/request') {
|
||||
return http.Response(
|
||||
jsonEncode({'upload_url': _kUploadUrl, 'cdn_url': _kCdnUrl}),
|
||||
200,
|
||||
);
|
||||
}
|
||||
putRequest = request;
|
||||
return http.Response('', 200);
|
||||
});
|
||||
final bytes = Uint8List.fromList([0xff, 0xd8, 0xff, 0xe0]);
|
||||
await makeActor(client).handle(
|
||||
UploadFile(bytes: bytes, contentType: 'image/jpeg'),
|
||||
);
|
||||
expect(putRequest!.method, equals('PUT'));
|
||||
expect(putRequest!.url.toString(), equals(_kUploadUrl));
|
||||
expect(putRequest!.headers['Content-Type'], equals('image/jpeg'));
|
||||
expect(putRequest!.bodyBytes, equals(bytes));
|
||||
});
|
||||
|
||||
test('PUT 204 is treated as success', () async {
|
||||
final result = await makeActor(makeClient(putStatus: 204)).handle(
|
||||
UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'),
|
||||
);
|
||||
expect(result, isA<FileUploaded>());
|
||||
});
|
||||
|
||||
test('non-200 upload request throws', () async {
|
||||
for (final status in [401, 403, 429]) {
|
||||
await expectLater(
|
||||
makeActor(makeClient(requestStatus: status)).handle(
|
||||
UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'),
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed PUT throws', () async {
|
||||
await expectLater(
|
||||
makeActor(makeClient(putStatus: 500)).handle(
|
||||
UploadFile(bytes: Uint8List(1), contentType: 'image/jpeg'),
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('file over 10 MB throws before making any request', () async {
|
||||
var requestMade = false;
|
||||
final client = MockClient((_) async {
|
||||
requestMade = true;
|
||||
return http.Response('', 200);
|
||||
});
|
||||
await expectLater(
|
||||
makeActor(client).handle(
|
||||
UploadFile(
|
||||
bytes: Uint8List(10 * 1024 * 1024 + 1),
|
||||
contentType: 'image/jpeg',
|
||||
),
|
||||
),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
expect(requestMade, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
Map<String, dynamic> _extractEvent(FakeRelayConnection relay) {
|
||||
final raw = relay.sentMessages
|
||||
.firstWhere((m) => (jsonDecode(m) as List)[0] == 'EVENT');
|
||||
return (jsonDecode(raw) as List)[1] as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Map<String, String> _tagsMap(Map<String, dynamic> event) =>
|
||||
Map.fromEntries(
|
||||
(event['tags'] as List).map(
|
||||
(t) => MapEntry((t as List)[0] as String, t[1] as String),
|
||||
),
|
||||
);
|
||||
51
test/file_upload_integration_test.dart
Normal file
51
test/file_upload_integration_test.dart
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@Tags(['integration'])
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:swarm/swarm.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// Well-known test key — obviously not a real user key.
|
||||
const _privKey =
|
||||
'0000000000000000000000000000000000000000000000000000000000000001';
|
||||
|
||||
// Per-app credentials for the xfay app.
|
||||
// ignore: avoid_hardcoded_credentials (test-only, not a user secret)
|
||||
const _kAppName = 'xfay';
|
||||
const _kAppSecret =
|
||||
'REDACTED';
|
||||
|
||||
// Minimal valid JPEG (SOI marker only — enough for R2 to store).
|
||||
final _kTestBytes = Uint8List.fromList([0xFF, 0xD8, 0xFF, 0xD9]);
|
||||
const _kContentType = 'image/jpeg';
|
||||
|
||||
void main() {
|
||||
late FileUploadActor actor;
|
||||
|
||||
setUp(() {
|
||||
actor = FileUploadActor(
|
||||
privateKey: _privKey,
|
||||
appName: _kAppName,
|
||||
appSecret: _kAppSecret,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() => actor.close());
|
||||
|
||||
test('register publishes kind 5392 to relay without error', () async {
|
||||
expect(
|
||||
await actor.handle(const RegisterForUpload()),
|
||||
isA<UploaderRegistered>(),
|
||||
);
|
||||
});
|
||||
|
||||
test('upload returns a CDN url under the xfay app path', () async {
|
||||
final result = await actor.handle(
|
||||
UploadFile(bytes: _kTestBytes, contentType: _kContentType),
|
||||
);
|
||||
expect(result, isA<FileUploaded>());
|
||||
final url = (result as FileUploaded).cdnUrl;
|
||||
expect(url, startsWith('https://cdn.otherwhere.app/xfay/'));
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue