Files
buzz/mobile/lib/shared/relay/media_upload.dart
Tom Brow 9c5032b63b perf(mobile): derive keys natively and hash NIP-98 bodies faster (#8965)
## Summary

Two pure-Dart crypto paths that run on the UI isolate during normal use:

- **NIP-98 body hashes.** Every relay HTTP query
(`RelaySessionNotifier._postForEvents`) hashes its request body for the
NIP-98 auth header with pointycastle's `SHA256Digest`. This switches
that hash, the media upload hash and the NIP-OA preimage hash to
`package:crypto`'s `sha256`, which is already a dependency. The output
is identical.
- **Public key derivation.** `AccountIdentityStore.publicKeyForNsec`
runs on every relay auth assertion and derived the key with the `nostr`
package's pure-Dart curve math. It now uses the native
`derivePublicKeyHex` (libsecp256k1) with the same validation. The
default-agent writer now signs with the native `signNostrEvent` instead
of `nostr.Event.from`.

Left as they were: `nostr.Keys` construction for signing keys (nostr
2.0.0 always derives in Dart, with no way to pass a known public key),
and `nostr.Keys.generate()` on the rare key-creation paths.

## Measurements

- **Device** (iPhone 12 mini, Profile build, #4things captures): NIP-98
hashing took 17 to 24 ms of UI CPU per 10 to 20 s capture, mostly from
channel notices queries. `publicKeyForNsec` took about 7 ms per relay
auth assertion.
- **Synthetic** (Mac, AOT, same output): SHA-256 of a 1 KB body takes
39.9 µs with pointycastle and 6.6 µs with `package:crypto`. 50 KB: 1,518
µs vs 293 µs. 1 MB: 29.6 ms vs 6.1 ms.

- **Device A/B after the change** (iPhone 12 mini, Profile/AOT, Tom's
Block community, two app resumes per build from the home screen, 15 s
windows; A is `origin/main` 3d26ed7, B is A plus this PR and
[#8966](https://github.com/block/buzz/pull/8966)): NIP-98 signing per
resume went from 42 / 50 ms to 9 / 13 ms of UI CPU, and all SHA-256 work
per resume from 74 / 73 ms to 33 / 33 ms. Dart public-key derivation
through `nostr.Keys` (left as is, see above) still takes 15 to 23 ms per
resume.
- **Same A/B on current `main`** (14037e3): pointycastle SHA-256 per
resume went from 39 / 45 ms to 7 / 0 ms, and NIP-98 header building from
4 / 5 ms to 2 / 0 ms. `nostr.Keys` derivation: 12 to 17 ms per resume on
both builds.

## Testing

- New known-vector tests for the NIP-98 header hash: an empty body,
`abc`, and the one-million-`a` FIPS vector. They also check the event's
kind, method, signature and pubkey.
- New `publicKeyForNsec` tests: the generator-point vector, matching the
pure-Dart derivation, and rejecting invalid keys (scalar 0 and n,
garbage, an npub).
- These are correctness tests only. A timing test would be flaky, so
none would fail if the old pure-Dart code came back.
- `just mobile-check mobile-test` passes (pre-push hook).
- Automated device run in place of manual testing: the B build above on
the 12 mini, launched and resumed twice into a signed-in Block
community, loading the channel list, channel notices and read state over
authenticated relay queries (NIP-98 headers and relay auth assertions on
every request). Not exercised on the device: media upload and the
default-agent writer.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:23:47 -08:00

941 lines
28 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:file_selector/file_selector.dart' as file_selector;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:image_picker/image_picker.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:crypto/crypto.dart' as crypto;
import 'animated_image_sanitizer.dart';
import 'media_auth.dart';
import 'mp4_fast_start.dart';
import 'relay_provider.dart';
import '../hosted/hosted_relay_auth.dart';
import 'package:buzz/shared/crypto/event_signing.dart';
part 'media_upload/platform_bindings.dart';
part 'media_upload/helpers.dart';
const _mediaUploadPath = '/upload';
const _legacyMediaUploadPath = '/media/upload';
const _mediaUploadPlatformChannelName = 'buzz/media_upload';
const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload';
const _transcodeVideoToMp4Method = 'transcodeVideoToMp4';
const _packageVoiceNoteForUploadMethod = 'packageVoiceNoteForUpload';
const _generateVideoPosterMethod = 'generateVideoPoster';
const _transcodeImageToJpegMethod = 'transcodeImageToJpeg';
const _requiresLegacyMediaStoragePermissionMethod =
'requiresLegacyMediaStoragePermission';
const _readClipboardImageMethod = 'readClipboardImage';
const _clipboardHasImageMethod = 'clipboardHasImage';
const _uploadAuthKind = 24242;
const _uploadAuthLifetimeSeconds = 60;
const _heicBrands = {
'heic',
'heix',
'hevc',
'hevx',
'heim',
'heis',
'mif1',
'msf1',
};
final _mediaUploadPlatformChannel = MethodChannel(
_mediaUploadPlatformChannelName,
);
const _allowedImageMimeTypes = {
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
};
const _allowedVideoMimeTypes = {'video/mp4'};
const _allowedAudioMimeTypes = {'audio/mp4', 'audio/m4a', 'audio/aac'};
const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB
const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
typedef PickGalleryImage = Future<XFile?> Function();
/// Selects multiple gallery images for upload in picker order.
typedef PickGalleryImages = Future<List<XFile>> Function();
typedef PickGalleryVideo = Future<XFile?> Function();
typedef PickAttachmentFile = Future<XFile?> Function();
typedef SanitizeImageBytes =
Future<Uint8List> Function(Uint8List bytes, String mimeType);
typedef TranscodeImageToJpeg = Future<Uint8List> Function(Uint8List bytes);
typedef TranscodeVideoToMp4 = Future<String> Function(String filePath);
/// Packages a recorded voice-note file into its upload container.
typedef PackageVoiceNoteForUpload = Future<String> Function(String filePath);
/// Generates poster-frame bytes for the video at [filePath], when available.
typedef GenerateVideoPoster = Future<Uint8List?> Function(String filePath);
typedef ReadClipboardImage = Future<Uint8List?> Function();
class MediaPolicyUploadException implements Exception {
const MediaPolicyUploadException();
@override
String toString() => _mediaPolicyUploadMessage;
}
/// Cancels a single user-initiated media upload without closing the shared
/// HTTP client used by later uploads.
class UploadCancellationToken {
final Completer<void> _cancelled = Completer<void>();
/// Whether cancellation has been requested.
bool get isCancelled => _cancelled.isCompleted;
/// Completes when cancellation is first requested.
Future<void> get whenCancelled => _cancelled.future;
/// Requests cancellation. Calling this more than once has no effect.
void cancel() {
if (!_cancelled.isCompleted) _cancelled.complete();
}
}
/// Indicates that a user cancelled a media upload before it completed.
class UploadCancelledException implements Exception {
const UploadCancelledException();
}
@immutable
class _PreparedUploadImage {
final Uint8List bytes;
final String mimeType;
const _PreparedUploadImage({required this.bytes, required this.mimeType});
}
@immutable
class BlobDescriptor {
final String url;
final String sha256;
final int size;
final String type;
final int uploaded;
final String? dim;
final String? blurhash;
final String? thumb;
final double? duration;
final String? image;
final String? filename;
const BlobDescriptor({
required this.url,
required this.sha256,
required this.size,
required this.type,
required this.uploaded,
this.dim,
this.blurhash,
this.thumb,
this.duration,
this.image,
this.filename,
});
factory BlobDescriptor.fromJson(Map<String, dynamic> json) => BlobDescriptor(
url: json['url'] as String,
sha256: json['sha256'] as String,
size: (json['size'] as num).toInt(),
type: json['type'] as String,
uploaded: (json['uploaded'] as num).toInt(),
dim: json['dim'] as String?,
blurhash: json['blurhash'] as String?,
thumb: json['thumb'] as String?,
duration: (json['duration'] as num?)?.toDouble(),
image: json['image'] as String?,
filename: json['filename'] as String?,
);
BlobDescriptor withFilename(String value) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration,
image: image,
filename: value,
);
/// Returns a descriptor carrying canonical packaged voice-note metadata.
BlobDescriptor withVoiceNoteMetadata({
required String filename,
required double fallbackDurationSeconds,
}) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration ?? fallbackDurationSeconds,
image: image,
filename: filename,
);
/// Returns a descriptor with [value] as its NIP-71 video poster URL.
BlobDescriptor withImage(String value) => BlobDescriptor(
url: url,
sha256: sha256,
size: size,
type: type,
uploaded: uploaded,
dim: dim,
blurhash: blurhash,
thumb: thumb,
duration: duration,
image: value,
filename: filename,
);
List<String> toImetaTag() => [
'imeta',
'url $url',
'm $type',
'x $sha256',
'size $size',
if (dim != null) 'dim $dim',
if (blurhash != null) 'blurhash $blurhash',
if (thumb != null) 'thumb $thumb',
if (duration != null) 'duration $duration',
if (image != null) 'image $image',
if (filename != null) 'filename $filename',
];
String toMarkdownImage() {
final label = (filename ?? 'file').replaceAllMapped(
RegExp(r'[\\\[\]]'),
(match) => '\\${match[0]}',
);
if (type.startsWith('audio/')) return '![audio]($url)';
if (_isPackagedVoiceNote(type, filename)) return '[$label]($url)';
if (type.startsWith('video/')) return '![video]($url)';
if (type.startsWith('image/')) return '![image]($url)';
return '[$label]($url)';
}
}
class MediaUploadService {
final String _baseUrl;
final String? _nsec;
final PickGalleryImage _pickGalleryImage;
final PickGalleryImages _pickGalleryImages;
final PickGalleryVideo _pickGalleryVideo;
final PickAttachmentFile? _pickAttachmentFile;
final SanitizeImageBytes _sanitizeImageBytes;
final TranscodeImageToJpeg _transcodeImageToJpeg;
final TranscodeVideoToMp4 _transcodeVideoToMp4;
final PackageVoiceNoteForUpload _packageVoiceNoteForUpload;
final GenerateVideoPoster _generateVideoPoster;
final ReadClipboardImage _readClipboardImage;
final DateTime Function() _now;
final http.Client _http;
final bool _ownsHttpClient;
MediaUploadService({
required String baseUrl,
required String? nsec,
required PickGalleryImage pickGalleryImage,
PickGalleryImages? pickGalleryImages,
required PickGalleryVideo pickGalleryVideo,
PickAttachmentFile? pickAttachmentFile,
SanitizeImageBytes? sanitizeImageBytes,
TranscodeImageToJpeg? transcodeImageToJpeg,
TranscodeVideoToMp4? transcodeVideoToMp4,
PackageVoiceNoteForUpload? packageVoiceNoteForUpload,
GenerateVideoPoster? generateVideoPoster,
ReadClipboardImage? readClipboardImage,
DateTime Function()? now,
http.Client? httpClient,
}) : _baseUrl = baseUrl,
_nsec = nsec,
_pickGalleryImage = pickGalleryImage,
_pickGalleryImages =
pickGalleryImages ??
(() async {
final image = await pickGalleryImage();
return image == null ? const <XFile>[] : [image];
}),
_pickGalleryVideo = pickGalleryVideo,
_pickAttachmentFile = pickAttachmentFile,
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
_transcodeImageToJpeg =
transcodeImageToJpeg ?? _transcodePickedImageToJpeg,
_transcodeVideoToMp4 = transcodeVideoToMp4 ?? _transcodePickedVideoToMp4,
_packageVoiceNoteForUpload =
packageVoiceNoteForUpload ?? _packagePickedVoiceNoteForUpload,
_generateVideoPoster = generateVideoPoster ?? _generatePickedVideoPoster,
_readClipboardImage = readClipboardImage ?? _readPlatformClipboardImage,
_now = now ?? DateTime.now,
_http = httpClient ?? http.Client(),
_ownsHttpClient = httpClient == null;
void dispose() {
if (_ownsHttpClient) {
_http.close();
}
}
/// Opens the system photo library without uploading the selected image.
Future<XFile?> pickGalleryImage() => _pickGalleryImage();
/// Produces displayable, sanitized image bytes before an upload begins.
Future<Uint8List> prepareImageBytes(XFile image) async =>
(await _prepareUploadImage(image)).bytes;
/// Opens the system picker with multi-selection enabled.
Future<List<XFile>> pickGalleryImages() => _pickGalleryImages();
Future<BlobDescriptor> uploadImage(
XFile image, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
final preparedImage = await _prepareUploadImage(image);
_throwIfCancelled(cancellationToken);
return _uploadPreparedBytes(
preparedImage.bytes,
mimeType: preparedImage.mimeType,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
Future<bool> clipboardHasImage() async {
return await _mediaUploadPlatformChannel.invokeMethod<bool>(
_clipboardHasImageMethod,
) ??
false;
}
/// Reads a clipboard image for composer preview before the user sends it.
Future<XFile?> readClipboardImage() async {
final bytes = await _readClipboardImage();
if (bytes == null || bytes.isEmpty) return null;
return XFile.fromData(bytes, name: 'Pasted image');
}
/// Opens the system gallery video picker.
Future<XFile?> pickGalleryVideo() => _pickGalleryVideo();
/// Sanitizes and uploads [pickedVideo] as an MP4 attachment.
Future<BlobDescriptor> uploadVideo(
XFile pickedVideo, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final length = await pickedVideo.length();
if (length > _maxVideoSizeBytes) {
throw Exception(
'Video is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
// Always rebuild the container. Passing an existing MP4 through would retain
// QuickTime GPS, global metadata, chapters, or non-A/V tracks.
String? transcodedPath;
try {
transcodedPath = await _transcodeVideoToMp4(pickedVideo.path);
_throwIfCancelled(cancellationToken);
final transcodedFile = File(transcodedPath);
final transcodedLength = await transcodedFile.length();
if (transcodedLength > _maxVideoSizeBytes) {
throw Exception(
'Transcoded video is too large (${(transcodedLength / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
final bytes = await transcodedFile.readAsBytes();
_throwIfCancelled(cancellationToken);
final video = await uploadBytes(
bytes,
mimeType: 'video/mp4',
onProgress: onProgress == null
? null
: (progress) => onProgress(progress * 0.9),
cancellationToken: cancellationToken,
);
// Extract from the canonical output first so the poster matches the
// uploaded orientation. Some AVFoundation exports need a moment before
// their first frame is seekable; fall back to the picked source instead
// of silently sending a permanently gray video card.
Uint8List? posterBytes;
Object? posterExtractionError;
for (final sourcePath in {transcodedPath, pickedVideo.path}) {
try {
final candidate = await _generateVideoPoster(sourcePath);
if (candidate != null && candidate.isNotEmpty) {
posterBytes = candidate;
break;
}
} catch (error) {
posterExtractionError = error;
}
}
if (posterBytes == null) {
if (posterExtractionError != null) {
debugPrint('Unable to generate video poster: $posterExtractionError');
}
onProgress?.call(1);
return video;
}
// Posters are best-effort: a video that passed the media policy should
// still send if the separate preview upload fails.
try {
_throwIfCancelled(cancellationToken);
final poster = await uploadImage(
XFile.fromData(
posterBytes,
mimeType: 'image/jpeg',
name: 'video-poster.jpg',
),
onProgress: onProgress == null
? null
: (progress) => onProgress(0.9 + (progress * 0.1)),
cancellationToken: cancellationToken,
);
return video.withImage(poster.url);
} catch (error) {
debugPrint('Unable to upload video poster: $error');
onProgress?.call(1);
return video;
}
} finally {
if (transcodedPath != null) {
try {
await File(transcodedPath).delete();
} catch (_) {
// Best-effort temp file cleanup.
}
}
}
}
/// Packages a recorded AAC voice note in the canonical MP4 envelope.
Future<BlobDescriptor> uploadVoiceNote(
XFile voiceNote, {
required Duration duration,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final mimeType = voiceNote.mimeType ?? 'audio/mp4';
if (!_allowedAudioMimeTypes.contains(mimeType)) {
throw Exception('unsupported voice note type: $mimeType');
}
String? packagedPath;
try {
packagedPath = await _packageVoiceNoteForUpload(voiceNote.path);
_throwIfCancelled(cancellationToken);
final bytes = await File(packagedPath).readAsBytes();
if (bytes.isEmpty) throw Exception('Voice note is empty.');
if (bytes.length > _maxFileSizeBytes) {
throw Exception('Voice note is too large. Maximum is 100MB.');
}
final descriptor = await _uploadPreparedBytes(
bytes,
mimeType: 'video/mp4',
onProgress: onProgress,
cancellationToken: cancellationToken,
);
return descriptor.withVoiceNoteMetadata(
filename: _voiceNoteMp4Filename(voiceNote.name),
fallbackDurationSeconds: duration.inMilliseconds / 1000,
);
} finally {
if (packagedPath != null && packagedPath != voiceNote.path) {
try {
await File(packagedPath).delete();
} on FileSystemException {
// Best-effort temp file cleanup.
}
}
}
}
/// Opens the system document picker for a generic file attachment.
Future<XFile?> pickAttachmentFile() async {
final pickAttachmentFile = _pickAttachmentFile;
if (pickAttachmentFile == null) {
throw Exception("File attachments aren't available on this device.");
}
return pickAttachmentFile();
}
/// Uploads [pickedFile] as a size-limited generic attachment.
Future<BlobDescriptor> uploadFile(
XFile pickedFile, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final length = await pickedFile.length();
if (length == 0) {
throw Exception('File is empty.');
}
if (length > _maxFileSizeBytes) {
throw Exception(
'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.',
);
}
final bytes = await pickedFile.readAsBytes();
_throwIfCancelled(cancellationToken);
final descriptor = await _uploadPreparedBytes(
bytes,
mimeType: 'application/octet-stream',
allowGenericFile: true,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name));
}
Future<BlobDescriptor> uploadBytes(
Uint8List bytes, {
required String mimeType,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
if (mimeType == 'image/gif' ||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
try {
bytes = sanitizeAnimatedImageForUpload(bytes, mimeType);
} on FormatException {
throw Exception('failed to sanitize image for upload');
}
}
return _uploadPreparedBytes(
bytes,
mimeType: mimeType,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
Future<BlobDescriptor> _uploadPreparedBytes(
Uint8List bytes, {
required String mimeType,
bool allowGenericFile = false,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
if (!allowGenericFile &&
!_allowedImageMimeTypes.contains(mimeType) &&
!_allowedVideoMimeTypes.contains(mimeType)) {
throw Exception('unsupported file type: $mimeType');
}
final sha256 = _sha256Hex(bytes);
var response = await _sendUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _mediaUploadPath,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
if (response.statusCode == HttpStatus.notFound ||
response.statusCode == HttpStatus.methodNotAllowed) {
response = await _sendUploadRequest(
bytes: bytes,
mimeType: mimeType,
sha256: sha256,
path: _legacyMediaUploadPath,
onProgress: onProgress,
cancellationToken: cancellationToken,
);
}
if (response.statusCode < 200 || response.statusCode >= 300) {
if (_allowedImageMimeTypes.contains(mimeType) &&
(response.statusCode == HttpStatus.unsupportedMediaType ||
response.statusCode == HttpStatus.unprocessableEntity)) {
throw const MediaPolicyUploadException();
}
throw Exception(
'upload failed (${response.statusCode}): ${response.body}',
);
}
return BlobDescriptor.fromJson(
jsonDecode(response.body) as Map<String, dynamic>,
);
}
Future<http.Response> _sendUploadRequest({
required Uint8List bytes,
required String mimeType,
required String sha256,
required String path,
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) async {
_throwIfCancelled(cancellationToken);
final request = http.AbortableStreamedRequest(
'PUT',
Uri.parse(_baseUrl).resolve(path),
abortTrigger: cancellationToken?.whenCancelled,
);
request.contentLength = bytes.length;
request.headers.addAll(
_buildUploadHeaders(mimeType: mimeType, sha256: sha256),
);
final writeRequest = request.sink
.addStream(_uploadByteStream(bytes, onProgress))
.whenComplete(request.sink.close);
final response = await _http.send(request);
await writeRequest;
_throwIfCancelled(cancellationToken);
return http.Response.fromStream(response);
}
void _throwIfCancelled(UploadCancellationToken? cancellationToken) {
if (cancellationToken?.isCancelled ?? false) {
throw const UploadCancelledException();
}
}
Map<String, String> _buildUploadHeaders({
required String mimeType,
required String sha256,
}) {
final headers = <String, String>{
'Authorization': _buildUploadAuthHeader(sha256),
'Content-Type': mimeType,
'X-SHA-256': sha256,
};
return headers;
}
String _buildUploadAuthHeader(String sha256) {
final authEvent = _buildUploadAuthEvent(sha256);
final authJson = authEvent.toJson();
final encoded = base64Url.encode(utf8.encode(authJson)).replaceAll('=', '');
return 'Nostr $encoded';
}
nostr.Event _buildUploadAuthEvent(String sha256) {
final nsec = _nsec;
if (nsec == null || nsec.isEmpty) {
throw Exception('Cannot upload media: no signing key available');
}
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) {
throw Exception('Invalid nsec');
}
final expiration =
(_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds;
final serverAuthority = extractServerAuthority(_baseUrl);
if (serverAuthority == null) {
throw Exception(
'Cannot mint upload auth: no server authority in relay URL: $_baseUrl',
);
}
final tags = <List<String>>[
['t', 'upload'],
['x', sha256],
['expiration', '$expiration'],
['server', serverAuthority],
];
return signNostrEvent(
kind: _uploadAuthKind,
content: 'Upload buzz-media',
tags: tags,
secretKey: privkeyHex,
);
}
Future<_PreparedUploadImage> _prepareUploadImage(XFile pickedImage) async {
final bytes = await pickedImage.readAsBytes();
final detectedMimeType = _tryDetectImageMimeType(bytes);
if (detectedMimeType case final mimeType?) {
return _prepareDetectedUploadImage(bytes, mimeType);
}
if (_shouldTranscodePickedImage(pickedImage, bytes)) {
return _prepareTranscodedUploadImage(bytes);
}
throw Exception('unsupported file type');
}
Future<_PreparedUploadImage> _prepareDetectedUploadImage(
Uint8List bytes,
String mimeType,
) async {
final preparedBytes = await _sanitizeImageBytesIfNeeded(bytes, mimeType);
return _buildPreparedUploadImage(preparedBytes);
}
Future<_PreparedUploadImage> _prepareTranscodedUploadImage(
Uint8List bytes,
) async {
final transcodedBytes = await _transcodeImageToJpeg(bytes);
return _buildPreparedUploadImage(transcodedBytes);
}
_PreparedUploadImage _buildPreparedUploadImage(Uint8List bytes) {
return _PreparedUploadImage(
bytes: bytes,
mimeType: _detectImageMimeType(bytes),
);
}
Future<Uint8List> _sanitizeImageBytesIfNeeded(
Uint8List bytes,
String mimeType,
) async {
if (mimeType == 'image/gif' ||
(mimeType == 'image/png' && _isAnimatedPng(bytes)) ||
(mimeType == 'image/webp' && _isAnimatedWebp(bytes))) {
try {
return sanitizeAnimatedImageForUpload(bytes, mimeType);
} on FormatException {
throw Exception('failed to sanitize image for upload');
}
}
if (!_shouldSanitizePickedImage(mimeType)) {
return bytes;
}
final sanitizedBytes = await _sanitizeImageBytes(bytes, mimeType);
if (sanitizedBytes.isEmpty) {
throw Exception('failed to sanitize image for upload');
}
return sanitizedBytes;
}
}
String _safeAttachmentFilename(String filename) {
final segments = filename.split(RegExp(r'[/\\]'));
final basename = segments.isEmpty ? '' : segments.last;
final sanitized = StringBuffer();
var byteLength = 0;
for (final rune in basename.runes) {
if ((rune >= 0 && rune <= 0x1f) || (rune >= 0x7f && rune <= 0x9f)) {
continue;
}
final character = String.fromCharCode(rune);
final characterByteLength = utf8.encode(character).length;
if (byteLength + characterByteLength > 255) break;
sanitized.write(character);
byteLength += characterByteLength;
}
final safeBasename = sanitized.toString().trim();
return safeBasename.isEmpty ? 'file' : safeBasename;
}
String _voiceNoteMp4Filename(String filename) {
final safe = _safeAttachmentFilename(filename);
final withoutExtension = safe.replaceFirst(RegExp(r'\.[^.]*$'), '');
final stem = withoutExtension.toLowerCase().startsWith('voice-note-')
? withoutExtension
: 'voice-note-$withoutExtension';
return '$stem.mp4';
}
bool _isPackagedVoiceNote(String mimeType, String? filename) {
final normalized = filename?.toLowerCase();
return mimeType == 'video/mp4' &&
normalized != null &&
normalized.startsWith('voice-note-') &&
normalized.endsWith('.mp4');
}
Stream<List<int>> _uploadByteStream(
Uint8List bytes,
ValueChanged<double>? onProgress,
) async* {
const chunkSize = 64 * 1024;
onProgress?.call(0);
if (bytes.isEmpty) {
onProgress?.call(1);
return;
}
for (var start = 0; start < bytes.length; start += chunkSize) {
final end = math.min(start + chunkSize, bytes.length);
yield Uint8List.sublistView(bytes, start, end);
onProgress?.call(end / bytes.length);
}
}
String _sha256Hex(Uint8List bytes) => crypto.sha256.convert(bytes).toString();
String? _tryDetectImageMimeType(Uint8List bytes) {
try {
return _detectImageMimeType(bytes);
} on Exception {
return null;
}
}
String _detectImageMimeType(Uint8List bytes) {
if (_startsWith(bytes, const [0xff, 0xd8, 0xff])) {
return 'image/jpeg';
}
if (_startsWith(bytes, const [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
])) {
return 'image/png';
}
if (_startsWith(bytes, ascii.encode('GIF87a')) ||
_startsWith(bytes, ascii.encode('GIF89a'))) {
return 'image/gif';
}
if (_startsWith(bytes, ascii.encode('RIFF')) &&
bytes.length >= 12 &&
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) == 'WEBP') {
return 'image/webp';
}
throw Exception('unsupported file type');
}
bool _shouldTranscodePickedImage(XFile pickedImage, Uint8List bytes) {
return _supportsNativeUploadImageProcessing() &&
(_hasHeicFileExtension(pickedImage) || _looksLikeHeicOrHeif(bytes));
}
bool _isAnimatedPng(Uint8List bytes) {
if (!_startsWith(bytes, const [
0x89,
0x50,
0x4e,
0x47,
0x0d,
0x0a,
0x1a,
0x0a,
])) {
return false;
}
var offset = 8;
while (offset + 12 <= bytes.length) {
final chunkSize = _readUint32BigEndian(bytes, offset);
if (offset + 12 + chunkSize > bytes.length) {
return false;
}
if (_matchesAscii(bytes, offset + 4, 'acTL')) {
return true;
}
offset += 12 + chunkSize;
}
return false;
}
bool _isAnimatedWebp(Uint8List bytes) {
if (!_startsWith(bytes, ascii.encode('RIFF')) ||
bytes.length < 12 ||
ascii.decode(bytes.sublist(8, 12), allowInvalid: true) != 'WEBP') {
return false;
}
var offset = 12;
while (offset + 8 <= bytes.length) {
final chunkSize = _readUint32LittleEndian(bytes, offset + 4);
final payloadOffset = offset + 8;
if (payloadOffset + chunkSize > bytes.length) {
return false;
}
if (_matchesAscii(bytes, offset, 'ANIM') ||
_matchesAscii(bytes, offset, 'ANMF')) {
return true;
}
if (_matchesAscii(bytes, offset, 'VP8X') &&
chunkSize >= 1 &&
(bytes[payloadOffset] & 0x02) != 0) {
return true;
}
offset = payloadOffset + chunkSize + (chunkSize.isOdd ? 1 : 0);
}
return false;
}
bool _shouldSanitizePickedImage(String mimeType) {
return _supportsNativeUploadImageProcessing() &&
(mimeType == 'image/jpeg' ||
mimeType == 'image/png' ||
mimeType == 'image/webp');
}
bool _supportsNativeUploadImageProcessing() {
return switch (defaultTargetPlatform) {
TargetPlatform.android || TargetPlatform.iOS => true,
_ => false,
};
}
bool _hasHeicFileExtension(XFile pickedImage) {
for (final candidate in [pickedImage.name, pickedImage.path]) {
final normalizedCandidate = candidate.toLowerCase();
if (normalizedCandidate.endsWith('.heic') ||
normalizedCandidate.endsWith('.heif')) {
return true;
}
}
return false;
}
bool _looksLikeHeicOrHeif(Uint8List bytes) {
if (bytes.length < 12 || !_matchesAscii(bytes, 4, 'ftyp')) {
return false;
}
final upperBound = bytes.length < 32 ? bytes.length : 32;
for (var offset = 8; offset + 4 <= upperBound; offset += 4) {
final brand = ascii.decode(
bytes.sublist(offset, offset + 4),
allowInvalid: true,
);
if (_heicBrands.contains(brand.toLowerCase())) {
return true;
}
}
return false;
}