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_fetch_actor.dart';
|
||||||
export 'src/nostr_signer_actor.dart';
|
export 'src/nostr_signer_actor.dart';
|
||||||
export 'src/geo_actor.dart';
|
export 'src/geo_actor.dart';
|
||||||
|
export 'src/file_upload_actor.dart';
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ dependencies:
|
||||||
bip340: ^0.3.1
|
bip340: ^0.3.1
|
||||||
crypto: ^3.0.7
|
crypto: ^3.0.7
|
||||||
dart_geohash: ^2.1.0
|
dart_geohash: ^2.1.0
|
||||||
|
http: ^1.6.0
|
||||||
|
pointycastle: ^4.0.0
|
||||||
retry: ^3.1.2
|
retry: ^3.1.2
|
||||||
web_socket_channel: ^3.0.3
|
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