Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/matrix.dart
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export 'src/models/receipts.dart';
export 'src/utils/sync_update_extension.dart';
export 'src/utils/to_device_event.dart';
export 'src/utils/uia_request.dart';
export 'src/utils/content_scanner_config.dart';
export 'src/utils/uri_extension.dart';
export 'src/models/login_type.dart';
export 'src/models/power_level.dart';
Expand Down
8 changes: 8 additions & 0 deletions lib/src/client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,13 @@ class Client extends MatrixApi {
MatrixImageFileResizeArguments,
)? customImageResizer;

/// Optional matrix-content-scanner proxy configuration.
///
/// When set, media URL helpers resolve `mxc://` URIs to scanner URLs, and
/// attachment downloads use the scanner for network requests. Cached media is
/// trusted and is not scanned again.
MatrixContentScannerConfig? contentScannerConfig;

/// The compare function how the rooms should be sorted internally.
/// The [defaultRoomSorter] is used if no custom room sorter is provided.
RoomSorter? _customRoomSorter;
Expand Down Expand Up @@ -219,6 +226,7 @@ class Client extends MatrixApi {
this.enableLatexMarkdown = true,
this.dehydratedDeviceDisplayName = 'Dehydrated Device',
RoomSorter? customRoomSorter,
this.contentScannerConfig,
}) : _database = database,
syncFilter = syncFilter ??
Filter(
Expand Down
93 changes: 77 additions & 16 deletions lib/src/event.dart
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ class Event extends MatrixEvent {
/// Throws an exception if the scheme is not `mxc` or the homeserver is not
/// set.
///
/// Important! To use this link you have to set a http header like this:
/// Scanner and authenticated media URLs may need an authorization header:
/// `headers: {"authorization": "Bearer ${client.accessToken}"}`
Future<Uri?> getAttachmentUri({
bool getThumbnail = false,
Expand Down Expand Up @@ -731,9 +731,13 @@ class Event extends MatrixEvent {
/// Throws an exception if the scheme is not `mxc` or the homeserver is not
/// set.
///
/// Important! To use this link you have to set a http header like this:
/// Deprecated: scanner-unaware. Use [getAttachmentUri] instead.
///
/// This URL may need an authorization header:
/// `headers: {"authorization": "Bearer ${client.accessToken}"}`
@Deprecated('Use getAttachmentUri() instead')
@Deprecated(
'Use getAttachmentUri() instead. This legacy helper is scanner-unaware.',
)
Uri? getAttachmentUrl({
bool getThumbnail = false,
bool useThumbnailMxcUrl = false,
Expand Down Expand Up @@ -809,6 +813,10 @@ class Event extends MatrixEvent {
/// true to download the thumbnail instead. Set [fromLocalStoreOnly] to true
/// if you want to retrieve the attachment from the local store only without
/// making http request.
///
/// With `Client.contentScannerConfig`, network downloads use the scanner.
/// Scanner errors throw [ContentScannerException]. For encrypted scanner
/// downloads, [downloadCallback] is ignored.
Future<MatrixFile> downloadAndDecryptAttachment({
bool getThumbnail = false,
Future<Uint8List> Function(Uri)? downloadCallback,
Expand Down Expand Up @@ -842,26 +850,38 @@ class Event extends MatrixEvent {
final thisInfoMapSize = thisInfoMap.tryGet<int>('size');
var storeable =
thisInfoMapSize != null && thisInfoMapSize <= database.maxFileSize;

Uint8List? uint8list;
if (storeable) {
uint8list = await room.client.database.getFile(mxcUrl);
}

// Download the file
final scanner = room.client.contentScannerConfig;
final useScannerForEncrypted = scanner != null && isEncrypted;
final canDownloadFileFromServer = uint8list == null && !fromLocalStoreOnly;
if (canDownloadFileFromServer) {
final httpClient = room.client.httpClient;
downloadCallback ??= (Uri url) async {
final request = http.Request('GET', url);
request.headers['authorization'] = 'Bearer ${room.client.accessToken}';

final response = await httpClient.send(request);

return await response.stream.toBytesWithProgress(onDownloadProgress);
};
uint8list =
await downloadCallback(await mxcUrl.getDownloadUri(room.client));
if (useScannerForEncrypted) {
final fileMap = getThumbnail
? infoMap.tryGetMap<String, Object?>('thumbnail_file')
: content.tryGetMap<String, Object?>('file');
if (fileMap == null) throw ('No encrypted file info found');

uint8list = await _downloadEncryptedAttachmentViaScanner(
scanner: scanner,
fileMap: fileMap,
);
} else {
final downloadUri = await mxcUrl.getDownloadUri(room.client);
if (downloadCallback != null) {
uint8list = await downloadCallback(downloadUri);
} else {
uint8list = await _downloadAttachmentBytes(
downloadUri,
scanner: scanner,
onDownloadProgress: onDownloadProgress,
);
}
}
storeable = storeable && uint8list.lengthInBytes < database.maxFileSize;
if (storeable) {
await database.storeFile(
Expand All @@ -874,7 +894,7 @@ class Event extends MatrixEvent {
throw ('Unable to download file from local store.');
}

// Decrypt the file
// Scanner downloads still return encrypted media bytes.
if (isEncrypted) {
final fileMap = getThumbnail
? infoMap.tryGetMap<String, Object?>('thumbnail_file')
Expand Down Expand Up @@ -914,6 +934,47 @@ class Event extends MatrixEvent {
);
}

Future<Uint8List> _downloadEncryptedAttachmentViaScanner({
required MatrixContentScannerConfig scanner,
required Map<String, Object?> fileMap,
}) async {
final request = http.Request('POST', scanner.downloadEncryptedUri);
request.headers['content-type'] = 'application/json';
if (scanner.withAuthHeader) {
request.headers['authorization'] = 'Bearer ${room.client.accessToken}';
}
request.body = jsonEncode({'file': fileMap});

final streamed = await room.client.httpClient.send(request);
final response = await http.Response.fromStream(streamed);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw parseContentScannerError(response);
}

return response.bodyBytes;
}

Future<Uint8List> _downloadAttachmentBytes(
Uri url, {
required MatrixContentScannerConfig? scanner,
void Function(int)? onDownloadProgress,
}) async {
final request = http.Request('GET', url);
if (scanner == null || scanner.withAuthHeader) {
request.headers['authorization'] = 'Bearer ${room.client.accessToken}';
}

final response = await room.client.httpClient.send(request);
if (scanner != null &&
(response.statusCode < 200 || response.statusCode >= 300)) {
throw parseContentScannerError(
await http.Response.fromStream(response),
);
}

return response.stream.toBytesWithProgress(onDownloadProgress);
}

/// Returns if this is a known event type.
bool get isEventTypeKnown =>
EventLocalizations.localizationsMap.containsKey(type);
Expand Down
114 changes: 114 additions & 0 deletions lib/src/utils/content_scanner_config.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Famedly Matrix SDK
* Copyright (C) 2026 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

import 'dart:convert';

import 'package:http/http.dart' as http;

/// Scanner endpoints used for Matrix media downloads.
class MatrixContentScannerConfig {
/// Base URI for media downloads. `{serverName}/{mediaId}` is appended.
final Uri downloadUri;

/// Base URI for thumbnails. Thumbnail query parameters are preserved.
final Uri downloadThumbnailUri;

/// Full URI of the scanner's `download_encrypted` endpoint.
final Uri downloadEncryptedUri;

/// Whether scanner requests should include the Matrix access token.
final bool withAuthHeader;

/// Preview hint from scanner discovery. Stored for app-level decisions.
final bool scanBeforePreview;

MatrixContentScannerConfig({
required Uri downloadUri,
required Uri downloadThumbnailUri,
required this.downloadEncryptedUri,
this.withAuthHeader = true,
this.scanBeforePreview = false,
}) : downloadUri = _ensureTrailingSlash(downloadUri),
downloadThumbnailUri = _ensureTrailingSlash(downloadThumbnailUri);

factory MatrixContentScannerConfig.fromJson(Map<String, Object?> json) =>
MatrixContentScannerConfig(
downloadUri: Uri.parse(json['download_uri']! as String),
downloadThumbnailUri:
Uri.parse(json['download_thumbnail_uri']! as String),
downloadEncryptedUri: Uri.parse(json['download_encrypted']! as String),
withAuthHeader: (json['with_auth_header'] as bool?) ?? true,
scanBeforePreview: (json['scan_before_preview'] as bool?) ?? false,
);

Map<String, Object?> toJson() => {
'download_uri': downloadUri.toString(),
'download_thumbnail_uri': downloadThumbnailUri.toString(),
'download_encrypted': downloadEncryptedUri.toString(),
'with_auth_header': withAuthHeader,
'scan_before_preview': scanBeforePreview,
};

static Uri _ensureTrailingSlash(Uri uri) {
if (uri.path.endsWith('/')) return uri;
return uri.replace(path: '${uri.path}/');
}
}

/// Thrown when the content scanner returns a non-2xx response.
class ContentScannerException implements Exception {
/// Scanner error code, or `M_UNKNOWN` when the body cannot be parsed.
final String reason;

/// Human-readable scanner error.
final String info;

/// HTTP status code.
final int statusCode;

const ContentScannerException({
required this.reason,
required this.info,
required this.statusCode,
});

@override
String toString() => 'ContentScannerException($statusCode $reason): $info';
}

/// Parses scanner and Matrix-style error responses.
ContentScannerException parseContentScannerError(http.Response response) {
var reason = 'M_UNKNOWN';
var info = response.reasonPhrase ?? response.body;
try {
final decoded = jsonDecode(response.body);
if (decoded is Map<String, Object?>) {
final r = decoded['reason'] ?? decoded['errcode'];
final i = decoded['info'] ?? decoded['error'];
if (r is String) reason = r;
if (i is String) info = i;
}
} catch (_) {
// Body is not JSON - keep defaults.
}
return ContentScannerException(
reason: reason,
info: info,
statusCode: response.statusCode,
);
}
57 changes: 42 additions & 15 deletions lib/src/utils/uri_extension.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,16 @@ extension MxcUriExtension on Uri {
/// Throws an exception if the scheme is not `mxc` or the homeserver is not
/// set.
///
/// Important! To use this link you have to set a http header like this:
/// Scanner and authenticated media URLs may need an authorization header:
/// `headers: {"authorization": "Bearer ${client.accessToken}"}`
Future<Uri> getDownloadUri(Client client) async {
if (!isScheme('mxc')) return Uri();

final scanner = client.contentScannerConfig;
if (scanner != null) {
return _appendMxcTo(scanner.downloadUri);
}

String uriPath;

if (await client.authenticatedMediaSupported()) {
Expand All @@ -40,11 +47,18 @@ extension MxcUriExtension on Uri {
'_matrix/media/v3/download/$host${hasPort ? ':$port' : ''}$path';
}

return isScheme('mxc')
? client.homeserver != null
? client.homeserver?.resolve(uriPath) ?? Uri()
: Uri()
: Uri();
final homeserver = client.homeserver;
if (homeserver == null) return Uri();

return homeserver.resolve(uriPath);
}

Uri _appendMxcTo(Uri base) {
final reference = '$host${hasPort ? ':$port' : ''}$path';
final trimmed =
reference.startsWith('/') ? reference.substring(1) : reference;
final basePath = base.path.endsWith('/') ? base.path : '${base.path}/';
return base.replace(path: '$basePath$trimmed');
}

/// Transforms this `mxc://` Uri into a `http` resource, which can be used
Expand All @@ -57,7 +71,7 @@ extension MxcUriExtension on Uri {
/// Throws an exception if the scheme is not `mxc` or the homeserver is not
/// set.
///
/// Important! To use this link you have to set a http header like this:
/// Scanner and authenticated media URLs may need an authorization header:
/// `headers: {"authorization": "Bearer ${client.accessToken}"}`
Future<Uri> getThumbnailUri(
Client client, {
Expand All @@ -67,6 +81,20 @@ extension MxcUriExtension on Uri {
bool? animated = false,
}) async {
if (!isScheme('mxc')) return Uri();

final queryParameters = {
if (width != null) 'width': width.round().toString(),
if (height != null) 'height': height.round().toString(),
if (method != null) 'method': method.toString().split('.').last,
if (animated != null) 'animated': animated.toString(),
};

final scanner = client.contentScannerConfig;
if (scanner != null) {
return _appendMxcTo(scanner.downloadThumbnailUri)
.replace(queryParameters: queryParameters);
}

final homeserver = client.homeserver;
if (homeserver == null) {
return Uri();
Expand All @@ -86,16 +114,13 @@ extension MxcUriExtension on Uri {
host: homeserver.host,
path: requestPath,
port: homeserver.port,
queryParameters: {
if (width != null) 'width': width.round().toString(),
if (height != null) 'height': height.round().toString(),
if (method != null) 'method': method.toString().split('.').last,
if (animated != null) 'animated': animated.toString(),
},
queryParameters: queryParameters,
);
}

@Deprecated('Use `getDownloadUri()` instead')
@Deprecated(
'Use `getDownloadUri()` instead. This legacy helper is scanner-unaware.',
)
Uri getDownloadLink(Client matrix) => isScheme('mxc')
? matrix.homeserver != null
? matrix.homeserver?.resolve(
Expand All @@ -110,7 +135,9 @@ extension MxcUriExtension on Uri {
/// `ThumbnailMethod.scale` and defaults to `ThumbnailMethod.scale`.
/// If `animated` (default false) is set to true, an animated thumbnail is requested
/// as per MSC2705. Thumbnails only animate if the media repository supports that.
@Deprecated('Use `getThumbnailUri()` instead')
@Deprecated(
'Use `getThumbnailUri()` instead. This legacy helper is scanner-unaware.',
)
Uri getThumbnail(
Client matrix, {
num? width,
Expand Down
Loading
Loading