import 'dart:convert'; import 'dart:typed_data'; import 'package:http/http.dart' as http; import 'package:http_parser/http_parser.dart'; import 'package:logging/logging.dart'; import 'package:result_monad/result_monad.dart'; import 'globals.dart'; import 'models/TimelineIdentifiers.dart'; import 'models/connection.dart'; import 'models/credentials.dart'; import 'models/exec_error.dart'; import 'models/gallery_data.dart'; import 'models/group_data.dart'; import 'models/image_entry.dart'; import 'models/media_attachment_uploads/image_types_enum.dart'; import 'models/timeline_entry.dart'; import 'models/user_notification.dart'; import 'serializers/friendica/connection_friendica_extensions.dart'; import 'serializers/friendica/gallery_data_friendica_extensions.dart'; import 'serializers/friendica/image_entry_friendica_extensions.dart'; import 'serializers/mastodon/connection_mastodon_extensions.dart'; import 'serializers/mastodon/group_data_mastodon_extensions.dart'; import 'serializers/mastodon/notification_mastodon_extension.dart'; import 'serializers/mastodon/timeline_entry_mastodon_extensions.dart'; import 'services/auth_service.dart'; class FriendicaClient { static final _logger = Logger('$FriendicaClient'); final Credentials _credentials; late final String _authHeader; String get serverName => _credentials.serverName; Credentials get credentials => _credentials; FriendicaClient({required Credentials credentials}) : _credentials = credentials { final authenticationString = "${_credentials.username}:${_credentials.password}"; final encodedAuthString = base64Encode(utf8.encode(authenticationString)); _authHeader = "Basic $encodedAuthString"; } FutureResult, ExecError> getNotifications() async { final url = 'https://$serverName/api/v1/notifications?include_all=true&limit=200'; final request = Uri.parse(url); _logger.finest(() => 'Getting new notifications'); return (await _getApiListRequest(request).andThenSuccessAsync( (notificationsJson) async => notificationsJson .map((json) => NotificationMastodonExtension.fromJson(json)) .toList())) .mapError((error) { if (error is ExecError) { return error; } return ExecError(type: ErrorType.localError, message: error.toString()); }); } FutureResult clearNotifications() async { final url = 'https://$serverName/api/v1/notifications/clear'; final request = Uri.parse(url); _logger.finest(() => 'Clearing unread notification'); final response = await _postUrl(request, {}); return response.mapValue((value) => true); } FutureResult clearNotification( UserNotification notification) async { final url = 'https://$serverName/api/v1/notifications/${notification.id}/dismiss'; final request = Uri.parse(url); _logger.finest(() => 'Clearing unread notification for $notification'); final response = await _postUrl(request, {}); return response.mapValue((value) => true); } FutureResult, ExecError> getGalleryData() async { _logger.finest(() => 'Getting gallery data'); final url = 'https://$serverName/api/friendica/photoalbums'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (albumsJson) async => albumsJson .map((json) => GalleryDataFriendicaExtensions.fromJson(json)) .toList())) .mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult, ExecError> getGalleryImages( String galleryName) async { _logger.finest(() => 'Getting gallery data'); final url = 'https://$serverName/api/friendica/photoalbum?album=$galleryName'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (imagesJson) async => imagesJson .map((json) => ImageEntryFriendicaExtension.fromJson(json)) .toList())) .mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult, ExecError> getGroups() async { _logger.finest(() => 'Getting group (Mastodon List) data'); final url = 'https://$serverName/api/v1/lists'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (listsJson) async => listsJson .map((json) => GroupDataMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult addConnectionToGroup( GroupData group, Connection connection, ) async { _logger.finest(() => 'Adding connection to group'); final url = 'https://$serverName/api/v1/lists/${group.id}/accounts'; final request = Uri.parse(url); final requestData = { 'account_ids': [connection.id] }; return (await _postUrl(request, requestData)).mapValue((_) => true); } FutureResult removeConnectionFromGroup( GroupData group, Connection connection, ) async { _logger.finest(() => 'Adding connection to group'); final url = 'https://$serverName/api/v1/lists/${group.id}/accounts'; final request = Uri.parse(url); final requestData = { 'account_ids': [connection.id] }; return (await _deleteUrl(request, requestData)).mapValue((_) => true); } FutureResult, ExecError> getMyFollowing( {int sinceId = -1, int maxId = -1, int limit = 50}) async { _logger.finest(() => 'Getting following data since $sinceId, maxId $maxId, limit $limit'); final myId = getIt().currentId; final paging = _buildPagingData(sinceId: sinceId, maxId: maxId, limit: limit); final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; return (await _getApiListRequest(Uri.parse('$baseUrl/following&$paging')) .andThenSuccessAsync((listJson) async => listJson .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult, ExecError> getMyFollowers( {int sinceId = -1, int maxId = -1, int limit = 50}) async { _logger.finest(() => 'Getting followers data since $sinceId, maxId $maxId, limit $limit'); final myId = getIt().currentId; final paging = _buildPagingData(sinceId: sinceId, maxId: maxId, limit: limit); final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; return (await _getApiListRequest(Uri.parse('$baseUrl/followers&$paging')) .andThenSuccessAsync((listJson) async => listJson .map((json) => ConnectionMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult getConnectionWithStatus( Connection connection) async { _logger.finest(() => 'Getting group (Mastodon List) data'); final myId = getIt().currentId; final id = int.parse(connection.id); final paging = '?min_id=${id - 1}&max_id=${id + 1}'; final baseUrl = 'https://$serverName/api/v1/accounts/$myId'; final following = await _getApiListRequest(Uri.parse('$baseUrl/following$paging')).fold( onSuccess: (followings) => followings.isNotEmpty, onError: (error) { _logger.severe('Error getting following list: $error'); return false; }); final follower = await _getApiListRequest(Uri.parse('$baseUrl/followers$paging')).fold( onSuccess: (followings) => followings.isNotEmpty, onError: (error) { _logger.severe('Error getting follower list: $error'); return false; }); var status = ConnectionStatus.none; if (following && follower) { status = ConnectionStatus.mutual; } else if (following) { status = ConnectionStatus.youFollowThem; } else if (follower) { status = ConnectionStatus.theyFollowYou; } return Result.ok(connection.copy(status: status)); } FutureResult, ExecError> getMemberGroupsForConnection( String connectionId) async { _logger.finest(() => 'Getting groups (Mastodon Lists) containing connection: $connectionId'); final url = 'https://$serverName/api/v1/accounts/$connectionId/lists'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (listsJson) async => listsJson .map((json) => GroupDataMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } FutureResult, ExecError> getUserTimeline( {String userId = '', int page = 1, int count = 10}) async { _logger.finest(() => 'Getting user timeline for $userId'); final baseUrl = 'https://$serverName/api/statuses/user_timeline?'; final pagingData = 'count=$count&page=$page'; final url = userId.isEmpty ? '$baseUrl$pagingData' : '${baseUrl}screen_name=$userId$pagingData'; final request = Uri.parse(url); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .mapError((error) => error as ExecError); } FutureResult, ExecError> getTimeline( {required TimelineIdentifiers type, int sinceId = 0, int maxId = 0, int limit = 20}) async { final String timelinePath = _typeToTimelinePath(type); final String timelineQPs = _typeToTimelineQueryParameters(type); final baseUrl = 'https://$serverName/api/v1/$timelinePath'; final pagingData = _buildPagingData(sinceId: sinceId, maxId: maxId, limit: limit); final url = '$baseUrl?exclude_replies=true&$pagingData&$timelineQPs'; final request = Uri.parse(url); _logger.finest(() => 'Getting ${type.toHumanKey()} limit $limit sinceId: $sinceId maxId: $maxId : $url'); return (await _getApiListRequest(request).andThenSuccessAsync( (postsJson) async => postsJson .map((json) => TimelineEntryMastodonExtensions.fromJson(json)) .toList())) .execErrorCast(); } FutureResult getFileBytes(Uri url) async { _logger.finest('GET: $url'); try { final response = await http.get( url, headers: { 'Authorization': _authHeader, }, ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(response.bodyBytes); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult, ExecError> getPostOrComment(String id, {bool fullContext = false}) async { return (await runCatchingAsync(() async { final baseUrl = 'https://$serverName/api/v1/statuses/$id'; final url = fullContext ? '$baseUrl/context' : baseUrl; final request = Uri.parse('$url?limit=1000'); _logger.finest(() => 'Getting entry for status $id, full context? $fullContext : $url'); return (await _getApiRequest(request).andThenSuccessAsync((json) async { if (fullContext) { final ancestors = json['ancestors'] as List; final descendants = json['descendants'] as List; final items = [ ...ancestors .map((a) => TimelineEntryMastodonExtensions.fromJson(a)), ...descendants .map((d) => TimelineEntryMastodonExtensions.fromJson(d)) ]; return items; } else { return [TimelineEntryMastodonExtensions.fromJson(json)]; } })); })) .mapError((error) => ExecError( type: ErrorType.parsingError, message: error.toString(), )); } FutureResult deleteEntryById(String id) async { _logger.finest(() => 'Deleting post/comment $id'); final url = 'https://$serverName/api/v1/statuses/$id'; final request = Uri.parse(url); return (await _deleteUrl(request, {})).mapValue((_) => true); } FutureResult createNewStatus({ required String text, String spoilerText = '', String inReplyToId = '', List mediaIds = const [], }) async { _logger.finest(() => 'Creating status ${inReplyToId.isNotEmpty ? "In Reply to: " : ""} $inReplyToId, with media: $mediaIds'); final url = Uri.parse('https://$serverName/api/v1/statuses'); final body = { 'status': text, 'visibility': 'public', if (spoilerText.isNotEmpty) 'spoiler_text': spoilerText, if (inReplyToId.isNotEmpty) 'in_reply_to_id': inReplyToId, if (mediaIds.isNotEmpty) 'media_ids': mediaIds, }; final result = await _postUrl(url, body); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult resharePost(String id) async { _logger.finest(() => 'Reshare post $id'); final url = Uri.parse('https://$serverName/api/v1/statuses/$id/reblog'); final result = await _postUrl(url, {}); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult unResharePost(String id) async { _logger.finest(() => 'Reshare post $id'); final url = Uri.parse('https://$serverName/api/v1/statuses/$id/unreblog'); final result = await _postUrl(url, {}); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult changeFavoriteStatus( String id, bool status) async { final action = status ? 'favourite' : 'unfavourite'; final url = Uri.parse('https://$serverName/api/v1/statuses/$id/$action'); final result = await _postUrl(url, {}); if (result.isFailure) { return result.errorCast(); } final responseText = result.value; return runCatching(() { final json = jsonDecode(responseText); return Result.ok(TimelineEntryMastodonExtensions.fromJson(json)); }).mapError((error) { return ExecError(type: ErrorType.parsingError, message: error.toString()); }); } FutureResult acceptFollow( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/follow_requests/$id/authorize'); final result = await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult rejectFollow( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/follow_requests/$id/reject'); final result = await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult ignoreFollow( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/follow_requests/$id/ignore'); final result = await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult followConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/follow'); final result = await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult unFollowConnection( Connection connection) async { final id = connection.id; final url = Uri.parse('https://$serverName/api/v1/accounts/$id/unfollow'); final result = await _postUrl(url, {}).andThenSuccessAsync((jsonString) async { return _updateConnectionFromFollowRequestResult(connection, jsonString); }); return result.mapError((error) => error is ExecError ? error : ExecError(type: ErrorType.localError, message: error.toString())); } FutureResult getMyProfile() async { _logger.finest(() => 'Getting logged in user profile'); final request = Uri.parse('https://$serverName/api/friendica/profile/show'); return (await _getApiRequest(request)).mapValue((json) => ConnectionFriendicaExtensions.fromJson(json['friendica_owner']) .copy(status: ConnectionStatus.you, network: 'friendica')); } FutureResult uploadFileAsAttachment({ required List bytes, String description = '', String album = '', String fileName = '', }) async { final postUri = Uri.parse('https://$serverName/api/friendica/photo/create'); final request = http.MultipartRequest('POST', postUri); request.headers['Authorization'] = _authHeader; request.fields['desc'] = description; request.fields['album'] = album; request.files.add(await http.MultipartFile.fromBytes( 'media', filename: fileName, contentType: MediaType.parse('image/${ImageTypes.fromExtension(fileName).name}'), bytes)); final response = await request.send(); final body = utf8.decode(await response.stream.toBytes()); if (response.statusCode != 200) { return Result.error( ExecError( type: ErrorType.missingEndpoint, message: body, ), ); } final imageDataJson = jsonDecode(body); final newImageData = ImageEntryFriendicaExtension.fromJson(imageDataJson); return Result.ok(newImageData); } FutureResult _getUrl(Uri url) async { _logger.finer('GET: $url'); try { final response = await http.get( url, headers: { 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult _postUrl( Uri url, Map body) async { _logger.finest('POST: $url'); try { final response = await http.post( url, headers: { 'Cookies': 'XDEBUG_SESSION=PHPSTORM;path=/;', 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, body: jsonEncode(body), ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult _deleteUrl( Uri url, Map body) async { _logger.finest('DELETE: $url'); try { final response = await http.delete( url, headers: { 'Authorization': _authHeader, 'Content-Type': 'application/json; charset=UTF-8' }, body: jsonEncode(body), ); if (response.statusCode != 200) { return Result.error(ExecError( type: ErrorType.authentication, message: '${response.statusCode}: ${response.reasonPhrase}')); } return Result.ok(utf8.decode(response.bodyBytes)); } catch (e) { return Result.error( ExecError(type: ErrorType.localError, message: e.toString())); } } FutureResult, ExecError> _getApiListRequest(Uri url) async { return (await _getUrl(url).andThenSuccessAsync( (jsonText) async => jsonDecode(jsonText) as List)) .mapError((error) => error as ExecError); } FutureResult _getApiRequest(Uri url) async { return (await _getUrl(url) .andThenSuccessAsync((jsonText) async => jsonDecode(jsonText))) .mapError((error) => error as ExecError); } String _typeToTimelinePath(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: return 'timelines/home'; case TimelineType.global: return 'timelines/public'; case TimelineType.local: return 'timelines/public'; case TimelineType.group: return 'timelines/list/${type.auxData}'; case TimelineType.profile: return '/accounts/${type.auxData}/statuses'; case TimelineType.self: final myId = getIt().currentId; return '/accounts/$myId/statuses'; } } String _typeToTimelineQueryParameters(TimelineIdentifiers type) { switch (type.timeline) { case TimelineType.home: case TimelineType.global: case TimelineType.profile: case TimelineType.group: case TimelineType.self: return ''; case TimelineType.local: return 'local=true'; } } String _buildPagingData( {required int sinceId, required int maxId, required int limit}) { var pagingData = 'limit=$limit'; if (maxId > 0) { pagingData = '$pagingData&max_id=$maxId'; } if (sinceId > 0) { pagingData = '&since_id=$sinceId'; } return pagingData; } Connection _updateConnectionFromFollowRequestResult( Connection connection, String jsonString) { final json = jsonDecode(jsonString) as Map; final theyFollowYou = json['followed_by'] ?? 'false'; final youFollowThem = json['following'] ?? 'false'; late final ConnectionStatus newStatus; if (theyFollowYou && youFollowThem) { newStatus = ConnectionStatus.mutual; } else if (theyFollowYou) { newStatus = ConnectionStatus.theyFollowYou; } else if (youFollowThem) { newStatus = ConnectionStatus.youFollowThem; } else { newStatus = ConnectionStatus.none; } return connection.copy(status: newStatus); } }