diff --git a/.gitignore b/.gitignore index c5edab1..f4c3816 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,12 @@ .envrc .direnv/ +# Files and directories created by pub. +.dart_tool/ +.packages + +# Conventional directory for build outputs. +build/ + +# Omit committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/moxxmpp/CHANGELOG.md b/moxxmpp/CHANGELOG.md new file mode 100644 index 0000000..8a83953 --- /dev/null +++ b/moxxmpp/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial version copied over from Moxxyv2 diff --git a/moxxmpp/README.md b/moxxmpp/README.md new file mode 100644 index 0000000..a16e658 --- /dev/null +++ b/moxxmpp/README.md @@ -0,0 +1,7 @@ +# moxxmpp + +A pure-Dart XMPP library written for Moxxy. + +## License + +See `../LICENSE`. diff --git a/moxxmpp/analysis_options.yaml b/moxxmpp/analysis_options.yaml new file mode 100644 index 0000000..f434b72 --- /dev/null +++ b/moxxmpp/analysis_options.yaml @@ -0,0 +1,14 @@ +include: package:very_good_analysis/analysis_options.yaml +linter: + rules: + public_member_api_docs: false + lines_longer_than_80_chars: false + use_setters_to_change_properties: false + avoid_positional_boolean_parameters: false + avoid_bool_literals_in_conditional_expressions: false + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "test/" diff --git a/moxxmpp/example/moxxmpp_example.dart b/moxxmpp/example/moxxmpp_example.dart new file mode 100644 index 0000000..64922a6 --- /dev/null +++ b/moxxmpp/example/moxxmpp_example.dart @@ -0,0 +1,6 @@ +import 'package:moxxmpp/moxxmpp.dart'; + +void main() { + var awesome = Awesome(); + print('awesome: ${awesome.isAwesome}'); +} diff --git a/moxxmpp/lib/moxxmpp.dart b/moxxmpp/lib/moxxmpp.dart new file mode 100644 index 0000000..cd47ded --- /dev/null +++ b/moxxmpp/lib/moxxmpp.dart @@ -0,0 +1,76 @@ +library moxxmpp; + +export 'package:moxxmpp/src/connection.dart'; +export 'package:moxxmpp/src/events.dart'; +export 'package:moxxmpp/src/iq.dart'; +export 'package:moxxmpp/src/jid.dart'; +export 'package:moxxmpp/src/managers/attributes.dart'; +export 'package:moxxmpp/src/managers/base.dart'; +export 'package:moxxmpp/src/managers/data.dart'; +export 'package:moxxmpp/src/managers/handlers.dart'; +export 'package:moxxmpp/src/managers/namespaces.dart'; +export 'package:moxxmpp/src/managers/priorities.dart'; +export 'package:moxxmpp/src/message.dart'; +export 'package:moxxmpp/src/namespaces.dart'; +export 'package:moxxmpp/src/negotiators/manager.dart'; +export 'package:moxxmpp/src/negotiators/namespaces.dart'; +export 'package:moxxmpp/src/negotiators/negotiator.dart'; +export 'package:moxxmpp/src/negotiators/resource_binding.dart'; +export 'package:moxxmpp/src/negotiators/sasl/negotiator.dart'; +export 'package:moxxmpp/src/negotiators/sasl/plain.dart'; +export 'package:moxxmpp/src/negotiators/sasl/scram.dart'; +export 'package:moxxmpp/src/negotiators/starttls.dart'; +export 'package:moxxmpp/src/ping.dart'; +export 'package:moxxmpp/src/presence.dart'; +export 'package:moxxmpp/src/reconnect.dart'; +export 'package:moxxmpp/src/rfcs/rfc_2782.dart'; +export 'package:moxxmpp/src/rfcs/rfc_4790.dart'; +export 'package:moxxmpp/src/roster.dart'; +export 'package:moxxmpp/src/settings.dart'; +export 'package:moxxmpp/src/socket.dart'; +export 'package:moxxmpp/src/stanza.dart'; +export 'package:moxxmpp/src/stringxml.dart'; +export 'package:moxxmpp/src/types/error.dart'; +export 'package:moxxmpp/src/types/resultv2.dart'; +export 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart'; +export 'package:moxxmpp/src/xeps/staging/file_upload_notification.dart'; +export 'package:moxxmpp/src/xeps/xep_0004.dart'; +export 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +export 'package:moxxmpp/src/xeps/xep_0030/helpers.dart'; +export 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +export 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +export 'package:moxxmpp/src/xeps/xep_0054.dart'; +export 'package:moxxmpp/src/xeps/xep_0060/errors.dart'; +export 'package:moxxmpp/src/xeps/xep_0060/helpers.dart'; +export 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart'; +export 'package:moxxmpp/src/xeps/xep_0066.dart'; +export 'package:moxxmpp/src/xeps/xep_0084.dart'; +export 'package:moxxmpp/src/xeps/xep_0085.dart'; +export 'package:moxxmpp/src/xeps/xep_0115.dart'; +export 'package:moxxmpp/src/xeps/xep_0184.dart'; +export 'package:moxxmpp/src/xeps/xep_0191.dart'; +export 'package:moxxmpp/src/xeps/xep_0198/negotiator.dart'; +export 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart'; +export 'package:moxxmpp/src/xeps/xep_0198/state.dart'; +export 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart'; +export 'package:moxxmpp/src/xeps/xep_0203.dart'; +export 'package:moxxmpp/src/xeps/xep_0280.dart'; +export 'package:moxxmpp/src/xeps/xep_0297.dart'; +export 'package:moxxmpp/src/xeps/xep_0300.dart'; +export 'package:moxxmpp/src/xeps/xep_0333.dart'; +export 'package:moxxmpp/src/xeps/xep_0334.dart'; +export 'package:moxxmpp/src/xeps/xep_0352.dart'; +export 'package:moxxmpp/src/xeps/xep_0359.dart'; +export 'package:moxxmpp/src/xeps/xep_0363.dart'; +export 'package:moxxmpp/src/xeps/xep_0380.dart'; +export 'package:moxxmpp/src/xeps/xep_0384/crypto.dart'; +export 'package:moxxmpp/src/xeps/xep_0384/errors.dart'; +export 'package:moxxmpp/src/xeps/xep_0384/helpers.dart'; +export 'package:moxxmpp/src/xeps/xep_0384/types.dart'; +export 'package:moxxmpp/src/xeps/xep_0384/xep_0384.dart'; +export 'package:moxxmpp/src/xeps/xep_0385.dart'; +export 'package:moxxmpp/src/xeps/xep_0414.dart'; +export 'package:moxxmpp/src/xeps/xep_0446.dart'; +export 'package:moxxmpp/src/xeps/xep_0447.dart'; +export 'package:moxxmpp/src/xeps/xep_0448.dart'; +export 'package:moxxmpp/src/xeps/xep_0461.dart'; diff --git a/moxxmpp/lib/src/buffer.dart b/moxxmpp/lib/src/buffer.dart new file mode 100644 index 0000000..0379cc6 --- /dev/null +++ b/moxxmpp/lib/src/buffer.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:moxxmpp/src/stringxml.dart'; + +import 'package:xml/xml.dart'; +import 'package:xml/xml_events.dart'; + +class XmlStreamBuffer extends StreamTransformerBase { + + XmlStreamBuffer() : _streamController = StreamController(), _decoder = const XmlNodeDecoder(); + final StreamController _streamController; + final XmlNodeDecoder _decoder; + + @override + Stream bind(Stream stream) { + stream.toXmlEvents().selectSubtreeEvents((event) { + return event.qualifiedName != 'stream:stream'; + }).transform(_decoder).listen((nodes) { + for (final node in nodes) { + if (node.nodeType == XmlNodeType.ELEMENT) { + _streamController.add(XMLNode.fromXmlElement(node as XmlElement)); + } + } + }); + return _streamController.stream; + } +} diff --git a/moxxmpp/lib/src/connection.dart b/moxxmpp/lib/src/connection.dart new file mode 100644 index 0000000..032b46b --- /dev/null +++ b/moxxmpp/lib/src/connection.dart @@ -0,0 +1,1033 @@ +import 'dart:async'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:moxxmpp/src/buffer.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/iq.dart'; +import 'package:moxxmpp/src/managers/attributes.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/presence.dart'; +import 'package:moxxmpp/src/reconnect.dart'; +import 'package:moxxmpp/src/roster.dart'; +import 'package:moxxmpp/src/routing.dart'; +import 'package:moxxmpp/src/settings.dart'; +import 'package:moxxmpp/src/socket.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/negotiator.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart'; +import 'package:moxxmpp/src/xeps/xep_0352.dart'; +import 'package:synchronized/synchronized.dart'; +import 'package:uuid/uuid.dart'; + +enum XmppConnectionState { + notConnected, + connecting, + connected, + error +} + +enum StanzaFromType { + // Add the full JID to the stanza as the from attribute + full, + // Add the bare JID to the stanza as the from attribute + bare, + // Add no JID as the from attribute + none +} + +class StreamHeaderNonza extends XMLNode { + StreamHeaderNonza(String serverDomain) : super( + tag: 'stream:stream', + attributes: { + 'xmlns': stanzaXmlns, + 'version': '1.0', + 'xmlns:stream': streamXmlns, + 'to': serverDomain, + 'xml:lang': 'en', + }, + closeTag: false, + ); +} + +class XmppConnectionResult { + const XmppConnectionResult( + this.success, + { + this.reason, + } + ); + + final bool success; + // NOTE: [reason] is not human-readable, but the type of SASL error. + // See sasl/errors.dart + final String? reason; +} + +class XmppConnection { + /// [socket] is for debugging purposes. + /// [connectionPingDuration] is the duration after which a ping will be sent to keep + /// the connection open. Defaults to 15 minutes. + XmppConnection( + ReconnectionPolicy reconnectionPolicy, + this._socket, + { + this.connectionPingDuration = const Duration(minutes: 3), + this.connectingTimeout = const Duration(minutes: 2), + } + ) : + _connectionState = XmppConnectionState.notConnected, + _routingState = RoutingState.preConnection, + _eventStreamController = StreamController.broadcast(), + _resource = '', + _streamBuffer = XmlStreamBuffer(), + _uuid = const Uuid(), + _awaitingResponse = {}, + _awaitingResponseLock = Lock(), + _xmppManagers = {}, + _incomingStanzaHandlers = List.empty(growable: true), + _outgoingPreStanzaHandlers = List.empty(growable: true), + _outgoingPostStanzaHandlers = List.empty(growable: true), + _reconnectionPolicy = reconnectionPolicy, + _featureNegotiators = {}, + _streamFeatures = List.empty(growable: true), + _negotiationLock = Lock(), + _isAuthenticated = false, + _log = Logger('XmppConnection') { + // Allow the reconnection policy to perform reconnections by itself + _reconnectionPolicy.register( + _attemptReconnection, + _onNetworkConnectionLost, + ); + + _socketStream = _socket.getDataStream(); + // TODO(Unknown): Handle on done + _socketStream.transform(_streamBuffer).forEach(handleXmlStream); + _socket.getEventStream().listen(_handleSocketEvent); + } + + + /// Connection properties + /// + /// The state that the connection currently is in + XmppConnectionState _connectionState; + /// The socket that we are using for the connection and its data stream + final BaseSocketWrapper _socket; + late final Stream _socketStream; + /// Account settings + late ConnectionSettings _connectionSettings; + /// A policy on how to reconnect + final ReconnectionPolicy _reconnectionPolicy; + /// A list of stanzas we are tracking with its corresponding critical section + final Map> _awaitingResponse; + final Lock _awaitingResponseLock; + + /// Helpers + /// + final List _incomingStanzaHandlers; + final List _outgoingPreStanzaHandlers; + final List _outgoingPostStanzaHandlers; + final StreamController _eventStreamController; + final Map _xmppManagers; + + /// Stream properties + /// + /// Disco info we got after binding a resource (xmlns) + final List _serverFeatures = List.empty(growable: true); + /// The buffer object to keep split up stanzas together + final XmlStreamBuffer _streamBuffer; + /// UUID object to generate stanza and origin IDs + final Uuid _uuid; + /// The time between sending a ping to keep the connection open + // TODO(Unknown): Only start the timer if we did not send a stanza after n seconds + final Duration connectionPingDuration; + /// The time that we may spent in the "connecting" state + final Duration connectingTimeout; + /// The current state of the connection handling state machine. + RoutingState _routingState; + /// The currently bound resource or '' if none has been bound yet. + String _resource; + /// True if we are authenticated. False if not. + bool _isAuthenticated; + /// Timer for the keep-alive ping. + Timer? _connectionPingTimer; + /// Timer for the connecting timeout + Timer? _connectingTimeoutTimer; + /// Completers for certain actions + // ignore: use_late_for_private_fields_and_variables + Completer? _connectionCompleter; + + /// Negotiators + final Map _featureNegotiators; + XmppFeatureNegotiatorBase? _currentNegotiator; + final List _streamFeatures; + /// Prevent data from being passed to _currentNegotiator.negotiator while the negotiator + /// is still running. + final Lock _negotiationLock; + + /// Misc + final Logger _log; + + ReconnectionPolicy get reconnectionPolicy => _reconnectionPolicy; + + List get serverFeatures => _serverFeatures; + + bool get isAuthenticated => _isAuthenticated; + + /// Return the registered feature negotiator that has id [id]. Returns null if + /// none can be found. + T? getNegotiatorById(String id) => _featureNegotiators[id] as T?; + + /// Registers an [XmppManagerBase] sub-class as a manager on this connection. + /// [sortHandlers] should NOT be touched. It specified if the handler priorities + /// should be set up. The only time this should be false is when called via + /// [registerManagers]. + void registerManager(XmppManagerBase manager, { bool sortHandlers = true }) { + _log.finest('Registering ${manager.getId()}'); + manager.register( + XmppManagerAttributes( + sendStanza: sendStanza, + sendNonza: sendRawXML, + sendEvent: _sendEvent, + getConnectionSettings: () => _connectionSettings, + getManagerById: getManagerById, + isFeatureSupported: _serverFeatures.contains, + getFullJID: () => _connectionSettings.jid.withResource(_resource), + getSocket: () => _socket, + getConnection: () => this, + getNegotiatorById: getNegotiatorById, + ), + ); + + final id = manager.getId(); + _xmppManagers[id] = manager; + + if (id == discoManager) { + // NOTE: It is intentional that we do not exclude the [DiscoManager] from this + // loop. It may also register features. + for (final registeredManager in _xmppManagers.values) { + (manager as DiscoManager).addDiscoFeatures(registeredManager.getDiscoFeatures()); + } + } else if (_xmppManagers.containsKey(discoManager)) { + (_xmppManagers[discoManager]! as DiscoManager).addDiscoFeatures(manager.getDiscoFeatures()); + } + + _incomingStanzaHandlers.addAll(manager.getIncomingStanzaHandlers()); + _outgoingPreStanzaHandlers.addAll(manager.getOutgoingPreStanzaHandlers()); + _outgoingPostStanzaHandlers.addAll(manager.getOutgoingPostStanzaHandlers()); + + if (sortHandlers) { + _incomingStanzaHandlers.sort(stanzaHandlerSortComparator); + _outgoingPreStanzaHandlers.sort(stanzaHandlerSortComparator); + _outgoingPostStanzaHandlers.sort(stanzaHandlerSortComparator); + } + } + + /// Like [registerManager], but for a list of managers. + void registerManagers(List managers) { + for (final manager in managers) { + registerManager(manager, sortHandlers: false); + } + + // Sort them + _incomingStanzaHandlers.sort(stanzaHandlerSortComparator); + _outgoingPreStanzaHandlers.sort(stanzaHandlerSortComparator); + _outgoingPostStanzaHandlers.sort(stanzaHandlerSortComparator); + } + + /// Register a list of negotiator with the connection. + void registerFeatureNegotiators(List negotiators) { + for (final negotiator in negotiators) { + _log.finest('Registering ${negotiator.id}'); + negotiator.register( + NegotiatorAttributes( + sendRawXML, + () => _connectionSettings, + _sendEvent, + getNegotiatorById, + getManagerById, + () => _connectionSettings.jid.withResource(_resource), + () => _socket, + () => _isAuthenticated, + ), + ); + _featureNegotiators[negotiator.id] = negotiator; + } + + _log.finest('Negotiators registered'); + } + + /// Reset all registered negotiators. + void _resetNegotiators() { + for (final negotiator in _featureNegotiators.values) { + negotiator.reset(); + } + + // Prevent leaking the last active negotiator + _currentNegotiator = null; + } + + /// Generate an Id suitable for an origin-id or stanza id + String generateId() { + return _uuid.v4(); + } + + /// Returns the Manager with id [id] or null if such a manager is not registered. + T? getManagerById(String id) => _xmppManagers[id] as T?; + + /// A [PresenceManager] is required, so have a wrapper for getting it. + /// Returns the registered [PresenceManager]. + PresenceManager getPresenceManager() { + assert(_xmppManagers.containsKey(presenceManager), 'A PresenceManager is mandatory'); + + return getManagerById(presenceManager)!; + } + + /// A [DiscoManager] is required so, have a wrapper for getting it. + /// Returns the registered [DiscoManager]. + DiscoManager getDiscoManager() { + assert(_xmppManagers.containsKey(discoManager), 'A DiscoManager is mandatory'); + + return getManagerById(discoManager)!; + } + + /// A [RosterManager] is required, so have a wrapper for getting it. + /// Returns the registered [RosterManager]. + RosterManager getRosterManager() { + assert(_xmppManagers.containsKey(rosterManager), 'A RosterManager is mandatory'); + + return getManagerById(rosterManager)!; + } + + /// Returns the registered [StreamManagementManager], if one is registered. + StreamManagementManager? getStreamManagementManager() { + return getManagerById(smManager); + } + + /// Returns the registered [CSIManager], if one is registered. + CSIManager? getCSIManager() { + return getManagerById(csiManager); + } + + /// Set the connection settings of this connection. + void setConnectionSettings(ConnectionSettings settings) { + _connectionSettings = settings; + } + + /// Returns the connection settings of this connection. + ConnectionSettings getConnectionSettings() { + return _connectionSettings; + } + + /// Attempts to reconnect to the server by following an exponential backoff. + Future _attemptReconnection() async { + _log.finest('_attemptReconnection: Setting state to notConnected'); + await _setConnectionState(XmppConnectionState.notConnected); + _log.finest('_attemptReconnection: Done'); + + // Prevent the reconnection triggering another reconnection + _socket.close(); + _log.finest('_attemptReconnection: Socket closed'); + + // Connect again + // ignore: cascade_invocations + _log.finest('Calling connect() from _attemptReconnection'); + await connect(); + } + + /// Called when a stream ending error has occurred + Future handleError(Object? error) async { + if (error != null) { + _log.severe('handleError: $error'); + } else { + _log.severe('handleError: Called with null'); + } + + // TODO(Unknown): This may be too harsh for every error + await _setConnectionState(XmppConnectionState.notConnected); + await _reconnectionPolicy.onFailure(); + } + + /// Called whenever the socket creates an event + Future _handleSocketEvent(XmppSocketEvent event) async { + if (event is XmppSocketErrorEvent) { + await handleError(event.error); + } else if (event is XmppSocketClosureEvent) { + _log.fine('Received XmppSocketClosureEvent. Reconnecting...'); + await _reconnectionPolicy.onFailure(); + } + } + + /// NOTE: For debugging purposes only + /// Returns the internal state of the state machine + RoutingState getRoutingState() { + return _routingState; + } + + /// Returns the ConnectionState of the connection + Future getConnectionState() async { + return _connectionState; + } + + /// Sends an [XMLNode] without any further processing to the server. + void sendRawXML(XMLNode node, { String? redact }) { + final string = node.toXml(); + _log.finest('==> $string'); + _socket.write(string, redact: redact); + } + + /// Sends [raw] to the server. + void sendRawString(String raw) { + _socket.write(raw); + } + + /// Returns true if we can send data through the socket. + Future _canSendData() async { + return [ + XmppConnectionState.connected, + XmppConnectionState.connecting + ].contains(await getConnectionState()); + } + + /// Sends a [stanza] to the server. If stream management is enabled, then keeping track + /// of the stanza is taken care of. Returns a Future that resolves when we receive a + /// response to the stanza. + /// + /// If addFrom is true, then a 'from' attribute will be added to the stanza if + /// [stanza] has none. + /// If addId is true, then an 'id' attribute will be added to the stanza if [stanza] has + /// none. + // TODO(Unknown): if addId = false, the function crashes. + Future sendStanza(Stanza stanza, { StanzaFromType addFrom = StanzaFromType.full, bool addId = true, bool awaitable = true, bool encrypted = false }) async { + var stanza_ = stanza; + + // Add extra data in case it was not set + if (addId && (stanza_.id == null || stanza_.id == '')) { + stanza_ = stanza.copyWith(id: generateId()); + } + if (addFrom != StanzaFromType.none && (stanza_.from == null || stanza_.from == '')) { + switch (addFrom) { + case StanzaFromType.full: { + stanza_ = stanza_.copyWith(from: _connectionSettings.jid.withResource(_resource).toString()); + } + break; + case StanzaFromType.bare: { + stanza_ = stanza_.copyWith(from: _connectionSettings.jid.toBare().toString()); + } + break; + case StanzaFromType.none: break; + } + } + + final id = stanza_.id!; + + _log.fine('Running pre stanza handlers..'); + final data = await _runOutgoingPreStanzaHandlers( + stanza_, + initial: StanzaHandlerData( + false, + false, + null, + stanza_, + encrypted: encrypted, + ), + ); + _log.fine('Done'); + + if (data.cancel) { + _log.fine('A stanza handler indicated that it wants to cancel sending.'); + await _sendEvent(StanzaSendingCancelledEvent(data)); + return Stanza( + tag: data.stanza.tag, + to: data.stanza.from, + from: data.stanza.to, + attributes: { + 'type': 'error', + ...data.stanza.id != null ? { + 'id': data.stanza.id!, + } : {}, + }, + ); + } + + final prefix = data.encrypted ? + '(Encrypted) ' : + ''; + _log.finest('==> $prefix${stanza_.toXml()}'); + + final stanzaString = data.stanza.toXml(); + + // ignore: cascade_invocations + _log.fine('Attempting to acquire lock for $id...'); + // TODO(PapaTutuWawa): Handle this much more graceful + var future = Future.value(XMLNode(tag: 'not-used')); + await _awaitingResponseLock.synchronized(() async { + _log.fine('Lock acquired for $id'); + if (awaitable) { + _awaitingResponse[id] = Completer(); + } + + // This uses the StreamManager to behave like a send queue + if (await _canSendData()) { + _socket.write(stanzaString); + + // Try to ack every stanza + // NOTE: Here we have send an Ack request nonza. This is now done by StreamManagementManager when receiving the StanzaSentEvent + } else { + _log.fine('_canSendData() returned false.'); + } + + _log.fine('Running post stanza handlers..'); + await _runOutgoingPostStanzaHandlers( + stanza_, + initial: StanzaHandlerData( + false, + false, + null, + stanza_, + ), + ); + _log.fine('Done'); + + if (awaitable) { + future = _awaitingResponse[id]!.future; + } + + _log.fine('Releasing lock for $id'); + }); + + return future; + } + + /// Called when we timeout during connecting + Future _onConnectingTimeout() async { + _log.severe('Connection stuck in "connecting". Causing a reconnection...'); + await handleError('Connecting timeout'); + } + + void _destroyConnectingTimer() { + if (_connectingTimeoutTimer != null) { + _connectingTimeoutTimer!.cancel(); + _connectingTimeoutTimer = null; + + _log.finest('Destroying connecting timeout timer...'); + } + } + + /// Sets the connection state to [state] and triggers an event of type + /// [ConnectionStateChangedEvent]. + Future _setConnectionState(XmppConnectionState state) async { + // Ignore changes that are not really changes. + if (state == _connectionState) return; + + _log.finest('Updating _connectionState from $_connectionState to $state'); + final oldState = _connectionState; + _connectionState = state; + + final sm = getNegotiatorById(streamManagementNegotiator); + await _sendEvent( + ConnectionStateChangedEvent( + state, + oldState, + sm?.isResumed ?? false, + ), + ); + + if (state == XmppConnectionState.connected) { + _log.finest('Starting _pingConnectionTimer'); + _connectionPingTimer = Timer.periodic(connectionPingDuration, _pingConnectionOpen); + + // We are connected, so the timer can stop. + _destroyConnectingTimer(); + } else if (state == XmppConnectionState.connecting) { + // Make sure it is not running... + _destroyConnectingTimer(); + + // ...and start it. + _log.finest('Starting connecting timeout timer...'); + _connectingTimeoutTimer = Timer(connectingTimeout, _onConnectingTimeout); + } else { + // Just make sure the connecting timeout timer is not running + _destroyConnectingTimer(); + + // The ping timer makes no sense if we are not connected + if (_connectionPingTimer != null) { + _log.finest('Destroying _pingConnectionTimer'); + _connectionPingTimer!.cancel(); + _connectionPingTimer = null; + } + } + } + + /// Sets the routing state and logs the change + void _updateRoutingState(RoutingState state) { + _log.finest('Updating _routingState from $_routingState to $state'); + _routingState = state; + } + + /// Sets the resource of the connection + void setResource(String resource) { + _log.finest('Updating _resource to $resource'); + _resource = resource; + } + + /// Returns the connection's events as a stream. + Stream asBroadcastStream() { + return _eventStreamController.stream.asBroadcastStream(); + } + + /// Timer callback to prevent the connection from timing out. + Future _pingConnectionOpen(Timer timer) async { + // Follow the recommendation of XEP-0198 and just request an ack. If SM is not enabled, + // send a whitespace ping + _log.finest('_pingConnectionTimer: Callback called.'); + + if (_connectionState == XmppConnectionState.connected) { + _log.finest('_pingConnectionTimer: Connected. Triggering a ping event.'); + unawaited(_sendEvent(SendPingEvent())); + } else { + _log.finest('_pingConnectionTimer: Not connected. Not triggering an event.'); + } + } + + /// Iterate over [handlers] and check if the handler matches [stanza]. If it does, + /// call its callback and end the processing if the callback returned true; continue + /// if it returned false. + Future _runStanzaHandlers(List handlers, Stanza stanza, { StanzaHandlerData? initial }) async { + var state = initial ?? StanzaHandlerData(false, false, null, stanza); + for (final handler in handlers) { + if (handler.matches(state.stanza)) { + state = await handler.callback(state.stanza, state); + if (state.done || state.cancel) return state; + } + } + + return state; + } + + Future _runIncomingStanzaHandlers(Stanza stanza) async { + return _runStanzaHandlers(_incomingStanzaHandlers, stanza); + } + + Future _runOutgoingPreStanzaHandlers(Stanza stanza, { StanzaHandlerData? initial }) async { + return _runStanzaHandlers(_outgoingPreStanzaHandlers, stanza, initial: initial); + } + + Future _runOutgoingPostStanzaHandlers(Stanza stanza, { StanzaHandlerData? initial }) async { + final data = await _runStanzaHandlers( + _outgoingPostStanzaHandlers, + stanza, + initial: initial, + ); + return data.done; + } + + /// Called whenever we receive a stanza after resource binding or stream resumption. + Future _handleStanza(XMLNode nonza) async { + // Process nonzas separately + if (!['message', 'iq', 'presence'].contains(nonza.tag)) { + _log.finest('<== ${nonza.toXml()}'); + + var nonzaHandled = false; + await Future.forEach( + _xmppManagers.values, + (XmppManagerBase manager) async { + final handled = await manager.runNonzaHandlers(nonza); + + if (!nonzaHandled && handled) nonzaHandled = true; + } + ); + + if (!nonzaHandled) { + _log.warning('Unhandled nonza received: ${nonza.toXml()}'); + } + return; + } + + final stanza = Stanza.fromXMLNode(nonza); + + // Run the incoming stanza handlers and bounce with an error if no manager handled + // it. + final incomingHandlers = await _runIncomingStanzaHandlers(stanza); + final prefix = incomingHandlers.encrypted ? + '(Encrypted) ' : + ''; + _log.finest('<== $prefix${incomingHandlers.stanza.toXml()}'); + + // See if we are waiting for this stanza + final id = stanza.attributes['id'] as String?; + var awaited = false; + await _awaitingResponseLock.synchronized(() async { + if (id != null && _awaitingResponse.containsKey(id)) { + _awaitingResponse[id]!.complete(incomingHandlers.stanza); + _awaitingResponse.remove(id); + awaited = true; + } + }); + + if (awaited) { + return; + } + + // Only bounce if the stanza has neither been awaited, nor handled. + if (!incomingHandlers.done) { + handleUnhandledStanza(this, stanza); + } + } + + /// Returns true if all mandatory features in [features] have been negotiated. + /// Otherwise returns false. + bool _isMandatoryNegotiationDone(List features) { + return features.every( + (XMLNode feature) { + return feature.firstTag('required') == null && feature.tag != 'mechanisms'; + } + ); + } + + /// Returns true if we can still negotiate. Returns false if no negotiator is + /// matching and ready. + bool _isNegotiationPossible(List features) { + return getNextNegotiator(features, log: false) != null; + } + + /// Returns the next negotiator that matches [features]. Returns null if none can be + /// picked. If [log] is true, then the list of matching negotiators will be logged. + @visibleForTesting + XmppFeatureNegotiatorBase? getNextNegotiator(List features, {bool log = true}) { + final matchingNegotiators = _featureNegotiators.values + .where( + (XmppFeatureNegotiatorBase negotiator) { + return negotiator.state == NegotiatorState.ready && negotiator.matchesFeature(features); + } + ) + .toList() + ..sort((a, b) => b.priority.compareTo(a.priority)); + + if (log) { + _log.finest('List of matching negotiators: ${matchingNegotiators.map((a) => a.id)}'); + } + + if (matchingNegotiators.isEmpty) return null; + + return matchingNegotiators.first; + } + + /// Called once all negotiations are done. Sends the initial presence, performs + /// a disco sweep among other things. + Future _onNegotiationsDone() async { + // Set the connection state + await _setConnectionState(XmppConnectionState.connected); + + // Resolve the connection completion future + _connectionCompleter?.complete(const XmppConnectionResult(true)); + _connectionCompleter = null; + + // Send out initial presence + await getPresenceManager().sendInitialPresence(); + } + + /// To be called after _currentNegotiator!.negotiate(..) has been called. Checks the + /// state of the negotiator and picks the next negotiatior, ends negotiation or + /// waits, depending on what the negotiator did. + Future _checkCurrentNegotiator() async { + if (_currentNegotiator!.state == NegotiatorState.done) { + _log.finest('Negotiator ${_currentNegotiator!.id} done'); + + if (_currentNegotiator!.sendStreamHeaderWhenDone) { + _currentNegotiator = null; + _streamFeatures.clear(); + _sendStreamHeader(); + } else { + // Track what features we still have + _streamFeatures + .removeWhere((node) { + return node.attributes['xmlns'] == _currentNegotiator!.negotiatingXmlns; + }); + _currentNegotiator = null; + + if (_isMandatoryNegotiationDone(_streamFeatures) && !_isNegotiationPossible(_streamFeatures)) { + _log.finest('Negotiations done!'); + _updateRoutingState(RoutingState.handleStanzas); + + await _onNegotiationsDone(); + } else { + _currentNegotiator = getNextNegotiator(_streamFeatures); + _log.finest('Chose ${_currentNegotiator!.id} as next negotiator'); + + final fakeStanza = XMLNode( + tag: 'stream:features', + children: _streamFeatures, + ); + await _currentNegotiator!.negotiate(fakeStanza); + await _checkCurrentNegotiator(); + } + } + } else if (_currentNegotiator!.state == NegotiatorState.retryLater) { + _log.finest('Negotiator wants to continue later. Picking new one...'); + + _currentNegotiator!.state = NegotiatorState.ready; + + if (_isMandatoryNegotiationDone(_streamFeatures) && !_isNegotiationPossible(_streamFeatures)) { + _log.finest('Negotiations done!'); + + _updateRoutingState(RoutingState.handleStanzas); + await _onNegotiationsDone(); + } else { + _log.finest('Picking new negotiator...'); + _currentNegotiator = getNextNegotiator(_streamFeatures); + _log.finest('Chose $_currentNegotiator as next negotiator'); + final fakeStanza = XMLNode( + tag: 'stream:features', + children: _streamFeatures, + ); + await _currentNegotiator!.negotiate(fakeStanza); + await _checkCurrentNegotiator(); + } + } else if (_currentNegotiator!.state == NegotiatorState.skipRest) { + _log.finest('Negotiator wants to skip the remaining negotiation... Negotiations (assumed) done!'); + + _updateRoutingState(RoutingState.handleStanzas); + await _onNegotiationsDone(); + } + } + + void _closeSocket() { + _socket.close(); + } + + /// Called whenever we receive data that has been parsed as XML. + Future handleXmlStream(XMLNode node) async { + // Check if we received a stream error + if (node.tag == 'stream:error') { + _log + ..finest('<== ${node.toXml()}') + ..severe('Received a stream error! Attempting reconnection'); + await handleError('Stream error'); + + return; + } + + switch (_routingState) { + case RoutingState.negotiating: + _log.finest('<== ${node.toXml()}'); + + // Why lock here? The problem is that if we do stream resumption, then we might + // receive "...", which will all be fed into the negotiator, + // causing (a) the negotiator to become confused and (b) the stanzas/nonzas to be + // missed. This causes the data to wait while the negotiator is running and thus + // prevent this issue. + await _negotiationLock.synchronized(() async { + if (_routingState != RoutingState.negotiating) { + unawaited(handleXmlStream(node)); + return; + } + + if (_currentNegotiator != null) { + // If we already have a negotiator, just let it do its thing + _log.finest('Negotiator currently active...'); + + await _currentNegotiator!.negotiate(node); + await _checkCurrentNegotiator(); + } else { + _streamFeatures + ..clear() + ..addAll(node.children); + + // We need to pick a new one + if (_isMandatoryNegotiationDone(node.children)) { + // Mandatory features are done but can we still negotiate more? + if (_isNegotiationPossible(node.children)) {// We can still negotiate features, so do that. + _log.finest('All required stream features done! Continuing negotiation'); + _currentNegotiator = getNextNegotiator(node.children); + _log.finest('Chose $_currentNegotiator as next negotiator'); + await _currentNegotiator!.negotiate(node); + await _checkCurrentNegotiator(); + } else { + _updateRoutingState(RoutingState.handleStanzas); + } + } else { + // There still are mandatory features + if (!_isNegotiationPossible(node.children)) { + _log.severe('Mandatory negotiations not done but continuation not possible'); + _updateRoutingState(RoutingState.error); + await _setConnectionState(XmppConnectionState.error); + + // Resolve the connection completion future + _connectionCompleter?.complete( + const XmppConnectionResult( + false, + reason: 'Could not complete connection negotiations', + ), + ); + _connectionCompleter = null; + return; + } + + _currentNegotiator = getNextNegotiator(node.children); + _log.finest('Chose $_currentNegotiator as next negotiator'); + await _currentNegotiator!.negotiate(node); + await _checkCurrentNegotiator(); + } + } + }); + break; + case RoutingState.handleStanzas: + await _handleStanza(node); + break; + case RoutingState.preConnection: + case RoutingState.error: + _log.warning('Received data while in non-receiving state'); + break; + } + } + + /// Sends an empty String over the socket. + void sendWhitespacePing() { + _socket.write(''); + } + + /// Sends an event to the connection's event stream. + Future _sendEvent(XmppEvent event) async { + _log.finest('Event: ${event.toString()}'); + + // Specific event handling + if (event is ResourceBindingSuccessEvent) { + _log.finest('Received ResourceBindingSuccessEvent. Setting _resource to ${event.resource}'); + setResource(event.resource); + + _log.finest('Resetting _serverFeatures'); + _serverFeatures.clear(); + } else if (event is AuthenticationSuccessEvent) { + _log.finest('Received AuthenticationSuccessEvent. Setting _isAuthenticated to true'); + _isAuthenticated = true; + } else if (event is AuthenticationFailedEvent) { + _log.finest('Failed authentication'); + _updateRoutingState(RoutingState.error); + await _setConnectionState(XmppConnectionState.error); + + // Resolve the connection completion future + _connectionCompleter?.complete( + XmppConnectionResult( + false, + reason: 'Authentication failed: ${event.saslError}', + ), + ); + _connectionCompleter = null; + _closeSocket(); + } + + for (final manager in _xmppManagers.values) { + await manager.onXmppEvent(event); + } + + _eventStreamController.add(event); + } + + /// Sends a stream header to the socket. + void _sendStreamHeader() { + _socket.write( + XMLNode( + tag: 'xml', + attributes: { + 'version': '1.0' + }, + closeTag: false, + isDeclaration: true, + children: [ + StreamHeaderNonza(_connectionSettings.jid.domain), + ], + ).toXml(), + ); + } + + /// To be called when we lost the network connection. + void _onNetworkConnectionLost() { + _socket.close(); + _setConnectionState(XmppConnectionState.notConnected); + } + + /// Attempt to gracefully close the session + Future disconnect() async { + _reconnectionPolicy.setShouldReconnect(false); + getPresenceManager().sendUnavailablePresence(); + _socket.prepareDisconnect(); + sendRawString(''); + await _setConnectionState(XmppConnectionState.notConnected); + _socket.close(); + + // Clear Stream Management state, if available + await getStreamManagementManager()?.resetState(); + } + + /// Make sure that all required managers are registered + void _runPreConnectionAssertions() { + assert(_xmppManagers.containsKey(presenceManager), 'A PresenceManager is mandatory'); + assert(_xmppManagers.containsKey(rosterManager), 'A RosterManager is mandatory'); + assert(_xmppManagers.containsKey(discoManager), 'A DiscoManager is mandatory'); + assert(_xmppManagers.containsKey(pingManager), 'A PingManager is mandatory'); + } + + /// Like [connect] but the Future resolves when the resource binding is either done or + /// SASL has failed. + Future connectAwaitable({ String? lastResource }) { + _runPreConnectionAssertions(); + _connectionCompleter = Completer(); + _log.finest('Calling connect() from connectAwaitable'); + connect(lastResource: lastResource); + return _connectionCompleter!.future; + } + + /// Start the connection process using the provided connection settings. + Future connect({ String? lastResource }) async { + if (_connectionState != XmppConnectionState.notConnected && _connectionState != XmppConnectionState.error) { + _log.fine('Cancelling this connection attempt as one appears to be already running.'); + return; + } + + _runPreConnectionAssertions(); + _reconnectionPolicy.setShouldReconnect(true); + + if (lastResource != null) { + setResource(lastResource); + } + + await _reconnectionPolicy.reset(); + + await _sendEvent(ConnectingEvent()); + + final smManager = getStreamManagementManager(); + String? host; + int? port; + if (smManager?.state.streamResumptionLocation != null) { + // TODO(Unknown): Maybe wrap this in a try catch? + final parsed = Uri.parse(smManager!.state.streamResumptionLocation!); + host = parsed.host; + port = parsed.port; + } + + final result = await _socket.connect( + _connectionSettings.jid.domain, + host: host, + port: port, + ); + if (!result) { + await handleError(null); + } else { + await _reconnectionPolicy.onSuccess(); + _log.fine('Preparing the internal state for a connection attempt'); + _resetNegotiators(); + await _setConnectionState(XmppConnectionState.connecting); + _updateRoutingState(RoutingState.negotiating); + _isAuthenticated = false; + _sendStreamHeader(); + } + } +} diff --git a/moxxmpp/lib/src/events.dart b/moxxmpp/lib/src/events.dart new file mode 100644 index 0000000..9fded44 --- /dev/null +++ b/moxxmpp/lib/src/events.dart @@ -0,0 +1,206 @@ +import 'package:moxxmpp/src/connection.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart'; +import 'package:moxxmpp/src/xeps/xep_0066.dart'; +import 'package:moxxmpp/src/xeps/xep_0085.dart'; +import 'package:moxxmpp/src/xeps/xep_0359.dart'; +import 'package:moxxmpp/src/xeps/xep_0385.dart'; +import 'package:moxxmpp/src/xeps/xep_0446.dart'; +import 'package:moxxmpp/src/xeps/xep_0447.dart'; +import 'package:moxxmpp/src/xeps/xep_0461.dart'; + +abstract class XmppEvent {} + +/// Triggered when the connection state of the XmppConnection has +/// changed. +class ConnectionStateChangedEvent extends XmppEvent { + ConnectionStateChangedEvent(this.state, this.before, this.resumed); + final XmppConnectionState before; + final XmppConnectionState state; + final bool resumed; +} + +/// Triggered when we encounter a stream error. +class StreamErrorEvent extends XmppEvent { + StreamErrorEvent({ required this.error }); + final String error; +} + +/// Triggered after the SASL authentication has failed. +class AuthenticationFailedEvent extends XmppEvent { + AuthenticationFailedEvent(this.saslError); + final String saslError; +} + +/// Triggered after the SASL authentication has succeeded. +class AuthenticationSuccessEvent extends XmppEvent {} + +/// Triggered when we want to ping the connection open +class SendPingEvent extends XmppEvent {} + +/// Triggered when the stream resumption was successful +class StreamResumedEvent extends XmppEvent { + StreamResumedEvent({ required this.h }); + final int h; +} + +/// Triggered when stream resumption failed +class StreamResumeFailedEvent extends XmppEvent {} + +class MessageEvent extends XmppEvent { + MessageEvent({ + required this.body, + required this.fromJid, + required this.toJid, + required this.sid, + required this.stanzaId, + required this.isCarbon, + required this.deliveryReceiptRequested, + required this.isMarkable, + required this.encrypted, + required this.other, + this.type, + this.oob, + this.sfs, + this.sims, + this.reply, + this.chatState, + this.fun, + this.funReplacement, + this.funCancellation, + }); + final String body; + final JID fromJid; + final JID toJid; + final String sid; + final String? type; + final StableStanzaId stanzaId; + final bool isCarbon; + final bool deliveryReceiptRequested; + final bool isMarkable; + final OOBData? oob; + final StatelessFileSharingData? sfs; + final StatelessMediaSharingData? sims; + final ReplyData? reply; + final ChatState? chatState; + final FileMetadataData? fun; + final String? funReplacement; + final String? funCancellation; + final bool encrypted; + final Map other; +} + +/// Triggered when a client responds to our delivery receipt request +class DeliveryReceiptReceivedEvent extends XmppEvent { + DeliveryReceiptReceivedEvent({ required this.from, required this.id }); + final JID from; + final String id; +} + +class ChatMarkerEvent extends XmppEvent { + ChatMarkerEvent({ + required this.type, + required this.from, + required this.id, + }); + final JID from; + final String type; + final String id; +} + +// Triggered when we received a Stream resumption ID +class StreamManagementEnabledEvent extends XmppEvent { + StreamManagementEnabledEvent({ + required this.resource, + this.id, + this.location, + }); + final String resource; + final String? id; + final String? location; +} + +/// Triggered when we bound a resource +class ResourceBindingSuccessEvent extends XmppEvent { + ResourceBindingSuccessEvent({ required this.resource }); + final String resource; +} + +/// Triggered when we receive presence +class PresenceReceivedEvent extends XmppEvent { + PresenceReceivedEvent(this.jid, this.presence); + final JID jid; + final Stanza presence; +} + +/// Triggered when we are starting an connection attempt +class ConnectingEvent extends XmppEvent {} + +/// Triggered when we found out what the server supports +class ServerDiscoDoneEvent extends XmppEvent {} + +class ServerItemDiscoEvent extends XmppEvent { + ServerItemDiscoEvent(this.info); + final DiscoInfo info; +} + +/// Triggered when we receive a subscription request +class SubscriptionRequestReceivedEvent extends XmppEvent { + SubscriptionRequestReceivedEvent({ required this.from }); + final JID from; +} + +/// Triggered when we receive a new or updated avatar +class AvatarUpdatedEvent extends XmppEvent { + AvatarUpdatedEvent({ required this.jid, required this.base64, required this.hash }); + final String jid; + final String base64; + final String hash; +} + +/// Triggered when a PubSub notification has been received +class PubSubNotificationEvent extends XmppEvent { + PubSubNotificationEvent({ required this.item, required this.from }); + final PubSubItem item; + final String from; +} + +/// Triggered by the StreamManagementManager if a stanza has been acked +class StanzaAckedEvent extends XmppEvent { + StanzaAckedEvent(this.stanza); + final Stanza stanza; +} + +/// Triggered when receiving a push of the blocklist +class BlocklistBlockPushEvent extends XmppEvent { + BlocklistBlockPushEvent({ required this.items }); + final List items; +} + +/// Triggered when receiving a push of the blocklist +class BlocklistUnblockPushEvent extends XmppEvent { + BlocklistUnblockPushEvent({ required this.items }); + final List items; +} + +/// Triggered when receiving a push of the blocklist +class BlocklistUnblockAllPushEvent extends XmppEvent { + BlocklistUnblockAllPushEvent(); +} + +/// Triggered when a stanza has not been sent because a stanza handler +/// wanted to cancel the entire process. +class StanzaSendingCancelledEvent extends XmppEvent { + StanzaSendingCancelledEvent(this.data); + final StanzaHandlerData data; +} + +/// Triggered when the device list of a Jid is updated +class OmemoDeviceListUpdatedEvent extends XmppEvent { + OmemoDeviceListUpdatedEvent(this.jid, this.deviceList); + final JID jid; + final List deviceList; +} diff --git a/moxxmpp/lib/src/iq.dart b/moxxmpp/lib/src/iq.dart new file mode 100644 index 0000000..961dd43 --- /dev/null +++ b/moxxmpp/lib/src/iq.dart @@ -0,0 +1,10 @@ +import 'package:moxxmpp/src/connection.dart'; +import 'package:moxxmpp/src/stanza.dart'; + +bool handleUnhandledStanza(XmppConnection conn, Stanza stanza) { + if (stanza.type != 'error' && stanza.type != 'result') { + conn.sendStanza(stanza.errorReply('cancel', 'feature-not-implemented')); + } + + return true; +} diff --git a/moxxmpp/lib/src/jid.dart b/moxxmpp/lib/src/jid.dart new file mode 100644 index 0000000..80c832a --- /dev/null +++ b/moxxmpp/lib/src/jid.dart @@ -0,0 +1,108 @@ +import 'package:meta/meta.dart'; + +@immutable +class JID { + + const JID(this.local, this.domain, this.resource); + + factory JID.fromString(String jid) { + // 0: Parsing either the local or domain part + // 1: Parsing the domain part + // 2: Parsing the resource + var state = 0; + var buffer = ''; + var local_ = ''; + var domain_ = ''; + var resource_ = ''; + + for (var i = 0; i < jid.length; i++) { + final c = jid[i]; + final eol = i == jid.length - 1; + + switch (state) { + case 0: { + if (c == '@') { + local_ = buffer; + buffer = ''; + state = 1; + } else if (c == '/') { + domain_ = buffer; + buffer = ''; + state = 2; + } else if (eol) { + domain_ = buffer + c; + } else { + buffer += c; + } + } + break; + case 1: { + if (c == '/') { + domain_ = buffer; + buffer = ''; + state = 2; + } else if (eol) { + domain_ = buffer; + + if (c != ' ') { + domain_ = domain_ + c; + } + } else if (c != ' ') { + buffer += c; + } + } + break; + case 2: { + if (eol) { + resource_ = buffer; + + if (c != ' ') { + resource_ = resource_ + c; + } + } else if (c != ''){ + buffer += c; + } + } + } + } + + return JID(local_, domain_, resource_); + } + final String local; + final String domain; + final String resource; + + bool isBare() => resource.isEmpty; + bool isFull() => resource.isNotEmpty; + + JID toBare() => JID(local, domain, ''); + JID withResource(String resource) => JID(local, domain, resource); + + @override + String toString() { + var result = ''; + + if (local.isNotEmpty) { + result += '$local@$domain'; + } else { + result += domain; + } + if (isFull()) { + result += '/$resource'; + } + + return result; + } + + @override + bool operator ==(Object other) { + if (other is JID) { + return other.local == local && other.domain == domain && other.resource == resource; + } + + return false; + } + + @override + int get hashCode => local.hashCode ^ domain.hashCode ^ resource.hashCode; +} diff --git a/moxxmpp/lib/src/managers/attributes.dart b/moxxmpp/lib/src/managers/attributes.dart new file mode 100644 index 0000000..72360c1 --- /dev/null +++ b/moxxmpp/lib/src/managers/attributes.dart @@ -0,0 +1,55 @@ +import 'dart:async'; + +import 'package:moxxmpp/src/connection.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/settings.dart'; +import 'package:moxxmpp/src/socket.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class XmppManagerAttributes { + + XmppManagerAttributes({ + required this.sendStanza, + required this.sendNonza, + required this.getManagerById, + required this.sendEvent, + required this.getConnectionSettings, + required this.isFeatureSupported, + required this.getFullJID, + required this.getSocket, + required this.getConnection, + required this.getNegotiatorById, + }); + /// Send a stanza whose response can be awaited. + final Future Function(Stanza stanza, { StanzaFromType addFrom, bool addId, bool awaitable, bool encrypted}) sendStanza; + + /// Send a nonza. + final void Function(XMLNode) sendNonza; + + /// Send an event to the connection's event channel. + final void Function(XmppEvent) sendEvent; + + /// Get the connection settings of the attached connection. + final ConnectionSettings Function() getConnectionSettings; + + /// (Maybe) Get a Manager attached to the connection by its Id. + final T? Function(String) getManagerById; + + /// Returns true if a server feature is supported + final bool Function(String) isFeatureSupported; + + /// Returns the full JID of the current account + final JID Function() getFullJID; + + /// Returns the current socket. MUST NOT be used to send data. + final BaseSocketWrapper Function() getSocket; + + /// Return the [XmppConnection] the manager is registered against. + final XmppConnection Function() getConnection; + + final T? Function(String) getNegotiatorById; +} diff --git a/moxxmpp/lib/src/managers/base.dart b/moxxmpp/lib/src/managers/base.dart new file mode 100644 index 0000000..092cbe1 --- /dev/null +++ b/moxxmpp/lib/src/managers/base.dart @@ -0,0 +1,72 @@ +import 'package:logging/logging.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/attributes.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +abstract class XmppManagerBase { + late final XmppManagerAttributes _managerAttributes; + late final Logger _log; + + /// Registers the callbacks from XmppConnection with the manager + void register(XmppManagerAttributes attributes) { + _managerAttributes = attributes; + _log = Logger(getName()); + } + + /// Returns the attributes that are registered with the manager. + /// Must only be called after register has been called on it. + XmppManagerAttributes getAttributes() { + return _managerAttributes; + } + + /// Return the StanzaHandlers associated with this manager that deal with stanzas we + /// send. These are run before the stanza is sent. + List getOutgoingPreStanzaHandlers() => []; + + /// Return the StanzaHandlers associated with this manager that deal with stanzas we + /// send. These are run after the stanza is sent. + List getOutgoingPostStanzaHandlers() => []; + + /// Return the StanzaHandlers associated with this manager that deal with stanzas we + /// receive. + List getIncomingStanzaHandlers() => []; + + /// Return the NonzaHandlers associated with this manager. + List getNonzaHandlers() => []; + + /// Return a list of features that should be included in a disco response. + List getDiscoFeatures() => []; + + /// Return the Id (akin to xmlns) of this manager. + String getId(); + + /// Return a name that will be used for logging. + String getName(); + + /// Return the logger for this manager. + Logger get logger => _log; + + /// Called when XmppConnection triggers an event + Future onXmppEvent(XmppEvent event) async {} + + /// Returns true if the XEP is supported on the server. If not, returns false + Future isSupported(); + + /// Runs all NonzaHandlers of this Manager which match the nonza. Resolves to true if + /// the nonza has been handled by one of the handlers. Resolves to false otherwise. + Future runNonzaHandlers(XMLNode nonza) async { + var handled = false; + await Future.forEach( + getNonzaHandlers(), + (NonzaHandler handler) async { + if (handler.matches(nonza)) { + handled = true; + await handler.callback(nonza); + } + } + ); + + return handled; + } +} diff --git a/moxxmpp/lib/src/managers/data.dart b/moxxmpp/lib/src/managers/data.dart new file mode 100644 index 0000000..fca20f9 --- /dev/null +++ b/moxxmpp/lib/src/managers/data.dart @@ -0,0 +1,60 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/xeps/xep_0066.dart'; +import 'package:moxxmpp/src/xeps/xep_0085.dart'; +import 'package:moxxmpp/src/xeps/xep_0203.dart'; +import 'package:moxxmpp/src/xeps/xep_0359.dart'; +import 'package:moxxmpp/src/xeps/xep_0380.dart'; +import 'package:moxxmpp/src/xeps/xep_0385.dart'; +import 'package:moxxmpp/src/xeps/xep_0446.dart'; +import 'package:moxxmpp/src/xeps/xep_0447.dart'; +import 'package:moxxmpp/src/xeps/xep_0461.dart'; + +part 'data.freezed.dart'; + +@freezed +class StanzaHandlerData with _$StanzaHandlerData { + factory StanzaHandlerData( + // Indicates to the runner that processing is now done. This means that all + // pre-processing is done and no other handlers should be consulted. + bool done, + // Indicates to the runner that processing is to be cancelled and no further handlers + // should run. The stanza also will not be sent. + bool cancel, + // The reason why we cancelled the processing and sending + dynamic cancelReason, + // The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely + // necessary, e.g. with Message Carbons or OMEMO + Stanza stanza, + { + // Whether the stanza is retransmitted. Only useful in the context of outgoing + // stanza handlers. MUST NOT be overwritten. + @Default(false) bool retransmitted, + StatelessMediaSharingData? sims, + StatelessFileSharingData? sfs, + OOBData? oob, + StableStanzaId? stableId, + ReplyData? reply, + ChatState? chatState, + @Default(false) bool isCarbon, + @Default(false) bool deliveryReceiptRequested, + @Default(false) bool isMarkable, + // File Upload Notifications + // A notification + FileMetadataData? fun, + // The stanza id this replaces + String? funReplacement, + // The stanza id this cancels + String? funCancellation, + // Whether the stanza was received encrypted + @Default(false) bool encrypted, + // The stated type of encryption used, if any was used + ExplicitEncryptionType? encryptionType, + // Delayed Delivery + DelayedDelivery? delayedDelivery, + // This is for stanza handlers that are not part of the XMPP library but still need + // pass data around. + @Default({}) Map other, + } + ) = _StanzaHandlerData; +} diff --git a/moxxmpp/lib/src/managers/data.freezed.dart b/moxxmpp/lib/src/managers/data.freezed.dart new file mode 100644 index 0000000..966113a --- /dev/null +++ b/moxxmpp/lib/src/managers/data.freezed.dart @@ -0,0 +1,613 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target + +part of 'data.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +mixin _$StanzaHandlerData { +// Indicates to the runner that processing is now done. This means that all +// pre-processing is done and no other handlers should be consulted. + bool get done => + throw _privateConstructorUsedError; // Indicates to the runner that processing is to be cancelled and no further handlers +// should run. The stanza also will not be sent. + bool get cancel => + throw _privateConstructorUsedError; // The reason why we cancelled the processing and sending + dynamic get cancelReason => + throw _privateConstructorUsedError; // The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely +// necessary, e.g. with Message Carbons or OMEMO + Stanza get stanza => + throw _privateConstructorUsedError; // Whether the stanza is retransmitted. Only useful in the context of outgoing +// stanza handlers. MUST NOT be overwritten. + bool get retransmitted => throw _privateConstructorUsedError; + StatelessMediaSharingData? get sims => throw _privateConstructorUsedError; + StatelessFileSharingData? get sfs => throw _privateConstructorUsedError; + OOBData? get oob => throw _privateConstructorUsedError; + StableStanzaId? get stableId => throw _privateConstructorUsedError; + ReplyData? get reply => throw _privateConstructorUsedError; + ChatState? get chatState => throw _privateConstructorUsedError; + bool get isCarbon => throw _privateConstructorUsedError; + bool get deliveryReceiptRequested => throw _privateConstructorUsedError; + bool get isMarkable => + throw _privateConstructorUsedError; // File Upload Notifications +// A notification + FileMetadataData? get fun => + throw _privateConstructorUsedError; // The stanza id this replaces + String? get funReplacement => + throw _privateConstructorUsedError; // The stanza id this cancels + String? get funCancellation => + throw _privateConstructorUsedError; // Whether the stanza was received encrypted + bool get encrypted => + throw _privateConstructorUsedError; // The stated type of encryption used, if any was used + ExplicitEncryptionType? get encryptionType => + throw _privateConstructorUsedError; // Delayed Delivery + DelayedDelivery? get delayedDelivery => + throw _privateConstructorUsedError; // This is for stanza handlers that are not part of the XMPP library but still need +// pass data around. + Map get other => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $StanzaHandlerDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $StanzaHandlerDataCopyWith<$Res> { + factory $StanzaHandlerDataCopyWith( + StanzaHandlerData value, $Res Function(StanzaHandlerData) then) = + _$StanzaHandlerDataCopyWithImpl<$Res>; + $Res call( + {bool done, + bool cancel, + dynamic cancelReason, + Stanza stanza, + bool retransmitted, + StatelessMediaSharingData? sims, + StatelessFileSharingData? sfs, + OOBData? oob, + StableStanzaId? stableId, + ReplyData? reply, + ChatState? chatState, + bool isCarbon, + bool deliveryReceiptRequested, + bool isMarkable, + FileMetadataData? fun, + String? funReplacement, + String? funCancellation, + bool encrypted, + ExplicitEncryptionType? encryptionType, + DelayedDelivery? delayedDelivery, + Map other}); +} + +/// @nodoc +class _$StanzaHandlerDataCopyWithImpl<$Res> + implements $StanzaHandlerDataCopyWith<$Res> { + _$StanzaHandlerDataCopyWithImpl(this._value, this._then); + + final StanzaHandlerData _value; + // ignore: unused_field + final $Res Function(StanzaHandlerData) _then; + + @override + $Res call({ + Object? done = freezed, + Object? cancel = freezed, + Object? cancelReason = freezed, + Object? stanza = freezed, + Object? retransmitted = freezed, + Object? sims = freezed, + Object? sfs = freezed, + Object? oob = freezed, + Object? stableId = freezed, + Object? reply = freezed, + Object? chatState = freezed, + Object? isCarbon = freezed, + Object? deliveryReceiptRequested = freezed, + Object? isMarkable = freezed, + Object? fun = freezed, + Object? funReplacement = freezed, + Object? funCancellation = freezed, + Object? encrypted = freezed, + Object? encryptionType = freezed, + Object? delayedDelivery = freezed, + Object? other = freezed, + }) { + return _then(_value.copyWith( + done: done == freezed + ? _value.done + : done // ignore: cast_nullable_to_non_nullable + as bool, + cancel: cancel == freezed + ? _value.cancel + : cancel // ignore: cast_nullable_to_non_nullable + as bool, + cancelReason: cancelReason == freezed + ? _value.cancelReason + : cancelReason // ignore: cast_nullable_to_non_nullable + as dynamic, + stanza: stanza == freezed + ? _value.stanza + : stanza // ignore: cast_nullable_to_non_nullable + as Stanza, + retransmitted: retransmitted == freezed + ? _value.retransmitted + : retransmitted // ignore: cast_nullable_to_non_nullable + as bool, + sims: sims == freezed + ? _value.sims + : sims // ignore: cast_nullable_to_non_nullable + as StatelessMediaSharingData?, + sfs: sfs == freezed + ? _value.sfs + : sfs // ignore: cast_nullable_to_non_nullable + as StatelessFileSharingData?, + oob: oob == freezed + ? _value.oob + : oob // ignore: cast_nullable_to_non_nullable + as OOBData?, + stableId: stableId == freezed + ? _value.stableId + : stableId // ignore: cast_nullable_to_non_nullable + as StableStanzaId?, + reply: reply == freezed + ? _value.reply + : reply // ignore: cast_nullable_to_non_nullable + as ReplyData?, + chatState: chatState == freezed + ? _value.chatState + : chatState // ignore: cast_nullable_to_non_nullable + as ChatState?, + isCarbon: isCarbon == freezed + ? _value.isCarbon + : isCarbon // ignore: cast_nullable_to_non_nullable + as bool, + deliveryReceiptRequested: deliveryReceiptRequested == freezed + ? _value.deliveryReceiptRequested + : deliveryReceiptRequested // ignore: cast_nullable_to_non_nullable + as bool, + isMarkable: isMarkable == freezed + ? _value.isMarkable + : isMarkable // ignore: cast_nullable_to_non_nullable + as bool, + fun: fun == freezed + ? _value.fun + : fun // ignore: cast_nullable_to_non_nullable + as FileMetadataData?, + funReplacement: funReplacement == freezed + ? _value.funReplacement + : funReplacement // ignore: cast_nullable_to_non_nullable + as String?, + funCancellation: funCancellation == freezed + ? _value.funCancellation + : funCancellation // ignore: cast_nullable_to_non_nullable + as String?, + encrypted: encrypted == freezed + ? _value.encrypted + : encrypted // ignore: cast_nullable_to_non_nullable + as bool, + encryptionType: encryptionType == freezed + ? _value.encryptionType + : encryptionType // ignore: cast_nullable_to_non_nullable + as ExplicitEncryptionType?, + delayedDelivery: delayedDelivery == freezed + ? _value.delayedDelivery + : delayedDelivery // ignore: cast_nullable_to_non_nullable + as DelayedDelivery?, + other: other == freezed + ? _value.other + : other // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc +abstract class _$$_StanzaHandlerDataCopyWith<$Res> + implements $StanzaHandlerDataCopyWith<$Res> { + factory _$$_StanzaHandlerDataCopyWith(_$_StanzaHandlerData value, + $Res Function(_$_StanzaHandlerData) then) = + __$$_StanzaHandlerDataCopyWithImpl<$Res>; + @override + $Res call( + {bool done, + bool cancel, + dynamic cancelReason, + Stanza stanza, + bool retransmitted, + StatelessMediaSharingData? sims, + StatelessFileSharingData? sfs, + OOBData? oob, + StableStanzaId? stableId, + ReplyData? reply, + ChatState? chatState, + bool isCarbon, + bool deliveryReceiptRequested, + bool isMarkable, + FileMetadataData? fun, + String? funReplacement, + String? funCancellation, + bool encrypted, + ExplicitEncryptionType? encryptionType, + DelayedDelivery? delayedDelivery, + Map other}); +} + +/// @nodoc +class __$$_StanzaHandlerDataCopyWithImpl<$Res> + extends _$StanzaHandlerDataCopyWithImpl<$Res> + implements _$$_StanzaHandlerDataCopyWith<$Res> { + __$$_StanzaHandlerDataCopyWithImpl( + _$_StanzaHandlerData _value, $Res Function(_$_StanzaHandlerData) _then) + : super(_value, (v) => _then(v as _$_StanzaHandlerData)); + + @override + _$_StanzaHandlerData get _value => super._value as _$_StanzaHandlerData; + + @override + $Res call({ + Object? done = freezed, + Object? cancel = freezed, + Object? cancelReason = freezed, + Object? stanza = freezed, + Object? retransmitted = freezed, + Object? sims = freezed, + Object? sfs = freezed, + Object? oob = freezed, + Object? stableId = freezed, + Object? reply = freezed, + Object? chatState = freezed, + Object? isCarbon = freezed, + Object? deliveryReceiptRequested = freezed, + Object? isMarkable = freezed, + Object? fun = freezed, + Object? funReplacement = freezed, + Object? funCancellation = freezed, + Object? encrypted = freezed, + Object? encryptionType = freezed, + Object? delayedDelivery = freezed, + Object? other = freezed, + }) { + return _then(_$_StanzaHandlerData( + done == freezed + ? _value.done + : done // ignore: cast_nullable_to_non_nullable + as bool, + cancel == freezed + ? _value.cancel + : cancel // ignore: cast_nullable_to_non_nullable + as bool, + cancelReason == freezed + ? _value.cancelReason + : cancelReason // ignore: cast_nullable_to_non_nullable + as dynamic, + stanza == freezed + ? _value.stanza + : stanza // ignore: cast_nullable_to_non_nullable + as Stanza, + retransmitted: retransmitted == freezed + ? _value.retransmitted + : retransmitted // ignore: cast_nullable_to_non_nullable + as bool, + sims: sims == freezed + ? _value.sims + : sims // ignore: cast_nullable_to_non_nullable + as StatelessMediaSharingData?, + sfs: sfs == freezed + ? _value.sfs + : sfs // ignore: cast_nullable_to_non_nullable + as StatelessFileSharingData?, + oob: oob == freezed + ? _value.oob + : oob // ignore: cast_nullable_to_non_nullable + as OOBData?, + stableId: stableId == freezed + ? _value.stableId + : stableId // ignore: cast_nullable_to_non_nullable + as StableStanzaId?, + reply: reply == freezed + ? _value.reply + : reply // ignore: cast_nullable_to_non_nullable + as ReplyData?, + chatState: chatState == freezed + ? _value.chatState + : chatState // ignore: cast_nullable_to_non_nullable + as ChatState?, + isCarbon: isCarbon == freezed + ? _value.isCarbon + : isCarbon // ignore: cast_nullable_to_non_nullable + as bool, + deliveryReceiptRequested: deliveryReceiptRequested == freezed + ? _value.deliveryReceiptRequested + : deliveryReceiptRequested // ignore: cast_nullable_to_non_nullable + as bool, + isMarkable: isMarkable == freezed + ? _value.isMarkable + : isMarkable // ignore: cast_nullable_to_non_nullable + as bool, + fun: fun == freezed + ? _value.fun + : fun // ignore: cast_nullable_to_non_nullable + as FileMetadataData?, + funReplacement: funReplacement == freezed + ? _value.funReplacement + : funReplacement // ignore: cast_nullable_to_non_nullable + as String?, + funCancellation: funCancellation == freezed + ? _value.funCancellation + : funCancellation // ignore: cast_nullable_to_non_nullable + as String?, + encrypted: encrypted == freezed + ? _value.encrypted + : encrypted // ignore: cast_nullable_to_non_nullable + as bool, + encryptionType: encryptionType == freezed + ? _value.encryptionType + : encryptionType // ignore: cast_nullable_to_non_nullable + as ExplicitEncryptionType?, + delayedDelivery: delayedDelivery == freezed + ? _value.delayedDelivery + : delayedDelivery // ignore: cast_nullable_to_non_nullable + as DelayedDelivery?, + other: other == freezed + ? _value._other + : other // ignore: cast_nullable_to_non_nullable + as Map, + )); + } +} + +/// @nodoc + +class _$_StanzaHandlerData implements _StanzaHandlerData { + _$_StanzaHandlerData(this.done, this.cancel, this.cancelReason, this.stanza, + {this.retransmitted = false, + this.sims, + this.sfs, + this.oob, + this.stableId, + this.reply, + this.chatState, + this.isCarbon = false, + this.deliveryReceiptRequested = false, + this.isMarkable = false, + this.fun, + this.funReplacement, + this.funCancellation, + this.encrypted = false, + this.encryptionType, + this.delayedDelivery, + final Map other = const {}}) + : _other = other; + +// Indicates to the runner that processing is now done. This means that all +// pre-processing is done and no other handlers should be consulted. + @override + final bool done; +// Indicates to the runner that processing is to be cancelled and no further handlers +// should run. The stanza also will not be sent. + @override + final bool cancel; +// The reason why we cancelled the processing and sending + @override + final dynamic cancelReason; +// The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely +// necessary, e.g. with Message Carbons or OMEMO + @override + final Stanza stanza; +// Whether the stanza is retransmitted. Only useful in the context of outgoing +// stanza handlers. MUST NOT be overwritten. + @override + @JsonKey() + final bool retransmitted; + @override + final StatelessMediaSharingData? sims; + @override + final StatelessFileSharingData? sfs; + @override + final OOBData? oob; + @override + final StableStanzaId? stableId; + @override + final ReplyData? reply; + @override + final ChatState? chatState; + @override + @JsonKey() + final bool isCarbon; + @override + @JsonKey() + final bool deliveryReceiptRequested; + @override + @JsonKey() + final bool isMarkable; +// File Upload Notifications +// A notification + @override + final FileMetadataData? fun; +// The stanza id this replaces + @override + final String? funReplacement; +// The stanza id this cancels + @override + final String? funCancellation; +// Whether the stanza was received encrypted + @override + @JsonKey() + final bool encrypted; +// The stated type of encryption used, if any was used + @override + final ExplicitEncryptionType? encryptionType; +// Delayed Delivery + @override + final DelayedDelivery? delayedDelivery; +// This is for stanza handlers that are not part of the XMPP library but still need +// pass data around. + final Map _other; +// This is for stanza handlers that are not part of the XMPP library but still need +// pass data around. + @override + @JsonKey() + Map get other { + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_other); + } + + @override + String toString() { + return 'StanzaHandlerData(done: $done, cancel: $cancel, cancelReason: $cancelReason, stanza: $stanza, retransmitted: $retransmitted, sims: $sims, sfs: $sfs, oob: $oob, stableId: $stableId, reply: $reply, chatState: $chatState, isCarbon: $isCarbon, deliveryReceiptRequested: $deliveryReceiptRequested, isMarkable: $isMarkable, fun: $fun, funReplacement: $funReplacement, funCancellation: $funCancellation, encrypted: $encrypted, encryptionType: $encryptionType, delayedDelivery: $delayedDelivery, other: $other)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_StanzaHandlerData && + const DeepCollectionEquality().equals(other.done, done) && + const DeepCollectionEquality().equals(other.cancel, cancel) && + const DeepCollectionEquality() + .equals(other.cancelReason, cancelReason) && + const DeepCollectionEquality().equals(other.stanza, stanza) && + const DeepCollectionEquality() + .equals(other.retransmitted, retransmitted) && + const DeepCollectionEquality().equals(other.sims, sims) && + const DeepCollectionEquality().equals(other.sfs, sfs) && + const DeepCollectionEquality().equals(other.oob, oob) && + const DeepCollectionEquality().equals(other.stableId, stableId) && + const DeepCollectionEquality().equals(other.reply, reply) && + const DeepCollectionEquality().equals(other.chatState, chatState) && + const DeepCollectionEquality().equals(other.isCarbon, isCarbon) && + const DeepCollectionEquality().equals( + other.deliveryReceiptRequested, deliveryReceiptRequested) && + const DeepCollectionEquality() + .equals(other.isMarkable, isMarkable) && + const DeepCollectionEquality().equals(other.fun, fun) && + const DeepCollectionEquality() + .equals(other.funReplacement, funReplacement) && + const DeepCollectionEquality() + .equals(other.funCancellation, funCancellation) && + const DeepCollectionEquality().equals(other.encrypted, encrypted) && + const DeepCollectionEquality() + .equals(other.encryptionType, encryptionType) && + const DeepCollectionEquality() + .equals(other.delayedDelivery, delayedDelivery) && + const DeepCollectionEquality().equals(other._other, this._other)); + } + + @override + int get hashCode => Object.hashAll([ + runtimeType, + const DeepCollectionEquality().hash(done), + const DeepCollectionEquality().hash(cancel), + const DeepCollectionEquality().hash(cancelReason), + const DeepCollectionEquality().hash(stanza), + const DeepCollectionEquality().hash(retransmitted), + const DeepCollectionEquality().hash(sims), + const DeepCollectionEquality().hash(sfs), + const DeepCollectionEquality().hash(oob), + const DeepCollectionEquality().hash(stableId), + const DeepCollectionEquality().hash(reply), + const DeepCollectionEquality().hash(chatState), + const DeepCollectionEquality().hash(isCarbon), + const DeepCollectionEquality().hash(deliveryReceiptRequested), + const DeepCollectionEquality().hash(isMarkable), + const DeepCollectionEquality().hash(fun), + const DeepCollectionEquality().hash(funReplacement), + const DeepCollectionEquality().hash(funCancellation), + const DeepCollectionEquality().hash(encrypted), + const DeepCollectionEquality().hash(encryptionType), + const DeepCollectionEquality().hash(delayedDelivery), + const DeepCollectionEquality().hash(_other) + ]); + + @JsonKey(ignore: true) + @override + _$$_StanzaHandlerDataCopyWith<_$_StanzaHandlerData> get copyWith => + __$$_StanzaHandlerDataCopyWithImpl<_$_StanzaHandlerData>( + this, _$identity); +} + +abstract class _StanzaHandlerData implements StanzaHandlerData { + factory _StanzaHandlerData(final bool done, final bool cancel, + final dynamic cancelReason, final Stanza stanza, + {final bool retransmitted, + final StatelessMediaSharingData? sims, + final StatelessFileSharingData? sfs, + final OOBData? oob, + final StableStanzaId? stableId, + final ReplyData? reply, + final ChatState? chatState, + final bool isCarbon, + final bool deliveryReceiptRequested, + final bool isMarkable, + final FileMetadataData? fun, + final String? funReplacement, + final String? funCancellation, + final bool encrypted, + final ExplicitEncryptionType? encryptionType, + final DelayedDelivery? delayedDelivery, + final Map other}) = _$_StanzaHandlerData; + + @override // Indicates to the runner that processing is now done. This means that all +// pre-processing is done and no other handlers should be consulted. + bool get done; + @override // Indicates to the runner that processing is to be cancelled and no further handlers +// should run. The stanza also will not be sent. + bool get cancel; + @override // The reason why we cancelled the processing and sending + dynamic get cancelReason; + @override // The stanza that is being dealt with. SHOULD NOT be overwritten, unless it is absolutely +// necessary, e.g. with Message Carbons or OMEMO + Stanza get stanza; + @override // Whether the stanza is retransmitted. Only useful in the context of outgoing +// stanza handlers. MUST NOT be overwritten. + bool get retransmitted; + @override + StatelessMediaSharingData? get sims; + @override + StatelessFileSharingData? get sfs; + @override + OOBData? get oob; + @override + StableStanzaId? get stableId; + @override + ReplyData? get reply; + @override + ChatState? get chatState; + @override + bool get isCarbon; + @override + bool get deliveryReceiptRequested; + @override + bool get isMarkable; + @override // File Upload Notifications +// A notification + FileMetadataData? get fun; + @override // The stanza id this replaces + String? get funReplacement; + @override // The stanza id this cancels + String? get funCancellation; + @override // Whether the stanza was received encrypted + bool get encrypted; + @override // The stated type of encryption used, if any was used + ExplicitEncryptionType? get encryptionType; + @override // Delayed Delivery + DelayedDelivery? get delayedDelivery; + @override // This is for stanza handlers that are not part of the XMPP library but still need +// pass data around. + Map get other; + @override + @JsonKey(ignore: true) + _$$_StanzaHandlerDataCopyWith<_$_StanzaHandlerData> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/moxxmpp/lib/src/managers/handlers.dart b/moxxmpp/lib/src/managers/handlers.dart new file mode 100644 index 0000000..de42b81 --- /dev/null +++ b/moxxmpp/lib/src/managers/handlers.dart @@ -0,0 +1,93 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +abstract class Handler { + + const Handler(this.matchStanzas, { this.nonzaTag, this.nonzaXmlns }); + final String? nonzaTag; + final String? nonzaXmlns; + final bool matchStanzas; + + /// Returns true if the node matches the description provided by this [Handler]. + bool matches(XMLNode node) { + var matches = false; + + if (nonzaTag == null && nonzaXmlns == null) { + matches = true; + } + + if (nonzaXmlns != null && nonzaTag != null) { + matches = (node.attributes['xmlns'] ?? '') == nonzaXmlns! && node.tag == nonzaTag!; + } + + if (matchStanzas && nonzaTag == null) { + matches = [ 'iq', 'presence', 'message' ].contains(node.tag); + } + + return matches; + } +} + +class NonzaHandler extends Handler { + + NonzaHandler({ + required this.callback, + String? nonzaTag, + String? nonzaXmlns, + }) : super( + false, + nonzaTag: nonzaTag, + nonzaXmlns: nonzaXmlns, + ); + final Future Function(XMLNode) callback; +} + +class StanzaHandler extends Handler { + + StanzaHandler({ + required this.callback, + this.tagXmlns, + this.tagName, + this.priority = 0, + String? stanzaTag, + }) : super( + true, + nonzaTag: stanzaTag, + nonzaXmlns: stanzaXmlns, + ); + final String? tagName; + final String? tagXmlns; + final int priority; + final Future Function(Stanza, StanzaHandlerData) callback; + + @override + bool matches(XMLNode node) { + var matches = super.matches(node); + + if (matches == false) { + return false; + } + + if (tagName != null) { + final firstTag = node.firstTag(tagName!, xmlns: tagXmlns); + + matches = firstTag != null; + } else if (tagXmlns != null) { + return listContains( + node.children, + (XMLNode _node) => _node.attributes.containsKey('xmlns') && _node.attributes['xmlns'] == tagXmlns, + ); + } + + if (tagName == null && tagXmlns == null) { + matches = true; + } + + return matches; + } +} + +int stanzaHandlerSortComparator(StanzaHandler a, StanzaHandler b) => b.priority.compareTo(a.priority); diff --git a/moxxmpp/lib/src/managers/namespaces.dart b/moxxmpp/lib/src/managers/namespaces.dart new file mode 100644 index 0000000..04c674c --- /dev/null +++ b/moxxmpp/lib/src/managers/namespaces.dart @@ -0,0 +1,26 @@ +const smManager = 'im.moxxy.streammangementmanager'; +const discoManager = 'im.moxxy.discomanager'; +const messageManager = 'im.moxxy.messagemanager'; +const rosterManager = 'im.moxxy.rostermanager'; +const presenceManager = 'im.moxxy.presencemanager'; +const csiManager = 'im.moxxy.csimanager'; +const carbonsManager = 'im.moxxy.carbonsmanager'; +const vcardManager = 'im.moxxy.vcardmanager'; +const pubsubManager = 'im.moxxy.pubsubmanager'; +const userAvatarManager = 'im.moxxy.useravatarmanager'; +const stableIdManager = 'im.moxxy.stableidmanager'; +const simsManager = 'im.moxxy.simsmanager'; +const messageDeliveryReceiptManager = 'im.moxxy.messagedeliveryreceiptmanager'; +const chatMarkerManager = 'im.moxxy.chatmarkermanager'; +const oobManager = 'im.moxxy.oobmanager'; +const sfsManager = 'im.moxxy.sfsmanager'; +const messageRepliesManager = 'im.moxxy.messagerepliesmanager'; +const blockingManager = 'im.moxxy.blockingmanager'; +const httpFileUploadManager = 'im.moxxy.httpfileuploadmanager'; +const chatStateManager = 'im.moxxy.chatstatemanager'; +const pingManager = 'im.moxxy.ping'; +const fileUploadNotificationManager = 'im.moxxy.fileuploadnotificationmanager'; +const omemoManager = 'org.moxxy.omemomanager'; +const emeManager = 'org.moxxy.ememanager'; +const cryptographicHashManager = 'org.moxxy.cryptographichashmanager'; +const delayedDeliveryManager = 'org.moxxy.delayeddeliverymanager'; diff --git a/moxxmpp/lib/src/managers/priorities.dart b/moxxmpp/lib/src/managers/priorities.dart new file mode 100644 index 0000000..e69de29 diff --git a/moxxmpp/lib/src/message.dart b/moxxmpp/lib/src/message.dart new file mode 100644 index 0000000..0c8af11 --- /dev/null +++ b/moxxmpp/lib/src/message.dart @@ -0,0 +1,223 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/staging/file_upload_notification.dart'; +import 'package:moxxmpp/src/xeps/xep_0066.dart'; +import 'package:moxxmpp/src/xeps/xep_0085.dart'; +import 'package:moxxmpp/src/xeps/xep_0184.dart'; +import 'package:moxxmpp/src/xeps/xep_0333.dart'; +import 'package:moxxmpp/src/xeps/xep_0359.dart'; +import 'package:moxxmpp/src/xeps/xep_0446.dart'; +import 'package:moxxmpp/src/xeps/xep_0447.dart'; +import 'package:moxxmpp/src/xeps/xep_0448.dart'; + +class MessageDetails { + + const MessageDetails({ + required this.to, + this.body, + this.requestDeliveryReceipt = false, + this.requestChatMarkers = true, + this.id, + this.originId, + this.quoteBody, + this.quoteId, + this.quoteFrom, + this.chatState, + this.sfs, + this.fun, + this.funReplacement, + this.funCancellation, + this.shouldEncrypt = false, + }); + final String to; + final String? body; + final bool requestDeliveryReceipt; + final bool requestChatMarkers; + final String? id; + final String? originId; + final String? quoteBody; + final String? quoteId; + final String? quoteFrom; + final ChatState? chatState; + final StatelessFileSharingData? sfs; + final FileMetadataData? fun; + final String? funReplacement; + final String? funCancellation; + final bool shouldEncrypt; +} + +class MessageManager extends XmppManagerBase { + @override + String getId() => messageManager; + + @override + String getName() => 'MessageManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + callback: _onMessage, + priority: -100, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza _, StanzaHandlerData state) async { + final message = state.stanza; + final body = message.firstTag('body'); + + getAttributes().sendEvent(MessageEvent( + body: body != null ? body.innerText() : '', + fromJid: JID.fromString(message.attributes['from']! as String), + toJid: JID.fromString(message.attributes['to']! as String), + sid: message.attributes['id']! as String, + stanzaId: state.stableId ?? const StableStanzaId(), + isCarbon: state.isCarbon, + deliveryReceiptRequested: state.deliveryReceiptRequested, + isMarkable: state.isMarkable, + type: message.attributes['type'] as String?, + oob: state.oob, + sfs: state.sfs, + sims: state.sims, + reply: state.reply, + chatState: state.chatState, + fun: state.fun, + funReplacement: state.funReplacement, + funCancellation: state.funCancellation, + encrypted: state.encrypted, + other: state.other, + ),); + + return state.copyWith(done: true); + } + + /// Send a message to to with the content body. If deliveryRequest is true, then + /// the message will also request a delivery receipt from the receiver. + /// If id is non-null, then it will be the id of the message stanza. + /// element to this id. If originId is non-null, then it will create an "origin-id" + /// child in the message stanza and set its id to originId. + void sendMessage(MessageDetails details) { + final stanza = Stanza.message( + to: details.to, + type: 'chat', + id: details.id, + children: [], + ); + + if (details.quoteBody != null) { + final fallback = '> ${details.quoteBody!}'; + + stanza + ..addChild( + XMLNode(tag: 'body', text: '$fallback\n${details.body}'), + ) + ..addChild( + XMLNode.xmlns( + tag: 'reply', + xmlns: replyXmlns, + attributes: { + 'to': details.quoteFrom!, + 'id': details.quoteId! + }, + ), + ) + ..addChild( + XMLNode.xmlns( + tag: 'fallback', + xmlns: fallbackXmlns, + attributes: { + 'for': replyXmlns + }, + children: [ + XMLNode( + tag: 'body', + attributes: { + 'start': '0', + 'end': '${fallback.length}' + }, + ) + ], + ), + ); + } else { + var body = details.body; + if (details.sfs != null) { + // TODO(Unknown): Maybe find a better solution + final firstSource = details.sfs!.sources.first; + if (firstSource is StatelessFileSharingUrlSource) { + body = firstSource.url; + } else if (firstSource is StatelessFileSharingEncryptedSource) { + body = firstSource.source.url; + } + } + + stanza.addChild( + XMLNode(tag: 'body', text: body), + ); + } + + if (details.requestDeliveryReceipt) { + stanza.addChild(makeMessageDeliveryRequest()); + } + if (details.requestChatMarkers) { + stanza.addChild(makeChatMarkerMarkable()); + } + if (details.originId != null) { + stanza.addChild(makeOriginIdElement(details.originId!)); + } + + if (details.sfs != null) { + stanza.addChild(details.sfs!.toXML()); + + final source = details.sfs!.sources.first; + if (source is StatelessFileSharingUrlSource) { + // SFS recommends OOB as a fallback + stanza.addChild(constructOOBNode(OOBData(url: source.url))); + } + } + + if (details.chatState != null) { + stanza.addChild( + // TODO(Unknown): Move this into xep_0085.dart + XMLNode.xmlns(tag: chatStateToString(details.chatState!), xmlns: chatStateXmlns), + ); + } + + if (details.fun != null) { + stanza.addChild( + XMLNode.xmlns( + tag: 'file-upload', + xmlns: fileUploadNotificationXmlns, + children: [ + details.fun!.toXML(), + ], + ), + ); + } + + if (details.funReplacement != null) { + stanza.addChild( + XMLNode.xmlns( + tag: 'replaces', + xmlns: fileUploadNotificationXmlns, + attributes: { + 'id': details.funReplacement!, + }, + ), + ); + } + + getAttributes().sendStanza(stanza, awaitable: false); + } +} diff --git a/moxxmpp/lib/src/namespaces.dart b/moxxmpp/lib/src/namespaces.dart new file mode 100644 index 0000000..e1e155f --- /dev/null +++ b/moxxmpp/lib/src/namespaces.dart @@ -0,0 +1,134 @@ +// RFC 6120 +const saslXmlns = 'urn:ietf:params:xml:ns:xmpp-sasl'; +const stanzaXmlns = 'jabber:client'; +const streamXmlns = 'http://etherx.jabber.org/streams'; +const bindXmlns = 'urn:ietf:params:xml:ns:xmpp-bind'; +const startTlsXmlns = 'urn:ietf:params:xml:ns:xmpp-tls'; +const fullStanzaXmlns = 'urn:ietf:params:xml:ns:xmpp-stanzas'; + +// RFC 6121 +const rosterXmlns = 'jabber:iq:roster'; +const rosterVersioningXmlns = 'urn:xmpp:features:rosterver'; + +// XEP-0004 +const dataFormsXmlns = 'jabber:x:data'; + +// XEP-0030 +const discoInfoXmlns = 'http://jabber.org/protocol/disco#info'; +const discoItemsXmlns = 'http://jabber.org/protocol/disco#items'; + +// XEP-0033 +const extendedAddressingXmlns = 'http://jabber.org/protocol/address'; + +// XEP-0054 +const vCardTempXmlns = 'vcard-temp'; +const vCardTempUpdate = 'vcard-temp:x:update'; + +// XEP-0060 +const pubsubXmlns = 'http://jabber.org/protocol/pubsub'; +const pubsubEventXmlns = 'http://jabber.org/protocol/pubsub#event'; +const pubsubOwnerXmlns = 'http://jabber.org/protocol/pubsub#owner'; +const pubsubPublishOptionsXmlns = 'http://jabber.org/protocol/pubsub#publish-options'; +const pubsubNodeConfigMax = 'http://jabber.org/protocol/pubsub#config-node-max'; +const pubsubNodeConfigMultiItems = 'http://jabber.org/protocol/pubsub#multi-items'; + +// XEP-0066 +const oobDataXmlns = 'jabber:x:oob'; + +// XEP-0084 +const userAvatarDataXmlns = 'urn:xmpp:avatar:data'; +const userAvatarMetadataXmlns = 'urn:xmpp:avatar:metadata'; + +// XEP-0085 +const chatStateXmlns = 'http://jabber.org/protocol/chatstates'; + +// XEP-0115 +const capsXmlns = 'http://jabber.org/protocol/caps'; + +// XEP-0184 +const deliveryXmlns = 'urn:xmpp:receipts'; + +// XEP-0191 +const blockingXmlns = 'urn:xmpp:blocking'; + +// XEP-0198 +const smXmlns = 'urn:xmpp:sm:3'; + +// XEP-0203 +const delayedDeliveryXmlns = 'urn:xmpp:delay'; + +// XEP-0234 +const jingleFileTransferXmlns = 'urn:xmpp:jingle:apps:file-transfer:5'; + +// XEP-0280 +const carbonsXmlns = 'urn:xmpp:carbons:2'; + +// XEP-0297 +const forwardedXmlns = 'urn:xmpp:forward:0'; + +// XEP-0300 +const hashXmlns = 'urn:xmpp:hashes:2'; +const hashFunctionNameBaseXmlns = 'urn:xmpp:hash-function-text-names'; +const hashSha256 = 'sha-256'; +const hashSha512 = 'sha-512'; +const hashSha3256 = 'sha3-256'; +const hashSha3512 = 'sha3-512'; +const hashBlake2b256 = 'blake2b-256'; +const hashBlake2b512 = 'blake2b-512'; + +// XEP-0333 +const chatMarkersXmlns = 'urn:xmpp:chat-markers:0'; + +// XEP-0334 +const messageProcessingHintsXmlns = 'urn:xmpp:hints'; + +// XEP-0352 +const csiXmlns = 'urn:xmpp:csi:0'; + +// XEP-0359 +const stableIdXmlns = 'urn:xmpp:sid:0'; + +// XEP-0363 +const httpFileUploadXmlns = 'urn:xmpp:http:upload:0'; + +// XEP-0372 +const referenceXmlns = 'urn:xmpp:reference:0'; + +// XEP-380 +const emeXmlns = 'urn:xmpp:eme:0'; +const emeOtr = 'urn:xmpp:otr:0'; +const emeLegacyOpenPGP = 'jabber:x:encrypted'; +const emeOpenPGP = 'urn:xmpp:openpgp:0'; +const emeOmemo = 'eu.siacs.conversations.axolotl'; +const emeOmemo1 = 'urn:xmpp:omemo:1'; +const emeOmemo2 = 'urn:xmpp:omemo:2'; + +// XEP-0384 +const omemoXmlns = 'urn:xmpp:omemo:2'; +const omemoDevicesXmlns = 'urn:xmpp:omemo:2:devices'; +const omemoBundlesXmlns = 'urn:xmpp:omemo:2:bundles'; + +// XEP-0385 +const simsXmlns = 'urn:xmpp:sims:1'; + +// XEP-0420 +const sceXmlns = 'urn:xmpp:sce:1'; + +// XEP-0446 +const fileMetadataXmlns = 'urn:xmpp:file:metadata:0'; + +// XEP-0447 +const sfsXmlns = 'urn:xmpp:sfs:0'; + +// XEP-0448 +const sfsEncryptionXmlns = 'urn:xmpp:esfs:0'; +const sfsEncryptionAes128GcmNoPaddingXmlns = 'urn:xmpp:ciphers:aes-128-gcm-nopadding:0'; +const sfsEncryptionAes256GcmNoPaddingXmlns = 'urn:xmpp:ciphers:aes-256-gcm-nopadding:0'; +const sfsEncryptionAes256CbcPkcs7Xmlns = 'urn:xmpp:ciphers:aes-256-cbc-pkcs7:0'; + +// XEP-0461 +const replyXmlns = 'urn:xmpp:reply:0'; +const fallbackXmlns = 'urn:xmpp:feature-fallback:0'; + +// ??? +const urlDataXmlns = 'http://jabber.org/protocol/url-data'; diff --git a/moxxmpp/lib/src/negotiators/manager.dart b/moxxmpp/lib/src/negotiators/manager.dart new file mode 100644 index 0000000..e69de29 diff --git a/moxxmpp/lib/src/negotiators/namespaces.dart b/moxxmpp/lib/src/negotiators/namespaces.dart new file mode 100644 index 0000000..8ea71fb --- /dev/null +++ b/moxxmpp/lib/src/negotiators/namespaces.dart @@ -0,0 +1,9 @@ +const saslPlainNegotiator = 'im.moxxy.sasl.plain'; +const saslScramSha1Negotiator = 'im.moxxy.sasl.scram.sha1'; +const saslScramSha256Negotiator = 'im.moxxy.sasl.scram.sha256'; +const saslScramSha512Negotiator = 'im.moxxy.sasl.scram.sha512'; +const csiNegotiator = 'im.moxxy.xeps.csi'; +const rosterNegotiator = 'im.moxxy.core.roster'; +const resourceBindingNegotiator = 'im.moxxy.core.resource'; +const streamManagementNegotiator = 'im.moxxy.xeps.sm'; +const startTlsNegotiator = 'im.moxxy.core.starttls'; diff --git a/moxxmpp/lib/src/negotiators/negotiator.dart b/moxxmpp/lib/src/negotiators/negotiator.dart new file mode 100644 index 0000000..c91a945 --- /dev/null +++ b/moxxmpp/lib/src/negotiators/negotiator.dart @@ -0,0 +1,108 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/settings.dart'; +import 'package:moxxmpp/src/socket.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +/// The state a negotiator is currently in +enum NegotiatorState { + // Ready to negotiate the feature + ready, + // Feature negotiated; negotiator must not be used again + done, + // Cancel the current attempt but we are not done + retryLater, + // The negotiator is in an error state + error, + // Skip the rest of the negotiation and assume the stream ready. Only use this when + // using stream restoration XEPs, like Stream Management. + skipRest, +} + +class NegotiatorAttributes { + + const NegotiatorAttributes( + this.sendNonza, + this.getConnectionSettings, + this.sendEvent, + this.getNegotiatorById, + this.getManagerById, + this.getFullJID, + this.getSocket, + this.isAuthenticated, + ); + /// Sends the nonza nonza and optionally redacts it in logs if redact is not null. + final void Function(XMLNode nonza, {String? redact}) sendNonza; + /// Returns the connection settings. + final ConnectionSettings Function() getConnectionSettings; + /// Send an event event to the connection's event bus + final Future Function(XmppEvent event) sendEvent; + /// Returns the negotiator with id id of the connection or null. + final T? Function(String) getNegotiatorById; + /// Returns the manager with id id of the connection or null. + final T? Function(String) getManagerById; + /// Returns the full JID of the current account + final JID Function() getFullJID; + /// Returns the socket the negotiator is attached to + final BaseSocketWrapper Function() getSocket; + /// Returns true if the stream is authenticated. Returns false if not. + final bool Function() isAuthenticated; +} + +abstract class XmppFeatureNegotiatorBase { + + XmppFeatureNegotiatorBase(this.priority, this.sendStreamHeaderWhenDone, this.negotiatingXmlns, this.id) + : state = NegotiatorState.ready; + /// The priority regarding other negotiators. The higher, the earlier will the + /// negotiator be used + final int priority; + + /// If true, then a new stream header will be sent when the negotiator switches its + /// state to done. If false, no stream header will be sent. + final bool sendStreamHeaderWhenDone; + + /// The XMLNS the negotiator will negotiate + final String negotiatingXmlns; + + /// The Id of the negotiator + final String id; + + /// The state the negotiator is currently in + NegotiatorState state; + + late NegotiatorAttributes _attributes; + + /// Register the negotiator against a connection class by means of [attributes]. + void register(NegotiatorAttributes attributes) { + _attributes = attributes; + } + + /// Returns true if a feature in [features], which are the children of the + /// nonza, can be negotiated. Otherwise, returns false. + bool matchesFeature(List features) { + return firstWhereOrNull( + features, + (XMLNode feature) => feature.attributes['xmlns'] == negotiatingXmlns, + ) != null; + } + + /// Called with the currently received nonza [nonza] when the negotiator is active. + /// If the negotiator is just elected to be the next one, then [nonza] is equal to + /// the nonza. + /// + /// Returns the next state of the negotiator. If done or retryLater is selected, then + /// negotiator won't be called again. If retryLater is returned, then the negotiator + /// must switch some internal state to prevent getting matched immediately again. + /// If ready is returned, then the negotiator indicates that it is not done with + /// negotiation. + Future negotiate(XMLNode nonza); + + /// Reset the negotiator to a state that negotation can happen again. + void reset() { + state = NegotiatorState.ready; + } + + NegotiatorAttributes get attributes => _attributes; +} diff --git a/moxxmpp/lib/src/negotiators/resource_binding.dart b/moxxmpp/lib/src/negotiators/resource_binding.dart new file mode 100644 index 0000000..9be1fbf --- /dev/null +++ b/moxxmpp/lib/src/negotiators/resource_binding.dart @@ -0,0 +1,66 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart'; +import 'package:uuid/uuid.dart'; + +class ResourceBindingNegotiator extends XmppFeatureNegotiatorBase { + + ResourceBindingNegotiator() : _requestSent = false, super(0, false, bindXmlns, resourceBindingNegotiator); + bool _requestSent; + + @override + bool matchesFeature(List features) { + final sm = attributes.getManagerById(smManager); + if (sm != null) { + return super.matchesFeature(features) && !sm.streamResumed && attributes.isAuthenticated(); + } + + return super.matchesFeature(features) && attributes.isAuthenticated(); + } + + @override + Future negotiate(XMLNode nonza) async { + if (!_requestSent) { + final stanza = XMLNode.xmlns( + tag: 'iq', + xmlns: stanzaXmlns, + attributes: { + 'type': 'set', + 'id': const Uuid().v4(), + }, + children: [ + XMLNode.xmlns( + tag: 'bind', + xmlns: bindXmlns, + ), + ], + ); + + _requestSent = true; + attributes.sendNonza(stanza); + } else { + if (nonza.tag != 'iq' || nonza.attributes['type'] != 'result') { + state = NegotiatorState.error; + return; + } + + final bind = nonza.firstTag('bind')!; + final jid = bind.firstTag('jid')!; + final resource = jid.innerText().split('/')[1]; + + await attributes.sendEvent(ResourceBindingSuccessEvent(resource: resource)); + state = NegotiatorState.done; + } + } + + @override + void reset() { + _requestSent = false; + + super.reset(); + } +} diff --git a/moxxmpp/lib/src/negotiators/sasl/kv.dart b/moxxmpp/lib/src/negotiators/sasl/kv.dart new file mode 100644 index 0000000..1636760 --- /dev/null +++ b/moxxmpp/lib/src/negotiators/sasl/kv.dart @@ -0,0 +1,46 @@ +enum ParserState { + variableName, + variableValue +} + +/// Parse a string like "n,,n=user,r=fyko+d2lbbFgONRv9qkxdawL" into +/// { "n": "user", "r": "fyko+d2lbbFgONRv9qkxdawL"}. +Map parseKeyValue(String keyValueString) { + var state = ParserState.variableName; + var name = ''; + var value = ''; + final values = {}; + + for (var i = 0; i < keyValueString.length; i++) { + final char = keyValueString[i]; + switch (state) { + case ParserState.variableName: { + if (char == '=') { + state = ParserState.variableValue; + } else if (char == ',') { + name = ''; + } else { + name += char; + } + } + break; + case ParserState.variableValue: { + if (char == ',' || i == keyValueString.length - 1) { + if (char != ',') { + value += char; + } + + values[name] = value; + value = ''; + name = ''; + state = ParserState.variableName; + } else { + value += char; + } + } + break; + } + } + + return values; +} diff --git a/moxxmpp/lib/src/negotiators/sasl/negotiator.dart b/moxxmpp/lib/src/negotiators/sasl/negotiator.dart new file mode 100644 index 0000000..ebb1a99 --- /dev/null +++ b/moxxmpp/lib/src/negotiators/sasl/negotiator.dart @@ -0,0 +1,27 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +abstract class SaslNegotiator extends XmppFeatureNegotiatorBase { + + SaslNegotiator(int priority, String id, this.mechanismName) : super(priority, true, saslXmlns, id); + /// The name inside the element + final String mechanismName; + + @override + bool matchesFeature(List features) { + // Is SASL advertised? + final mechanisms = firstWhereOrNull( + features, + (XMLNode feature) => feature.attributes['xmlns'] == saslXmlns, + ); + if (mechanisms == null) return false; + + // Is SASL PLAIN advertised? + return firstWhereOrNull( + mechanisms.children, + (XMLNode mechanism) => mechanism.text == mechanismName, + ) != null; + } +} diff --git a/moxxmpp/lib/src/negotiators/sasl/nonza.dart b/moxxmpp/lib/src/negotiators/sasl/nonza.dart new file mode 100644 index 0000000..0010ca2 --- /dev/null +++ b/moxxmpp/lib/src/negotiators/sasl/nonza.dart @@ -0,0 +1,13 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class SaslAuthNonza extends XMLNode { + SaslAuthNonza(String mechanism, String body) : super( + tag: 'auth', + attributes: { + 'xmlns': saslXmlns, + 'mechanism': mechanism , + }, + text: body, + ); +} diff --git a/moxxmpp/lib/src/negotiators/sasl/plain.dart b/moxxmpp/lib/src/negotiators/sasl/plain.dart new file mode 100644 index 0000000..d6d5a81 --- /dev/null +++ b/moxxmpp/lib/src/negotiators/sasl/plain.dart @@ -0,0 +1,73 @@ +import 'dart:convert'; + +import 'package:logging/logging.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/negotiators/sasl/negotiator.dart'; +import 'package:moxxmpp/src/negotiators/sasl/nonza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class SaslPlainAuthNonza extends SaslAuthNonza { + SaslPlainAuthNonza(String username, String password) : super( + 'PLAIN', base64.encode(utf8.encode('\u0000$username\u0000$password')), + ); +} + +class SaslPlainNegotiator extends SaslNegotiator { + + SaslPlainNegotiator() + : _authSent = false, + _log = Logger('SaslPlainNegotiator'), + super(0, saslPlainNegotiator, 'PLAIN'); + bool _authSent; + + final Logger _log; + + @override + bool matchesFeature(List features) { + if (!attributes.getConnectionSettings().allowPlainAuth) return false; + + if (super.matchesFeature(features)) { + if (!attributes.getSocket().isSecure()) { + _log.warning('Refusing to match SASL feature due to unsecured connection'); + return false; + } + + return true; + } + + return false; + } + + @override + Future negotiate(XMLNode nonza) async { + if (!_authSent) { + final settings = attributes.getConnectionSettings(); + attributes.sendNonza( + SaslPlainAuthNonza(settings.jid.local, settings.password), + redact: SaslPlainAuthNonza('******', '******').toXml(), + ); + _authSent = true; + } else { + final tag = nonza.tag; + if (tag == 'success') { + await attributes.sendEvent(AuthenticationSuccessEvent()); + state = NegotiatorState.done; + } else { + // We assume it's a + final error = nonza.children.first.tag; + await attributes.sendEvent(AuthenticationFailedEvent(error)); + + state = NegotiatorState.error; + } + } + } + + @override + void reset() { + _authSent = false; + + super.reset(); + } +} diff --git a/moxxmpp/lib/src/negotiators/sasl/scram.dart b/moxxmpp/lib/src/negotiators/sasl/scram.dart new file mode 100644 index 0000000..55850d5 --- /dev/null +++ b/moxxmpp/lib/src/negotiators/sasl/scram.dart @@ -0,0 +1,259 @@ +import 'dart:convert'; +import 'dart:math' show Random; + +import 'package:cryptography/cryptography.dart'; +import 'package:logging/logging.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/negotiators/sasl/kv.dart'; +import 'package:moxxmpp/src/negotiators/sasl/negotiator.dart'; +import 'package:moxxmpp/src/negotiators/sasl/nonza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:random_string/random_string.dart'; +import 'package:saslprep/saslprep.dart'; + +// NOTE: Inspired by https://github.com/vukoye/xmpp_dart/blob/3b1a0588562b9e591488c99d834088391840911d/lib/src/features/sasl/ScramSaslHandler.dart + +enum ScramHashType { + sha1, + sha256, + sha512 +} + +HashAlgorithm hashFromType(ScramHashType type) { + switch (type) { + case ScramHashType.sha1: return Sha1(); + case ScramHashType.sha256: return Sha256(); + case ScramHashType.sha512: return Sha512(); + } +} + +const scramSha1Mechanism = 'SCRAM-SHA-1'; +const scramSha256Mechanism = 'SCRAM-SHA-256'; +const scramSha512Mechanism = 'SCRAM-SHA-512'; + +String mechanismNameFromType(ScramHashType type) { + switch (type) { + case ScramHashType.sha1: return scramSha1Mechanism; + case ScramHashType.sha256: return scramSha256Mechanism; + case ScramHashType.sha512: return scramSha512Mechanism; + } +} + +String namespaceFromType(ScramHashType type) { + switch (type) { + case ScramHashType.sha1: return saslScramSha1Negotiator; + case ScramHashType.sha256: return saslScramSha256Negotiator; + case ScramHashType.sha512: return saslScramSha512Negotiator; + } +} + +class SaslScramAuthNonza extends SaslAuthNonza { + // This subclassing makes less sense here, but this is since the auth nonza here + // requires knowledge of the inner state of the Negotiator. + SaslScramAuthNonza({ required ScramHashType type, required String body }) : super( + mechanismNameFromType(type), body, + ); +} + +class SaslScramResponseNonza extends XMLNode { + SaslScramResponseNonza({ required String body }) : super( + tag: 'response', + attributes: { + 'xmlns': saslXmlns, + }, + text: body, + ); +} + +enum ScramState { + preSent, + initialMessageSent, + challengeResponseSent, + error +} + +const gs2Header = 'n,,'; + +class SaslScramNegotiator extends SaslNegotiator { + + // NOTE: NEVER, and I mean, NEVER set clientNonce or initalMessageNoGS2. They are just there for testing + SaslScramNegotiator( + int priority, + this.initialMessageNoGS2, + this.clientNonce, + this.hashType, + ) : + _hash = hashFromType(hashType), + _serverSignature = '', + _scramState = ScramState.preSent, + _log = Logger('SaslScramNegotiator(${mechanismNameFromType(hashType)})'), + super(priority, namespaceFromType(hashType), mechanismNameFromType(hashType)); + String? clientNonce; + String initialMessageNoGS2; + final ScramHashType hashType; + final HashAlgorithm _hash; + String _serverSignature; + + // The internal state for performing the negotiation + ScramState _scramState; + + final Logger _log; + + Future> calculateSaltedPassword(String salt, int iterations) async { + final pbkdf2 = Pbkdf2( + macAlgorithm: Hmac(_hash), + iterations: iterations, + bits: 160, // NOTE: RFC says 20 octets => 20 octets * 8 bits/octet + ); + + final saltedPasswordRaw = await pbkdf2.deriveKey( + secretKey: SecretKey( + utf8.encode(Saslprep.saslprep(attributes.getConnectionSettings().password)), + ), + nonce: base64.decode(salt), + ); + return saltedPasswordRaw.extractBytes(); + } + + Future> calculateClientKey(List saltedPassword) async { + return (await Hmac(_hash).calculateMac( + utf8.encode('Client Key'), secretKey: SecretKey(saltedPassword), + )).bytes; + } + + Future> calculateClientSignature(String authMessage, List storedKey) async { + return (await Hmac(_hash).calculateMac( + utf8.encode(authMessage), + secretKey: SecretKey(storedKey), + )).bytes; + } + + Future> calculateServerKey(List saltedPassword) async { + return (await Hmac(_hash).calculateMac( + utf8.encode('Server Key'), + secretKey: SecretKey(saltedPassword), + )).bytes; + } + + Future> calculateServerSignature(String authMessage, List serverKey) async { + return (await Hmac(_hash).calculateMac( + utf8.encode(authMessage), + secretKey: SecretKey(serverKey), + )).bytes; + } + + List calculateClientProof(List clientKey, List clientSignature) { + final clientProof = List.filled(clientKey.length, 0); + for (var i = 0; i < clientKey.length; i++) { + clientProof[i] = clientKey[i] ^ clientSignature[i]; + } + + return clientProof; + } + + Future calculateChallengeResponse(String base64Challenge) async { + final challengeString = utf8.decode(base64.decode(base64Challenge)); + final challenge = parseKeyValue(challengeString); + final clientFinalMessageBare = 'c=biws,r=${challenge['r']!}'; + + final saltedPassword = await calculateSaltedPassword(challenge['s']!, int.parse(challenge['i']!)); + final clientKey = await calculateClientKey(saltedPassword); + final storedKey = (await _hash.hash(clientKey)).bytes; + final authMessage = '$initialMessageNoGS2,$challengeString,$clientFinalMessageBare'; + final clientSignature = await calculateClientSignature(authMessage, storedKey); + final clientProof = calculateClientProof(clientKey, clientSignature); + final serverKey = await calculateServerKey(saltedPassword); + _serverSignature = base64.encode(await calculateServerSignature(authMessage, serverKey)); + + return '$clientFinalMessageBare,p=${base64.encode(clientProof)}'; + } + + @override + bool matchesFeature(List features) { + if (super.matchesFeature(features)) { + if (!attributes.getSocket().isSecure()) { + _log.warning('Refusing to match SASL feature due to unsecured connection'); + return false; + } + + return true; + } + + return false; + } + + @override + Future negotiate(XMLNode nonza) async { + switch (_scramState) { + case ScramState.preSent: + if (clientNonce == null || clientNonce == '') { + clientNonce = randomAlphaNumeric(40, provider: CoreRandomProvider.from(Random.secure())); + } + + initialMessageNoGS2 = 'n=${attributes.getConnectionSettings().jid.local},r=$clientNonce'; + + _scramState = ScramState.initialMessageSent; + attributes.sendNonza( + SaslScramAuthNonza(body: base64.encode(utf8.encode(gs2Header + initialMessageNoGS2)), type: hashType), + redact: SaslScramAuthNonza(body: '******', type: hashType).toXml(), + ); + break; + case ScramState.initialMessageSent: + if (nonza.tag != 'challenge') { + final error = nonza.children.first.tag; + await attributes.sendEvent(AuthenticationFailedEvent(error)); + + state = NegotiatorState.error; + _scramState = ScramState.error; + return; + } + + final challengeBase64 = nonza.innerText(); + final response = await calculateChallengeResponse(challengeBase64); + final responseBase64 = base64.encode(utf8.encode(response)); + _scramState = ScramState.challengeResponseSent; + attributes.sendNonza( + SaslScramResponseNonza(body: responseBase64), + redact: SaslScramResponseNonza(body: '******').toXml(), + ); + return; + case ScramState.challengeResponseSent: + if (nonza.tag != 'success') { + // We assume it's a + final error = nonza.children.first.tag; + await attributes.sendEvent(AuthenticationFailedEvent(error)); + _scramState = ScramState.error; + state = NegotiatorState.error; + return; + } + + // NOTE: This assumes that the string is always "v=..." and contains no other parameters + final signature = parseKeyValue(utf8.decode(base64.decode(nonza.innerText()))); + if (signature['v']! != _serverSignature) { + // TODO(Unknown): Notify of a signature mismatch + //final error = nonza.children.first.tag; + //attributes.sendEvent(AuthenticationFailedEvent(error)); + _scramState = ScramState.error; + state = NegotiatorState.error; + return; + } + + await attributes.sendEvent(AuthenticationSuccessEvent()); + state = NegotiatorState.done; + return; + case ScramState.error: + state = NegotiatorState.error; + return; + } + } + + @override + void reset() { + _scramState = ScramState.preSent; + + super.reset(); + } +} diff --git a/moxxmpp/lib/src/negotiators/starttls.dart b/moxxmpp/lib/src/negotiators/starttls.dart new file mode 100644 index 0000000..e30111b --- /dev/null +++ b/moxxmpp/lib/src/negotiators/starttls.dart @@ -0,0 +1,65 @@ +import 'package:logging/logging.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +enum _StartTlsState { + ready, + requested +} + +class StartTLSNonza extends XMLNode { + StartTLSNonza() : super.xmlns( + tag: 'starttls', + xmlns: startTlsXmlns, + ); +} + +class StartTlsNegotiator extends XmppFeatureNegotiatorBase { + + StartTlsNegotiator() + : _state = _StartTlsState.ready, + _log = Logger('StartTlsNegotiator'), + super(10, true, startTlsXmlns, startTlsNegotiator); + _StartTlsState _state; + + final Logger _log; + + @override + Future negotiate(XMLNode nonza) async { + switch (_state) { + case _StartTlsState.ready: + _log.fine('StartTLS is available. Performing StartTLS upgrade...'); + _state = _StartTlsState.requested; + attributes.sendNonza(StartTLSNonza()); + break; + case _StartTlsState.requested: + if (nonza.tag != 'proceed' || nonza.attributes['xmlns'] != startTlsXmlns) { + _log.severe('Failed to perform StartTLS negotiation'); + state = NegotiatorState.error; + return; + } + + _log.fine('Securing socket'); + final result = await attributes.getSocket() + .secure(attributes.getConnectionSettings().jid.domain); + if (!result) { + _log.severe('Failed to secure stream'); + state = NegotiatorState.error; + return; + } + + _log.fine('Stream is now TLS secured'); + state = NegotiatorState.done; + break; + } + } + + @override + void reset() { + _state = _StartTlsState.ready; + + super.reset(); + } +} diff --git a/moxxmpp/lib/src/ping.dart b/moxxmpp/lib/src/ping.dart new file mode 100644 index 0000000..4a46fb3 --- /dev/null +++ b/moxxmpp/lib/src/ping.dart @@ -0,0 +1,52 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart'; + +class PingManager extends XmppManagerBase { + @override + String getId() => pingManager; + + @override + String getName() => 'PingManager'; + + @override + Future isSupported() async => true; + + void _logWarning() { + logger.warning('Cannot send keepalives as SM is not available, the socket disallows whitespace pings and does not manage its own keepalives. Cannot guarantee that the connection survives.'); + } + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is SendPingEvent) { + logger.finest('Received ping event.'); + final attrs = getAttributes(); + final socket = attrs.getSocket(); + + if (socket.managesKeepalives()) { + logger.finest('Not sending ping as the socket manages it.'); + return; + } + + final stream = attrs.getManagerById(smManager) as StreamManagementManager?; + if (stream != null) { + if (stream.isStreamManagementEnabled() /*&& stream.getUnackedStanzaCount() > 0*/) { + logger.finest('Sending an ack ping as Stream Management is enabled'); + stream.sendAckRequestPing(); + } else if (attrs.getSocket().whitespacePingAllowed()) { + logger.finest('Sending a whitespace ping as Stream Management is not enabled'); + attrs.getConnection().sendWhitespacePing(); + } else { + _logWarning(); + } + } else { + if (attrs.getSocket().whitespacePingAllowed()) { + attrs.getConnection().sendWhitespacePing(); + } else { + _logWarning(); + } + } + } + } +} diff --git a/moxxmpp/lib/src/presence.dart b/moxxmpp/lib/src/presence.dart new file mode 100644 index 0000000..e8140f4 --- /dev/null +++ b/moxxmpp/lib/src/presence.dart @@ -0,0 +1,158 @@ +import 'package:moxxmpp/src/connection.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0115.dart'; +import 'package:moxxmpp/src/xeps/xep_0414.dart'; + +class PresenceManager extends XmppManagerBase { + + PresenceManager() : _capabilityHash = null, super(); + String? _capabilityHash; + + @override + String getId() => presenceManager; + + @override + String getName() => 'PresenceManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'presence', + callback: _onPresence, + ) + ]; + + @override + List getDiscoFeatures() => [ capsXmlns ]; + + @override + Future isSupported() async => true; + + Future _onPresence(Stanza presence, StanzaHandlerData state) async { + final attrs = getAttributes(); + switch (presence.type) { + case 'subscribe': + case 'subscribed': { + attrs.sendEvent( + SubscriptionRequestReceivedEvent(from: JID.fromString(presence.from!)), + ); + return state.copyWith(done: true); + } + default: break; + } + + if (presence.from != null) { + logger.finest("Received presence from '${presence.from}'"); + + getAttributes().sendEvent(PresenceReceivedEvent(JID.fromString(presence.from!), presence)); + return state.copyWith(done: true); + } + + return state; + } + + /// Returns the capability hash. + Future getCapabilityHash() async { + final manager = getAttributes().getManagerById(discoManager)! as DiscoManager; + _capabilityHash ??= await calculateCapabilityHash( + DiscoInfo( + manager.getRegisteredDiscoFeatures(), + manager.getIdentities(), + [], + getAttributes().getFullJID(), + ), + getHashByName('sha-1')!, + ); + + return _capabilityHash!; + } + + /// Sends the initial presence to enable receiving messages. + Future sendInitialPresence() async { + final attrs = getAttributes(); + attrs.sendNonza( + Stanza.presence( + from: attrs.getFullJID().toString(), + children: [ + XMLNode( + tag: 'show', + text: 'chat', + ), + XMLNode.xmlns( + tag: 'c', + xmlns: capsXmlns, + attributes: { + 'hash': 'sha-1', + 'node': 'http://moxxy.im', + 'ver': await getCapabilityHash() + }, + ) + ], + ), + ); + } + + /// Send an unavailable presence with no 'to' attribute. + void sendUnavailablePresence() { + getAttributes().sendStanza( + Stanza.presence( + type: 'unavailable', + ), + addFrom: StanzaFromType.full, + ); + } + + /// Sends a subscription request to [to]. + void sendSubscriptionRequest(String to) { + getAttributes().sendStanza( + Stanza.presence( + type: 'subscribe', + to: to, + ), + addFrom: StanzaFromType.none, + ); + } + + /// Sends an unsubscription request to [to]. + void sendUnsubscriptionRequest(String to) { + getAttributes().sendStanza( + Stanza.presence( + type: 'unsubscribe', + to: to, + ), + addFrom: StanzaFromType.none, + ); + } + + /// Accept a presence subscription request for [to]. + void sendSubscriptionRequestApproval(String to) { + getAttributes().sendStanza( + Stanza.presence( + type: 'subscribed', + to: to, + ), + addFrom: StanzaFromType.none, + ); + } + + /// Reject a presence subscription request for [to]. + void sendSubscriptionRequestRejection(String to) { + getAttributes().sendStanza( + Stanza.presence( + type: 'unsubscribed', + to: to, + ), + addFrom: StanzaFromType.none, + ); + } +} diff --git a/moxxmpp/lib/src/reconnect.dart b/moxxmpp/lib/src/reconnect.dart new file mode 100644 index 0000000..6eb3a4a --- /dev/null +++ b/moxxmpp/lib/src/reconnect.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:synchronized/synchronized.dart'; + +abstract class ReconnectionPolicy { + + ReconnectionPolicy() + : _shouldAttemptReconnection = false, + _isReconnecting = false, + _isReconnectingLock = Lock(); + /// Function provided by XmppConnection that allows the policy + /// to perform a reconnection. + Future Function()? performReconnect; + /// Function provided by XmppConnection that allows the policy + /// to say that we lost the connection. + void Function()? triggerConnectionLost; + /// Indicate if should try to reconnect. + bool _shouldAttemptReconnection; + /// Indicate if a reconnection attempt is currently running. + bool _isReconnecting; + /// And the corresponding lock + final Lock _isReconnectingLock; + + /// Called by XmppConnection to register the policy. + void register(Future Function() performReconnect, void Function() triggerConnectionLost) { + this.performReconnect = performReconnect; + this.triggerConnectionLost = triggerConnectionLost; + + unawaited(reset()); + } + + /// In case the policy depends on some internal state, this state must be reset + /// to an initial state when reset is called. In case timers run, they must be + /// terminated. + Future reset(); + + /// Called by the XmppConnection when the reconnection failed. + Future onFailure() async {} + + /// Caled by the XmppConnection when the reconnection was successful. + Future onSuccess(); + + bool get shouldReconnect => _shouldAttemptReconnection; + + /// Set whether a reconnection attempt should be made. + void setShouldReconnect(bool value) { + _shouldAttemptReconnection = value; + } + + /// Returns true if the manager is currently triggering a reconnection. If not, returns + /// false. + Future isReconnectionRunning() async { + return _isReconnectingLock.synchronized(() => _isReconnecting); + } + + /// Set the _isReconnecting state to [value]. + @protected + Future setIsReconnecting(bool value) async { + await _isReconnectingLock.synchronized(() async { + _isReconnecting = value; + }); + } + + @protected + Future testAndSetIsReconnecting() async { + return _isReconnectingLock.synchronized(() { + if (_isReconnecting) { + return false; + } else { + _isReconnecting = true; + return true; + } + }); + } +} + +/// A simple reconnection strategy: Make the reconnection delays exponentially longer +/// for every failed attempt. +class ExponentialBackoffReconnectionPolicy extends ReconnectionPolicy { + + ExponentialBackoffReconnectionPolicy() + : _counter = 0, + _log = Logger('ExponentialBackoffReconnectionPolicy'), + super(); + int _counter; + Timer? _timer; + final Logger _log; + + /// Called when the backoff expired + Future _onTimerElapsed() async { + final isReconnecting = await isReconnectionRunning(); + if (shouldReconnect) { + if (!isReconnecting) { + await performReconnect!(); + } else { + // Should never happen. + _log.fine('Backoff timer expired but reconnection is running, so doing nothing.'); + } + } + } + + @override + Future reset() async { + _log.finest('Resetting internal state'); + _counter = 0; + await setIsReconnecting(false); + + if (_timer != null) { + _timer!.cancel(); + _timer = null; + } + } + + @override + Future onFailure() async { + _log.finest('Failure occured. Starting exponential backoff'); + _counter++; + await setIsReconnecting(true); + + if (_timer != null) { + _timer!.cancel(); + } + + // Wait at max 80 seconds. + final seconds = min(pow(2, _counter).toInt(), 80); + _timer = Timer(Duration(seconds: seconds), _onTimerElapsed); + } + + @override + Future onSuccess() async { + await reset(); + } +} + +/// A stub reconnection policy for tests +@visibleForTesting +class TestingReconnectionPolicy extends ReconnectionPolicy { + TestingReconnectionPolicy() : super(); + + @override + Future onSuccess() async {} + + @override + Future onFailure() async {} + + @override + Future reset() async {} +} diff --git a/moxxmpp/lib/src/rfcs/rfc_2782.dart b/moxxmpp/lib/src/rfcs/rfc_2782.dart new file mode 100644 index 0000000..c000c6a --- /dev/null +++ b/moxxmpp/lib/src/rfcs/rfc_2782.dart @@ -0,0 +1,22 @@ +/*import 'package:moxdns/moxdns.dart'; + +/// Sorts the SRV records according to priority and weight. +int srvRecordSortComparator(SrvRecord a, SrvRecord b) { + if (a.priority < b.priority) { + return -1; + } else { + if (a.priority > b.priority) { + return 1; + } + + // a.priority == b.priority + if (a.weight < b.weight) { + return -1; + } else if (a.weight > b.weight) { + return 1; + } else { + return 0; + } + } +} +*/ diff --git a/moxxmpp/lib/src/rfcs/rfc_4790.dart b/moxxmpp/lib/src/rfcs/rfc_4790.dart new file mode 100644 index 0000000..5a83074 --- /dev/null +++ b/moxxmpp/lib/src/rfcs/rfc_4790.dart @@ -0,0 +1,26 @@ +/// A sort comparator using the i;octet collation defined by RFC 4790 +// TODO(Unknown): Maybe enforce utf8? +int ioctetSortComparator(String a, String b) { + if (a.isEmpty && b.isEmpty) { + return 0; + } + + if (a.isEmpty && b.isNotEmpty) { + return -1; + } + + if (a.isNotEmpty && b.isEmpty) { + return 1; + } + + if (a[0] == b[0]) { + return ioctetSortComparator(a.substring(1), b.substring(1)); + } + + // TODO(Unknown): Is this correct? + if (a.codeUnitAt(0) < b.codeUnitAt(0)) { + return -1; + } + + return 1; +} diff --git a/moxxmpp/lib/src/roster.dart b/moxxmpp/lib/src/roster.dart new file mode 100644 index 0000000..ffe9736 --- /dev/null +++ b/moxxmpp/lib/src/roster.dart @@ -0,0 +1,322 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/types/error.dart'; + +const rosterErrorNoQuery = 1; +const rosterErrorNonResult = 2; + +class XmppRosterItem { + + XmppRosterItem({ required this.jid, required this.subscription, this.ask, this.name, this.groups = const [] }); + final String jid; + final String? name; + final String subscription; + final String? ask; + final List groups; +} + +enum RosterRemovalResult { + okay, + error, + itemNotFound +} + +class RosterRequestResult { + + RosterRequestResult({ required this.items, this.ver }); + List items; + String? ver; +} + +class RosterPushEvent extends XmppEvent { + + RosterPushEvent({ required this.item, this.ver }); + final XmppRosterItem item; + final String? ver; +} + +/// A Stub feature negotiator for finding out whether roster versioning is supported. +class RosterFeatureNegotiator extends XmppFeatureNegotiatorBase { + RosterFeatureNegotiator() : _supported = false, super(11, false, rosterVersioningXmlns, rosterNegotiator); + + /// True if rosterVersioning is supported. False otherwise. + bool _supported; + bool get isSupported => _supported; + + @override + Future negotiate(XMLNode nonza) async { + // negotiate is only called when the negotiator matched, meaning the server + // advertises roster versioning. + _supported = true; + state = NegotiatorState.done; + } + + @override + void reset() { + _supported = false; + + super.reset(); + } +} + +/// This manager requires a RosterFeatureNegotiator to be registered. +class RosterManager extends XmppManagerBase { + + RosterManager() : _rosterVersion = null, super(); + String? _rosterVersion; + + @override + String getId() => rosterManager; + + @override + String getName() => 'RosterManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'iq', + tagName: 'query', + tagXmlns: rosterXmlns, + callback: _onRosterPush, + ) + ]; + + @override + Future isSupported() async => true; + + /// Override-able functions + Future commitLastRosterVersion(String version) async {} + Future loadLastRosterVersion() async {} + + void setRosterVersion(String ver) { + assert(_rosterVersion == null, 'A roster version must not be empty'); + + _rosterVersion = ver; + } + + Future _onRosterPush(Stanza stanza, StanzaHandlerData state) async { + final attrs = getAttributes(); + final from = stanza.attributes['from'] as String?; + final selfJid = attrs.getConnectionSettings().jid; + + logger.fine('Received roster push'); + + // Only allow the push if the from attribute is either + // - empty, i.e. not set + // - a full JID of our own + if (from != null && JID.fromString(from).toBare() != selfJid) { + logger.warning('Roster push invalid! Unexpected from attribute: ${stanza.toXml()}'); + return state.copyWith(done: true); + } + + final query = stanza.firstTag('query', xmlns: rosterXmlns)!; + final item = query.firstTag('item'); + + if (item == null) { + logger.warning('Received empty roster push'); + return state.copyWith(done: true); + } + + if (query.attributes['ver'] != null) { + final ver = query.attributes['ver']! as String; + await commitLastRosterVersion(ver); + _rosterVersion = ver; + } + + attrs.sendEvent(RosterPushEvent( + item: XmppRosterItem( + jid: item.attributes['jid']! as String, + subscription: item.attributes['subscription']! as String, + ask: item.attributes['ask'] as String?, + name: item.attributes['name'] as String?, + ), + ver: query.attributes['ver'] as String?, + ),); + await attrs.sendStanza(stanza.reply()); + + return state.copyWith(done: true); + } + + /// Shared code between requesting rosters without and with roster versioning, if + /// the server deems a regular roster response more efficient than n roster pushes. + Future> _handleRosterResponse(XMLNode? query) async { + final List items; + if (query != null) { + items = query.children.map((item) => XmppRosterItem( + name: item.attributes['name'] as String?, + jid: item.attributes['jid']! as String, + subscription: item.attributes['subscription']! as String, + ask: item.attributes['ask'] as String?, + groups: item.findTags('group').map((groupNode) => groupNode.innerText()).toList(), + ),).toList(); + + if (query.attributes['ver'] != null) { + final ver_ = query.attributes['ver']! as String; + await commitLastRosterVersion(ver_); + _rosterVersion = ver_; + } + } else { + logger.warning('Server response to roster request without roster versioning does not contain a element, while the type is not error. This violates RFC6121'); + return MayFail.failure(rosterErrorNoQuery); + } + + final ver = query.attributes['ver'] as String?; + if (ver != null) { + _rosterVersion = ver; + await commitLastRosterVersion(ver); + } + + return MayFail.success( + RosterRequestResult( + items: items, + ver: ver, + ), + ); + + } + + /// Requests the roster following RFC 6121 without using roster versioning. + Future> requestRoster() async { + final attrs = getAttributes(); + final response = await attrs.sendStanza( + Stanza.iq( + type: 'get', + children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: rosterXmlns, + ) + ], + ), + ); + + if (response.attributes['type'] != 'result') { + logger.warning('Error requesting roster without roster versioning: ${response.toXml()}'); + return MayFail.failure(rosterErrorNonResult); + } + + final query = response.firstTag('query', xmlns: rosterXmlns); + return _handleRosterResponse(query); + } + + /// Requests a series of roster pushes according to RFC6121. Requires that the server + /// advertises urn:xmpp:features:rosterver in the stream features. + Future> requestRosterPushes() async { + if (_rosterVersion == null) { + await loadLastRosterVersion(); + } + + final attrs = getAttributes(); + final result = await attrs.sendStanza( + Stanza.iq( + type: 'get', + children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: rosterXmlns, + attributes: { + 'ver': _rosterVersion ?? '' + }, + ) + ], + ), + ); + + if (result.attributes['type'] != 'result') { + logger.warning('Requesting roster pushes failed: ${result.toXml()}'); + return MayFail.failure(rosterErrorNonResult); + } + + final query = result.firstTag('query', xmlns: rosterXmlns); + return _handleRosterResponse(query); + } + + bool rosterVersioningAvailable() { + return getAttributes().getNegotiatorById(rosterNegotiator)!.isSupported; + } + + /// Attempts to add [jid] with a title of [title] and groups [groups] to the roster. + /// Returns true if the process was successful, false otherwise. + Future addToRoster(String jid, String title, { List? groups }) async { + final attrs = getAttributes(); + final response = await attrs.sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: rosterXmlns, + children: [ + XMLNode( + tag: 'item', + attributes: { + 'jid': jid, + ...title == jid.split('@')[0] ? {} : { 'name': title } + }, + children: (groups ?? []).map((group) => XMLNode(tag: 'group', text: group)).toList(), + ) + ], + ) + ], + ), + ); + + if (response.attributes['type'] != 'result') { + logger.severe('Error adding $jid to roster: $response'); + return false; + } + + return true; + } + + /// Attempts to remove [jid] from the roster. Returns true if the process was successful, + /// false otherwise. + Future removeFromRoster(String jid) async { + final attrs = getAttributes(); + final response = await attrs.sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: rosterXmlns, + children: [ + XMLNode( + tag: 'item', + attributes: { + 'jid': jid, + 'subscription': 'remove' + }, + ) + ], + ) + ], + ), + ); + + if (response.attributes['type'] != 'result') { + logger.severe('Failed to remove roster item: ${response.toXml()}'); + + final error = response.firstTag('error')!; + final notFound = error.firstTag('item-not-found') != null; + + if (notFound) { + logger.warning('Item was not found'); + return RosterRemovalResult.itemNotFound; + } + + return RosterRemovalResult.error; + } + + return RosterRemovalResult.okay; + } +} diff --git a/moxxmpp/lib/src/routing.dart b/moxxmpp/lib/src/routing.dart new file mode 100644 index 0000000..7e9573f --- /dev/null +++ b/moxxmpp/lib/src/routing.dart @@ -0,0 +1,6 @@ +enum RoutingState { + error, + preConnection, + negotiating, + handleStanzas +} diff --git a/moxxmpp/lib/src/settings.dart b/moxxmpp/lib/src/settings.dart new file mode 100644 index 0000000..8479065 --- /dev/null +++ b/moxxmpp/lib/src/settings.dart @@ -0,0 +1,10 @@ +import 'package:moxxmpp/src/jid.dart'; + +class ConnectionSettings { + + ConnectionSettings({ required this.jid, required this.password, required this.useDirectTLS, required this.allowPlainAuth }); + final JID jid; + final String password; + final bool useDirectTLS; + final bool allowPlainAuth; +} diff --git a/moxxmpp/lib/src/socket.dart b/moxxmpp/lib/src/socket.dart new file mode 100644 index 0000000..c16416a --- /dev/null +++ b/moxxmpp/lib/src/socket.dart @@ -0,0 +1,343 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:logging/logging.dart'; +//import 'package:moxdns/moxdns.dart'; +//import 'package:moxxmpp/src/rfcs/rfc_2782.dart'; + +// NOTE: https://www.iana.org/assignments/tls-extensiontype-values/tls-extensiontype-values.xhtml#alpn-protocol-ids +const xmppClientALPNId = 'xmpp-client'; + +abstract class XmppSocketEvent {} + +/// Triggered by the socket when an error occurs. +class XmppSocketErrorEvent extends XmppSocketEvent { + + XmppSocketErrorEvent(this.error); + final Object error; +} + +/// Triggered when the socket is closed +class XmppSocketClosureEvent extends XmppSocketEvent {} + +/// This class is the base for a socket that XmppConnection can use. +abstract class BaseSocketWrapper { + /// This must return the unbuffered string stream that the socket receives. + Stream getDataStream(); + + /// This must return events generated by the socket. + /// See sub-classes of [XmppSocketEvent] for possible events. + Stream getEventStream(); + + /// This must close the socket but not the streams so that the same class can be + /// reused by calling [this.connect] again. + void close(); + + /// Write [data] into the socket. If [redact] is not null, then [redact] will be + /// logged instead of [data]. + void write(String data, { String? redact }); + + /// This must connect to [host]:[port] and initialize the streams accordingly. + /// [domain] is the domain that TLS should be validated against, in case the Socket + /// provides TLS encryption. Returns true if the connection has been successfully + /// established. Returns false if the connection has failed. + Future connect(String domain, { String? host, int? port }); + + /// Returns true if the socket is secured, e.g. using TLS. + bool isSecure(); + + /// Upgrades the connection into a secure version, e.g. by performing a TLS upgrade. + /// May do nothing if the connection is always secure. + /// Returns true if the socket has been successfully upgraded. False otherwise. + Future secure(String domain); + + /// Returns true if whitespace pings are allowed. False if not. + bool whitespacePingAllowed(); + + /// Returns true if it manages its own keepalive pings, like websockets. False if not. + bool managesKeepalives(); + + /// Brings the socket into a state that allows it to close without triggering any errors + /// to the XmppConnection. + void prepareDisconnect() {} +} + +/// TCP socket implementation for XmppConnection +/*class TCPSocketWrapper extends BaseSocketWrapper { + + TCPSocketWrapper(this._logData) + : _log = Logger('TCPSocketWrapper'), + _dataStream = StreamController.broadcast(), + _eventStream = StreamController.broadcast(), + _secure = false, + _ignoreSocketClosure = false; + Socket? _socket; + bool _ignoreSocketClosure; + final StreamController _dataStream; + final StreamController _eventStream; + StreamSubscription? _socketSubscription; + + final Logger _log; + final bool _logData; + + bool _secure; + + @override + bool isSecure() => _secure; + + @override + bool whitespacePingAllowed() => true; + + @override + bool managesKeepalives() => false; + + /// Allow the socket to be destroyed by cancelling internal subscriptions. + void destroy() { + _socketSubscription?.cancel(); + } + + bool _onBadCertificate(dynamic certificate, String domain) { + _log.fine('Bad certificate: ${certificate.toString()}'); + //final isExpired = certificate.endValidity.isAfter(DateTime.now()); + // TODO(Unknown): Either validate the certificate ourselves or use a platform native + // hostname verifier (or Dart adds it themselves) + return false; + } + + Future _xep368Connect(String domain) async { + // TODO(Unknown): Maybe do DNSSEC one day + final results = await MoxdnsPlugin.srvQuery('_xmpps-client._tcp.$domain', false); + if (results.isEmpty) { + return false; + } + + results.sort(srvRecordSortComparator); + for (final srv in results) { + try { + _log.finest('Attempting secure connection to ${srv.target}:${srv.port}...'); + _ignoreSocketClosure = true; + _socket = await SecureSocket.connect( + srv.target, + srv.port, + timeout: const Duration(seconds: 5), + supportedProtocols: const [ xmppClientALPNId ], + onBadCertificate: (cert) => _onBadCertificate(cert, domain), + ); + + _ignoreSocketClosure = false; + _secure = true; + _log.finest('Success!'); + return true; + } on SocketException catch(e) { + _log.finest('Failure! $e'); + _ignoreSocketClosure = false; + } + } + + return false; + } + + Future _rfc6120Connect(String domain) async { + // TODO(Unknown): Maybe do DNSSEC one day + final results = await MoxdnsPlugin.srvQuery('_xmpp-client._tcp.$domain', false); + results.sort(srvRecordSortComparator); + + for (final srv in results) { + try { + _log.finest('Attempting connection to ${srv.target}:${srv.port}...'); + _ignoreSocketClosure = true; + _socket = await Socket.connect( + srv.target, + srv.port, + timeout: const Duration(seconds: 5), + ); + + _ignoreSocketClosure = false; + _log.finest('Success!'); + return true; + } on SocketException catch(e) { + _log.finest('Failure! $e'); + _ignoreSocketClosure = false; + continue; + } + } + + return _rfc6120FallbackConnect(domain); + } + + /// Connect to [host] with port [port] and returns true if the connection + /// was successfully established. Does not setup the streams as this has + /// to be done by the caller. + Future _hostPortConnect(String host, int port) async { + try { + _log.finest('Attempting fallback connection to $host:$port...'); + _ignoreSocketClosure = true; + _socket = await Socket.connect( + host, + port, + timeout: const Duration(seconds: 5), + ); + _log.finest('Success!'); + return true; + } on SocketException catch(e) { + _log.finest('Failure! $e'); + _ignoreSocketClosure = false; + return false; + } + } + + /// Connect to [domain] using the default C2S port of XMPP. Returns + /// true if the connection was successful. Does not setup the streams + /// as [_rfc6120FallbackConnect] should only be called from + /// [_rfc6120Connect], which already sets the streams up on a successful + /// connection. + Future _rfc6120FallbackConnect(String domain) async { + return _hostPortConnect(domain, 5222); + } + + @override + Future secure(String domain) async { + if (_secure) { + _log.warning('Connection is already marked as secure. Doing nothing'); + return true; + } + + if (_socket == null) { + _log.severe('Failed to secure socket since _socket is null'); + return false; + } + + _ignoreSocketClosure = true; + + try { + _socket = await SecureSocket.secure( + _socket!, + supportedProtocols: const [ xmppClientALPNId ], + onBadCertificate: (cert) => _onBadCertificate(cert, domain), + ); + + _secure = true; + _ignoreSocketClosure = false; + _setupStreams(); + return true; + } on SocketException { + _ignoreSocketClosure = false; + return false; + } + } + + void _setupStreams() { + if (_socket == null) { + _log.severe('Failed to setup streams as _socket is null'); + return; + } + + _socketSubscription = _socket!.listen( + (List event) { + final data = utf8.decode(event); + if (_logData) { + _log.finest('<== $data'); + } + _dataStream.add(data); + }, + onError: (Object error) { + _log.severe(error.toString()); + _eventStream.add(XmppSocketErrorEvent(error)); + }, + ); + // ignore: implicit_dynamic_parameter + _socket!.done.then((_) { + if (!_ignoreSocketClosure) { + _eventStream.add(XmppSocketClosureEvent()); + } + }); + } + + @override + Future connect(String domain, { String? host, int? port }) async { + _ignoreSocketClosure = false; + _secure = false; + + // Connection order: + // 1. host:port, if given + // 2. XEP-0368 + // 3. RFC 6120 + // 4. RFC 6120 fallback + + if (host != null && port != null) { + _log.finest('Specific host and port given'); + if (await _hostPortConnect(host, port)) { + _setupStreams(); + return true; + } + } + + if (await _xep368Connect(domain)) { + _setupStreams(); + return true; + } + + // NOTE: _rfc6120Connect already attempts the fallback + if (await _rfc6120Connect(domain)) { + _setupStreams(); + return true; + } + + return false; + } + + @override + void close() { + if (_socketSubscription != null) { + _log.finest('Closing socket subscription'); + _socketSubscription!.cancel(); + } + + if (_socket == null) { + _log.warning('Failed to close socket since _socket is null'); + return; + } + + _ignoreSocketClosure = true; + try { + _socket!.close(); + } catch(e) { + _log.warning('Closing socket threw exception: $e'); + } + _ignoreSocketClosure = false; + } + + @override + Stream getDataStream() => _dataStream.stream.asBroadcastStream(); + + @override + Stream getEventStream() => _eventStream.stream.asBroadcastStream(); + + @override + void write(Object? data, { String? redact }) { + if (_socket == null) { + _log.severe('Failed to write to socket as _socket is null'); + return; + } + + if (data != null && data is String && _logData) { + if (redact != null) { + _log.finest('**> $redact'); + } else { + _log.finest('==> $data'); + } + } + + try { + _socket!.write(data); + } on SocketException catch (e) { + _log.severe(e); + _eventStream.add(XmppSocketErrorEvent(e)); + } + } + + @override + void prepareDisconnect() { + _ignoreSocketClosure = true; + } +}*/ diff --git a/moxxmpp/lib/src/stanza.dart b/moxxmpp/lib/src/stanza.dart new file mode 100644 index 0000000..cc60da9 --- /dev/null +++ b/moxxmpp/lib/src/stanza.dart @@ -0,0 +1,131 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class Stanza extends XMLNode { + + Stanza({ this.to, this.from, this.type, this.id, List children = const [], required String tag, Map attributes = const {} }) : super( + tag: tag, + attributes: { + ...attributes, + ...type != null ? { 'type': type } : {}, + ...id != null ? { 'id': id } : {}, + ...to != null ? { 'to': to } : {}, + ...from != null ? { 'from': from } : {}, + 'xmlns': stanzaXmlns + }, + children: children, + ); + + factory Stanza.iq({ String? to, String? from, String? type, String? id, List children = const [], Map? attributes = const {} }) { + return Stanza( + tag: 'iq', + from: from, + to: to, + id: id, + type: type, + attributes: { + ...attributes!, + 'xmlns': stanzaXmlns + }, + children: children, + ); + } + + factory Stanza.presence({ String? to, String? from, String? type, String? id, List children = const [], Map? attributes = const {} }) { + return Stanza( + tag: 'presence', + from: from, + to: to, + id: id, + type: type, + attributes: { + ...attributes!, + 'xmlns': stanzaXmlns + }, + children: children, + ); + } + factory Stanza.message({ String? to, String? from, String? type, String? id, List children = const [], Map? attributes = const {} }) { + return Stanza( + tag: 'message', + from: from, + to: to, + id: id, + type: type, + attributes: { + ...attributes!, + 'xmlns': stanzaXmlns + }, + children: children, + ); + } + + factory Stanza.fromXMLNode(XMLNode node) { + return Stanza( + to: node.attributes['to'] as String?, + from: node.attributes['from'] as String?, + id: node.attributes['id'] as String?, + tag: node.tag, + type: node.attributes['type'] as String?, + children: node.children, + // TODO(Unknown): Remove to, from, id, and type + // TODO(Unknown): Not sure if this is the correct way to approach this + attributes: node.attributes + .map((String key, dynamic value) { + return MapEntry(key, value.toString()); + }), + ); + } + + String? to; + String? from; + String? type; + String? id; + + Stanza copyWith({ String? id, String? from, String? to, String? type, List? children }) { + return Stanza( + tag: tag, + to: to ?? this.to, + from: from ?? this.from, + id: id ?? this.id, + type: type ?? this.type, + children: children ?? this.children, + ); + } + + Stanza reply({ List children = const [] }) { + return copyWith( + from: attributes['to'] as String?, + to: attributes['from'] as String?, + type: tag == 'iq' ? 'result' : attributes['type'] as String?, + children: children, + ); + } + + Stanza errorReply(String type, String condition, { String? text }) { + return copyWith( + from: attributes['to'] as String?, + to: attributes['from'] as String?, + type: 'error', + children: [ + XMLNode( + tag: 'error', + attributes: { 'type': type }, + children: [ + XMLNode.xmlns( + tag: condition, + xmlns: fullStanzaXmlns, + children: text != null ?[ + XMLNode.xmlns( + tag: 'text', + xmlns: fullStanzaXmlns, + text: text, + ) + ] : [], + ) + ], + ) + ], + ); + } +} diff --git a/moxxmpp/lib/src/stringxml.dart b/moxxmpp/lib/src/stringxml.dart new file mode 100644 index 0000000..26c1c5b --- /dev/null +++ b/moxxmpp/lib/src/stringxml.dart @@ -0,0 +1,136 @@ +import 'package:xml/xml.dart'; + +class XMLNode { + + XMLNode({ + required this.tag, + this.attributes = const {}, + this.children = const [], + this.closeTag = true, + this.text, + this.isDeclaration = false, + }); + XMLNode.xmlns({ + required this.tag, + required String xmlns, + Map attributes = const {}, + this.children = const [], + this.closeTag = true, + this.text, + }) : attributes = { 'xmlns': xmlns, ...attributes }, isDeclaration = false; + /// Because this API is better ;) + /// Don't use in production. Just for testing + factory XMLNode.fromXmlElement(XmlElement element) { + final attributes = {}; + + for (final attribute in element.attributes) { + attributes[attribute.name.qualified] = attribute.value; + } + + if (element.childElements.isEmpty) { + return XMLNode( + tag: element.name.qualified, + attributes: attributes, + text: element.innerText, + ); + } else { + return XMLNode( + tag: element.name.qualified, + attributes: attributes, + children: element.childElements.toList().map(XMLNode.fromXmlElement).toList(), + ); + } + } + /// Just for testing purposes + factory XMLNode.fromString(String str) { + return XMLNode.fromXmlElement( + XmlDocument.parse(str).firstElementChild!, + ); + } + final String tag; + Map attributes; + List children; + bool closeTag; + String? text; + bool isDeclaration; + + /// Adds a child to this node. + void addChild(XMLNode child) { + children.add(child); + } + + /// Renders the attributes of the node into "attr1=\"value\" attr2=...". + String renderAttributes() { + return attributes.keys.map((String key) { + final dynamic value = attributes[key]; + assert(value is String || value is int, 'XML values must either be string or int'); + if (value is String) { + return "$key='$value'"; + } else { + return '$key=$value'; + } + }).join(' '); + } + + /// Renders the entire node, including its children, into an XML string. + String toXml() { + final decl = isDeclaration ? '?' : ''; + if (children.isEmpty) { + if (text != null && text!.isNotEmpty) { + final attrString = attributes.isEmpty ? '' : ' ${renderAttributes()}'; + return '<$tag$attrString>$text'; + } else { + return '<$decl$tag ${renderAttributes()}${closeTag ? " />" : "$decl>"}'; + } + } else { + final childXml = children.map((child) => child.toXml()).join(); + final xml = '<$decl$tag ${renderAttributes()}$decl>$childXml'; + return xml + (closeTag ? '' : ''); + } + } + + /// Returns the first child for which [test] returns true. If none is found, returns + /// null. + XMLNode? _firstTag(bool Function(XMLNode) test) { + try { + return children.firstWhere(test); + } catch(e) { + return null; + } + } + + /// Returns the first xml node that matches the description: + /// - node's tag is equal to [tag] + /// - (optional) node's xmlns attribute is equal to [xmlns] + /// Returns null if none is found. + XMLNode? firstTag(String tag, { String? xmlns}) { + return _firstTag((node) { + if (xmlns != null) { + return node.tag == tag && node.attributes['xmlns'] == xmlns; + } + + return node.tag == tag; + }); + } + + /// Returns the first child whose xmlns attribute is equal to [xmlns]. Returns null + /// if none is found. + XMLNode? firstTagByXmlns(String xmlns) { + return _firstTag((node) { + return node.attributes['xmlns'] == xmlns; + }); + } + + /// Returns all children whose tag is equal to [tag]. + List findTags(String tag, { String? xmlns }) { + return children.where((element) { + final xmlnsMatches = xmlns != null ? element.attributes['xmlns'] == xmlns : true; + return element.tag == tag && xmlnsMatches; + }).toList(); + } + + /// Returns the inner text of the node. If none is set, returns the "". + String innerText() { + return text ?? ''; + } +} diff --git a/moxxmpp/lib/src/types/error.dart b/moxxmpp/lib/src/types/error.dart new file mode 100644 index 0000000..a5e2ba0 --- /dev/null +++ b/moxxmpp/lib/src/types/error.dart @@ -0,0 +1,19 @@ +/// A wrapper class that can be used to indicate that a function may return a valid +/// instance of [T] but may also fail. +/// The way [MayFail] is intended to be used to to have function specific - or application +/// specific - error codes that can be either handled by code or be translated into a +/// localised error message for the user. +class MayFail { + + MayFail({ this.result, this.errorCode }); + MayFail.success(this.result); + MayFail.failure(this.errorCode); + T? result; + int? errorCode; + + bool isError() => result == null && errorCode != null; + + T getValue() => result!; + + int getErrorCode() => errorCode!; +} diff --git a/moxxmpp/lib/src/types/result.dart b/moxxmpp/lib/src/types/result.dart new file mode 100644 index 0000000..4a0984a --- /dev/null +++ b/moxxmpp/lib/src/types/result.dart @@ -0,0 +1,13 @@ +/// Class that is supposed to by used with a state type S and a value type V. +/// The state indicates if an action was successful or not, while the value +/// type indicates the return value, i.e. a result in a computation or the +/// actual error description. +class Result { + + Result(S state, V value) : _state = state, _value = value; + final S _state; + final V _value; + + S getState() => _state; + V getValue() => _value; +} diff --git a/moxxmpp/lib/src/types/resultv2.dart b/moxxmpp/lib/src/types/resultv2.dart new file mode 100644 index 0000000..06cabb9 --- /dev/null +++ b/moxxmpp/lib/src/types/resultv2.dart @@ -0,0 +1,13 @@ +class Result { + + const Result(this._data) : assert(_data is T || _data is V, 'Invalid data type: Must be either $T or $V'); + final dynamic _data; + + bool isType() => _data is S; + + S get() { + assert(_data is S, 'Data is not $S'); + + return _data as S; + } +} diff --git a/moxxmpp/lib/src/xeps/staging/extensible_file_thumbnails.dart b/moxxmpp/lib/src/xeps/staging/extensible_file_thumbnails.dart new file mode 100644 index 0000000..bf2dfd5 --- /dev/null +++ b/moxxmpp/lib/src/xeps/staging/extensible_file_thumbnails.dart @@ -0,0 +1,54 @@ +import 'package:moxxmpp/src/stringxml.dart'; + +/// NOTE: Specified by https://codeberg.org/moxxy/custom-xeps/src/branch/master/xep-xxxx-extensible-file-thumbnails.md + +const fileThumbnailsXmlns = 'proto:urn:xmpp:eft:0'; +const blurhashThumbnailType = '$fileThumbnailsXmlns:blurhash'; + +abstract class Thumbnail {} + +class BlurhashThumbnail extends Thumbnail { + + BlurhashThumbnail(this.hash); + final String hash; +} + +Thumbnail? parseFileThumbnailElement(XMLNode node) { + assert(node.attributes['xmlns'] == fileThumbnailsXmlns, 'Invalid element xmlns'); + assert(node.tag == 'file-thumbnail', 'Invalid element name'); + + switch (node.attributes['type']!) { + case blurhashThumbnailType: { + final hash = node.firstTag('blurhash')!.innerText(); + return BlurhashThumbnail(hash); + } + } + + return null; +} + +XMLNode? _fromThumbnail(Thumbnail thumbnail) { + if (thumbnail is BlurhashThumbnail) { + return XMLNode( + tag: 'blurhash', + text: thumbnail.hash, + ); + } + + return null; +} + +XMLNode constructFileThumbnailElement(Thumbnail thumbnail) { + final node = _fromThumbnail(thumbnail)!; + var type = ''; + if (thumbnail is BlurhashThumbnail) { + type = blurhashThumbnailType; + } + + return XMLNode.xmlns( + tag: 'file-thumbnail', + xmlns: fileThumbnailsXmlns, + attributes: { 'type': type }, + children: [ node ], + ); +} diff --git a/moxxmpp/lib/src/xeps/staging/file_upload_notification.dart b/moxxmpp/lib/src/xeps/staging/file_upload_notification.dart new file mode 100644 index 0000000..af3f564 --- /dev/null +++ b/moxxmpp/lib/src/xeps/staging/file_upload_notification.dart @@ -0,0 +1,72 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/xeps/xep_0446.dart'; + +/// NOTE: Specified by https://github.com/PapaTutuWawa/custom-xeps/blob/master/xep-xxxx-file-upload-notifications.md + +const fileUploadNotificationXmlns = 'proto:urn:xmpp:fun:0'; + +class FileUploadNotificationManager extends XmppManagerBase { + FileUploadNotificationManager() : super(); + + @override + String getId() => fileUploadNotificationManager; + + @override + String getName() => 'FileUploadNotificationManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'file-upload', + tagXmlns: fileUploadNotificationXmlns, + callback: _onFileUploadNotificationReceived, + priority: -99, + ), + StanzaHandler( + stanzaTag: 'message', + tagName: 'replaces', + tagXmlns: fileUploadNotificationXmlns, + callback: _onFileUploadNotificationReplacementReceived, + priority: -99, + ), + StanzaHandler( + stanzaTag: 'message', + tagName: 'cancelled', + tagXmlns: fileUploadNotificationXmlns, + callback: _onFileUploadNotificationCancellationReceived, + priority: -99, + ), + ]; + + @override + Future isSupported() async => true; + + Future _onFileUploadNotificationReceived(Stanza message, StanzaHandlerData state) async { + final funElement = message.firstTag('file-upload', xmlns: fileUploadNotificationXmlns)!; + return state.copyWith( + fun: FileMetadataData.fromXML( + funElement.firstTag('file', xmlns: fileMetadataXmlns)!, + ), + ); + } + + Future _onFileUploadNotificationReplacementReceived(Stanza message, StanzaHandlerData state) async { + final element = message.firstTag('replaces', xmlns: fileUploadNotificationXmlns)!; + return state.copyWith( + funReplacement: element.attributes['id']! as String, + ); + } + + Future _onFileUploadNotificationCancellationReceived(Stanza message, StanzaHandlerData state) async { + final element = message.firstTag('cancels', xmlns: fileUploadNotificationXmlns)!; + return state.copyWith( + funCancellation: element.attributes['id']! as String, + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0004.dart b/moxxmpp/lib/src/xeps/xep_0004.dart new file mode 100644 index 0000000..19387ae --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0004.dart @@ -0,0 +1,147 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class DataFormOption { + + const DataFormOption({ required this.value, this.label }); + final String? label; + final String value; + + XMLNode toXml() { + return XMLNode( + tag: 'option', + attributes: label != null ? { 'label': label } : {}, + children: [ + XMLNode( + tag: 'value', + text: value, + ) + ], + ); + } +} + +class DataFormField { + + const DataFormField({ + required this.options, + required this.values, + required this.isRequired, + this.varAttr, + this.type, + this.description, + this.label, + }); + final String? description; + final bool isRequired; + final List values; + final List options; + final String? type; + final String? varAttr; + final String? label; + + XMLNode toXml() { + return XMLNode( + tag: 'field', + attributes: { + ...varAttr != null ? { 'var': varAttr } : {}, + ...type != null ? { 'type': type } : {}, + ...label != null ? { 'label': label } : {} + }, + children: [ + ...description != null ? [XMLNode(tag: 'desc', text: description)] : [], + ...isRequired ? [XMLNode(tag: 'required')] : [], + ...values.map((value) => XMLNode(tag: 'value', text: value)).toList(), + ...options.map((option) => option.toXml()) + ], + ); + } +} + +class DataForm { + + const DataForm({ + required this.type, + required this.instructions, + required this.fields, + required this.reported, + required this.items, + this.title, + }); + final String type; + final String? title; + final List instructions; + final List fields; + final List reported; + final List> items; + + DataFormField? getFieldByVar(String varAttr) { + return firstWhereOrNull(fields, (field) => field.varAttr == varAttr); + } + + XMLNode toXml() { + return XMLNode.xmlns( + tag: 'x', + xmlns: dataFormsXmlns, + attributes: { + 'type': type + }, + children: [ + ...instructions.map((i) => XMLNode(tag: 'instruction', text: i)).toList(), + ...title != null ? [XMLNode(tag: 'title', text: title)] : [], + ...fields.map((field) => field.toXml()).toList(), + ...reported.map((report) => report.toXml()).toList(), + ...items.map((item) => XMLNode( + tag: 'item', + children: item.map((i) => i.toXml()).toList(), + ),).toList(), + ], + ); + } +} + +DataFormOption _parseDataFormOption(XMLNode option) { + return DataFormOption( + label: option.attributes['label'] as String?, + value: option.firstTag('value')!.innerText(), + ); +} + +DataFormField _parseDataFormField(XMLNode field) { + final desc = field.firstTag('desc')?.innerText(); + final isRequired = field.firstTag('required') != null; + final values = field.findTags('value').map((i) => i.innerText()).toList(); + final options = field.findTags('option').map(_parseDataFormOption).toList(); + + return DataFormField( + varAttr: field.attributes['var'] as String?, + type: field.attributes['type'] as String?, + options: options, + values: values, + isRequired: isRequired, + description: desc, + ); +} + +/// Parse a Data Form declaration. +DataForm parseDataForm(XMLNode x) { + assert(x.attributes['xmlns'] == dataFormsXmlns, 'Invalid element xmlns'); + assert(x.tag == 'x', 'Invalid element name'); + + final type = x.attributes['type']! as String; + final title = x.firstTag('title')?.innerText(); + final instructions = x.findTags('instructions').map((i) => i.innerText()).toList(); + final fields = x.findTags('field').map(_parseDataFormField).toList(); + final reported = x.firstTag('reported')?.findTags('field').map((i) => _parseDataFormField(i.firstTag('field')!)).toList() ?? []; + final items = x.findTags('item').map((i) => i.findTags('field').map(_parseDataFormField).toList()).toList(); + + return DataForm( + type: type, + instructions: instructions, + fields: fields, + reported: reported, + items: items, + title: title, + ); +} diff --git a/moxxmpp/lib/src/xeps/xep_0030/errors.dart b/moxxmpp/lib/src/xeps/xep_0030/errors.dart new file mode 100644 index 0000000..f11717d --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0030/errors.dart @@ -0,0 +1,7 @@ +abstract class DiscoError {} + +class UnknownDiscoError extends DiscoError {} + +class InvalidResponseDiscoError extends DiscoError {} + +class ErrorResponseDiscoError extends DiscoError {} diff --git a/moxxmpp/lib/src/xeps/xep_0030/helpers.dart b/moxxmpp/lib/src/xeps/xep_0030/helpers.dart new file mode 100644 index 0000000..5cb78fe --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0030/helpers.dart @@ -0,0 +1,25 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +// TODO(PapaTutuWawa): Move types into types.dart + +Stanza buildDiscoInfoQueryStanza(String entity, String? node) { + return Stanza.iq(to: entity, type: 'get', children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: discoInfoXmlns, + attributes: node != null ? { 'node': node } : {}, + ) + ],); +} + +Stanza buildDiscoItemsQueryStanza(String entity, { String? node }) { + return Stanza.iq(to: entity, type: 'get', children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: discoItemsXmlns, + attributes: node != null ? { 'node': node } : {}, + ) + ],); +} diff --git a/moxxmpp/lib/src/xeps/xep_0030/types.dart b/moxxmpp/lib/src/xeps/xep_0030/types.dart new file mode 100644 index 0000000..430c6d3 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0030/types.dart @@ -0,0 +1,46 @@ +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0004.dart'; + +class Identity { + + const Identity({ required this.category, required this.type, this.name, this.lang }); + final String category; + final String type; + final String? name; + final String? lang; + + XMLNode toXMLNode() { + return XMLNode( + tag: 'identity', + attributes: { + 'category': category, + 'type': type, + 'name': name, + ...lang == null ? {} : { 'xml:lang': lang } + }, + ); + } +} + +class DiscoInfo { + + const DiscoInfo( + this.features, + this.identities, + this.extendedInfo, + this.jid, + ); + final List features; + final List identities; + final List extendedInfo; + final JID jid; +} + +class DiscoItem { + + const DiscoItem({ required this.jid, this.node, this.name }); + final String jid; + final String? node; + final String? name; +} diff --git a/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart b/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart new file mode 100644 index 0000000..1aab955 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0030/xep_0030.dart @@ -0,0 +1,439 @@ +import 'dart:async'; +import 'package:meta/meta.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/presence.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/types/resultv2.dart'; +import 'package:moxxmpp/src/xeps/xep_0004.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/helpers.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0115.dart'; +import 'package:synchronized/synchronized.dart'; + +@immutable +class DiscoCacheKey { + + const DiscoCacheKey(this.jid, this.node); + final String jid; + final String? node; + + @override + bool operator ==(Object other) { + return other is DiscoCacheKey && jid == other.jid && node == other.node; + } + + @override + int get hashCode => jid.hashCode ^ node.hashCode; +} + +class DiscoManager extends XmppManagerBase { + + DiscoManager() + : _features = List.empty(growable: true), + _capHashCache = {}, + _capHashInfoCache = {}, + _discoInfoCache = {}, + _runningInfoQueries = {}, + _cacheLock = Lock(), + super(); + /// Our features + final List _features; + + // Map full JID to Capability hashes + final Map _capHashCache; + // Map capability hash to the disco info + final Map _capHashInfoCache; + // Map full JID to Disco Info + final Map _discoInfoCache; + // Mapping the full JID to a list of running requests + final Map>>> _runningInfoQueries; + // Cache lock + final Lock _cacheLock; + + @visibleForTesting + bool hasInfoQueriesRunning() => _runningInfoQueries.isNotEmpty; + + @visibleForTesting + List>> getRunningInfoQueries(DiscoCacheKey key) => _runningInfoQueries[key]!; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + tagName: 'query', + tagXmlns: discoInfoXmlns, + stanzaTag: 'iq', + callback: _onDiscoInfoRequest, + ), + StanzaHandler( + tagName: 'query', + tagXmlns: discoItemsXmlns, + stanzaTag: 'iq', + callback: _onDiscoItemsRequest, + ), + ]; + + @override + String getId() => discoManager; + + @override + String getName() => 'DiscoManager'; + + @override + List getDiscoFeatures() => [ discoInfoXmlns, discoItemsXmlns ]; + + @override + Future isSupported() async => true; + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is PresenceReceivedEvent) { + await _onPresence(event.jid, event.presence); + } else if (event is StreamResumeFailedEvent) { + await _cacheLock.synchronized(() async { + // Clear the cache + _discoInfoCache.clear(); + }); + } + } + + /// Adds a list of features to the possible disco info response. + /// This function only adds features that are not already present in the disco features. + void addDiscoFeatures(List features) { + for (final feat in features) { + if (!_features.contains(feat)) { + _features.add(feat); + } + } + } + + Future _onPresence(JID from, Stanza presence) async { + final c = presence.firstTag('c', xmlns: capsXmlns); + if (c == null) return; + + final info = CapabilityHashInfo( + c.attributes['ver']! as String, + c.attributes['node']! as String, + c.attributes['hash']! as String, + ); + + // Check if we already know of that cache + var cached = false; + await _cacheLock.synchronized(() async { + if (!_capHashCache.containsKey(info.ver)) { + cached = true; + } + }); + if (cached) return; + + // Request the cap hash + logger.finest("Received capability hash we don't know about. Requesting it..."); + final result = await discoInfoQuery(from.toString(), node: '${info.node}#${info.ver}'); + if (result.isType()) return; + + await _cacheLock.synchronized(() async { + _capHashCache[from.toString()] = info; + _capHashInfoCache[info.ver] = result.get(); + }); + } + + /// Returns the list of disco features registered. + List getRegisteredDiscoFeatures() => _features; + + /// May be overriden. Specifies the identities which will be returned in a disco info response. + List getIdentities() => const [ Identity(category: 'client', type: 'pc', name: 'moxxmpp', lang: 'en') ]; + + Future _onDiscoInfoRequest(Stanza stanza, StanzaHandlerData state) async { + if (stanza.type != 'get') return state; + + final presence = getAttributes().getManagerById(presenceManager)! as PresenceManager; + final query = stanza.firstTag('query')!; + final node = query.attributes['node'] as String?; + final capHash = await presence.getCapabilityHash(); + final isCapabilityNode = node == 'http://moxxy.im#$capHash'; + + if (!isCapabilityNode && node != null) { + await getAttributes().sendStanza(Stanza.iq( + to: stanza.from, + from: stanza.to, + id: stanza.id, + type: 'error', + children: [ + XMLNode.xmlns( + tag: 'query', + // TODO(PapaTutuWawa): Why are we copying the xmlns? + xmlns: query.attributes['xmlns']! as String, + attributes: { + 'node': node + }, + ), + XMLNode( + tag: 'error', + attributes: { + 'type': 'cancel' + }, + children: [ + XMLNode.xmlns( + tag: 'not-allowed', + xmlns: fullStanzaXmlns, + ) + ], + ) + ], + ) + ,); + + return state.copyWith(done: true); + } + + await getAttributes().sendStanza(stanza.reply( + children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: discoInfoXmlns, + attributes: { + ...!isCapabilityNode ? {} : { + 'node': 'http://moxxy.im#$capHash' + } + }, + children: [ + ...getIdentities().map((identity) => identity.toXMLNode()).toList(), + ..._features.map((feat) { + return XMLNode( + tag: 'feature', + attributes: { 'var': feat }, + ); + }).toList(), + ], + ), + ], + ),); + + return state.copyWith(done: true); + } + + Future _onDiscoItemsRequest(Stanza stanza, StanzaHandlerData state) async { + if (stanza.type != 'get') return state; + + final query = stanza.firstTag('query')!; + if (query.attributes['node'] != null) { + // TODO(Unknown): Handle the node we specified for XEP-0115 + await getAttributes().sendStanza( + Stanza.iq( + to: stanza.from, + from: stanza.to, + id: stanza.id, + type: 'error', + children: [ + XMLNode.xmlns( + tag: 'query', + // TODO(PapaTutuWawa): Why copy the xmlns? + xmlns: query.attributes['xmlns']! as String, + attributes: { + 'node': query.attributes['node']! as String, + }, + ), + XMLNode( + tag: 'error', + attributes: { + 'type': 'cancel' + }, + children: [ + XMLNode.xmlns( + tag: 'not-allowed', + xmlns: fullStanzaXmlns, + ), + ], + ), + ], + ), + ); + return state.copyWith(done: true); + } + + await getAttributes().sendStanza( + stanza.reply( + children: [ + XMLNode.xmlns( + tag: 'query', + xmlns: discoItemsXmlns, + ), + ], + ), + ); + return state.copyWith(done: true); + } + + Future _exitDiscoInfoCriticalSection(DiscoCacheKey key, Result result) async { + return _cacheLock.synchronized(() async { + // Complete all futures + for (final completer in _runningInfoQueries[key]!) { + completer.complete(result); + } + + // Add to cache if it is a result + if (result.isType()) { + _discoInfoCache[key] = result.get(); + } + + // Remove from the request cache + _runningInfoQueries.remove(key); + }); + } + + /// Sends a disco info query to the (full) jid [entity], optionally with node=[node]. + Future> discoInfoQuery(String entity, { String? node}) async { + final cacheKey = DiscoCacheKey(entity, node); + DiscoInfo? info; + Completer>? completer; + await _cacheLock.synchronized(() async { + // Check if we already know what the JID supports + if (_discoInfoCache.containsKey(cacheKey)) { + info = _discoInfoCache[cacheKey]; + } else { + // Is a request running? + if (_runningInfoQueries.containsKey(cacheKey)) { + completer = Completer(); + _runningInfoQueries[cacheKey]!.add(completer!); + } else { + _runningInfoQueries[cacheKey] = List.from(>[]); + } + } + }); + + if (info != null) { + return Result(info); + } else if (completer != null) { + return completer!.future; + } + + final stanza = await getAttributes().sendStanza( + buildDiscoInfoQueryStanza(entity, node), + ); + final query = stanza.firstTag('query'); + if (query == null) { + final result = Result(InvalidResponseDiscoError()); + await _exitDiscoInfoCriticalSection(cacheKey, result); + return result; + } + + final error = stanza.firstTag('error'); + if (error != null && stanza.attributes['type'] == 'error') { + final result = Result(ErrorResponseDiscoError()); + await _exitDiscoInfoCriticalSection(cacheKey, result); + return result; + } + + final features = List.empty(growable: true); + final identities = List.empty(growable: true); + + for (final element in query.children) { + if (element.tag == 'feature') { + features.add(element.attributes['var']! as String); + } else if (element.tag == 'identity') { + identities.add(Identity( + category: element.attributes['category']! as String, + type: element.attributes['type']! as String, + name: element.attributes['name'] as String?, + ),); + } + } + + final result = Result( + DiscoInfo( + features, + identities, + query.findTags('x', xmlns: dataFormsXmlns).map(parseDataForm).toList(), + JID.fromString(stanza.attributes['from']! as String), + ), + ); + await _exitDiscoInfoCriticalSection(cacheKey, result); + return result; + } + + /// Sends a disco items query to the (full) jid [entity], optionally with node=[node]. + Future>> discoItemsQuery(String entity, { String? node }) async { + final stanza = await getAttributes() + .sendStanza(buildDiscoItemsQueryStanza(entity, node: node)) as Stanza; + + final query = stanza.firstTag('query'); + if (query == null) return Result(InvalidResponseDiscoError()); + + final error = stanza.firstTag('error'); + if (error != null && stanza.type == 'error') { + //print("Disco Items error: " + error.toXml()); + return Result(ErrorResponseDiscoError()); + } + + final items = query.findTags('item').map((node) => DiscoItem( + jid: node.attributes['jid']! as String, + node: node.attributes['node'] as String?, + name: node.attributes['name'] as String?, + ),).toList(); + + return Result(items); + } + + /// Queries information about a jid based on its node and capability hash. + Future> discoInfoCapHashQuery(String jid, String node, String ver) async { + return discoInfoQuery(jid, node: '$node#$ver'); + } + + Future>> performDiscoSweep() async { + final attrs = getAttributes(); + final serverJid = attrs.getConnectionSettings().jid.domain; + final infoResults = List.empty(growable: true); + final result = await discoInfoQuery(serverJid); + if (result.isType()) { + final info = result.get(); + logger.finest('Discovered supported server features: ${info.features}'); + infoResults.add(info); + + attrs.sendEvent(ServerItemDiscoEvent(info)); + attrs.sendEvent(ServerDiscoDoneEvent()); + } else { + logger.warning('Failed to discover server features'); + return Result(UnknownDiscoError()); + } + + final response = await discoItemsQuery(serverJid); + if (response.isType>()) { + logger.finest('Discovered disco items form $serverJid'); + + // Query all items + final items = response.get>(); + for (final item in items) { + logger.finest('Querying info for ${item.jid}...'); + final itemInfoResult = await discoInfoQuery(item.jid); + if (itemInfoResult.isType()) { + final itemInfo = itemInfoResult.get(); + logger.finest('Received info for ${item.jid}'); + infoResults.add(itemInfo); + attrs.sendEvent(ServerItemDiscoEvent(itemInfo)); + } else { + logger.warning('Failed to discover info for ${item.jid}'); + } + } + } else { + logger.warning('Failed to discover items of $serverJid'); + } + + return Result(infoResults); + } + + /// A wrapper function around discoInfoQuery: Returns true if the entity with JID + /// [entity] supports the disco feature [feature]. If not, returns false. + Future supportsFeature(JID entity, String feature) async { + final info = await discoInfoQuery(entity.toString()); + if (info.isType()) return false; + + return info.get().features.contains(feature); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0054.dart b/moxxmpp/lib/src/xeps/xep_0054.dart new file mode 100644 index 0000000..4723fed --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0054.dart @@ -0,0 +1,118 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class VCardPhoto { + + const VCardPhoto({ this.binval }); + final String? binval; +} + +class VCard { + + const VCard({ this.nickname, this.url, this.photo }); + final String? nickname; + final String? url; + final VCardPhoto? photo; +} + +class VCardManager extends XmppManagerBase { + + VCardManager() : _lastHash = {}, super(); + final Map _lastHash; + + @override + String getId() => vcardManager; + + @override + String getName() => 'vCardManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'presence', + tagName: 'x', + tagXmlns: vCardTempUpdate, + callback: _onPresence, + ) + ]; + + @override + Future isSupported() async => true; + + /// In case we get the avatar hash some other way. + void setLastHash(String jid, String hash) { + _lastHash[jid] = hash; + } + + Future _onPresence(Stanza presence, StanzaHandlerData state) async { + final x = presence.firstTag('x', xmlns: vCardTempUpdate)!; + final hash = x.firstTag('photo')!.innerText(); + + final from = JID.fromString(presence.from!).toBare().toString(); + final lastHash = _lastHash[from]; + if (lastHash != hash) { + _lastHash[from] = hash; + final vcard = await requestVCard(from); + + if (vcard != null) { + final binval = vcard.photo?.binval; + if (binval != null) { + getAttributes().sendEvent(AvatarUpdatedEvent(jid: from, base64: binval, hash: hash)); + } else { + logger.warning('No avatar data found'); + } + } else { + logger.warning('Failed to retrieve vCard for $from'); + } + } + + return state.copyWith(done: true); + } + + VCardPhoto? _parseVCardPhoto(XMLNode? node) { + if (node == null) return null; + + return VCardPhoto( + binval: node.firstTag('BINVAL')?.innerText(), + ); + } + + VCard _parseVCard(XMLNode vcard) { + final nickname = vcard.firstTag('NICKNAME')?.innerText(); + final url = vcard.firstTag('URL')?.innerText(); + + return VCard( + url: url, + nickname: nickname, + photo: _parseVCardPhoto(vcard.firstTag('PHOTO')), + ); + } + + Future requestVCard(String jid) async { + final result = await getAttributes().sendStanza( + Stanza.iq( + to: jid, + type: 'get', + children: [ + XMLNode.xmlns( + tag: 'vCard', + xmlns: vCardTempXmlns, + ) + ], + ), + ); + + if (result.attributes['type'] != 'result') return null; + final vcard = result.firstTag('vCard', xmlns: vCardTempXmlns); + if (vcard == null) return null; + + return _parseVCard(vcard); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0060/errors.dart b/moxxmpp/lib/src/xeps/xep_0060/errors.dart new file mode 100644 index 0000000..34a4671 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0060/errors.dart @@ -0,0 +1,15 @@ +abstract class PubSubError {} + +class UnknownPubSubError extends PubSubError {} + +class PreconditionsNotMetError extends PubSubError {} + +class MalformedResponseError extends PubSubError {} + +class NoItemReturnedError extends PubSubError {} + +/// Returned if we can guess that the server, by which I mean ejabberd, rejected +/// the publish due to not liking that we set "max_items" to "max". +/// NOTE: This workaround is required due to https://github.com/processone/ejabberd/issues/3044 +// TODO(Unknown): Remove once ejabberd fixes it +class EjabberdMaxItemsError extends PubSubError {} diff --git a/moxxmpp/lib/src/xeps/xep_0060/helpers.dart b/moxxmpp/lib/src/xeps/xep_0060/helpers.dart new file mode 100644 index 0000000..f66ec15 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0060/helpers.dart @@ -0,0 +1,25 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/errors.dart'; + +PubSubError getPubSubError(XMLNode stanza) { + final error = stanza.firstTag('error'); + if (error != null) { + final conflict = error.firstTag('conflict'); + final preconditions = error.firstTag('precondition-not-met'); + if (conflict != null && preconditions != null) { + return PreconditionsNotMetError(); + } + + final badRequest = error.firstTag('bad-request', xmlns: fullStanzaXmlns); + final text = error.firstTag('text', xmlns: fullStanzaXmlns); + if (error.attributes['type'] == 'modify' && + badRequest != null && + text != null && + (text.text ?? '').contains('max_items')) { + return EjabberdMaxItemsError(); + } + } + + return UnknownPubSubError(); +} diff --git a/moxxmpp/lib/src/xeps/xep_0060/xep_0060.dart b/moxxmpp/lib/src/xeps/xep_0060/xep_0060.dart new file mode 100644 index 0000000..e492b45 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0060/xep_0060.dart @@ -0,0 +1,545 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/types/resultv2.dart'; +import 'package:moxxmpp/src/xeps/xep_0004.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/helpers.dart'; + +class PubSubPublishOptions { + + const PubSubPublishOptions({ + this.accessModel, + this.maxItems, + }); + final String? accessModel; + final String? maxItems; + + XMLNode toXml() { + return DataForm( + type: 'submit', + instructions: [], + reported: [], + items: [], + fields: [ + const DataFormField( + options: [], + isRequired: false, + values: [ pubsubPublishOptionsXmlns ], + varAttr: 'FORM_TYPE', + type: 'hidden', + ), + ...accessModel != null ? [ + DataFormField( + options: [], + isRequired: false, + values: [ accessModel! ], + varAttr: 'pubsub#access_model', + ) + ] : [], + ...maxItems != null ? [ + DataFormField( + options: [], + isRequired: false, + values: [maxItems! ], + varAttr: 'pubsub#max_items', + ), + ] : [], + ], + ).toXml(); + } +} + +class PubSubItem { + + const PubSubItem({ required this.id, required this.node, required this.payload }); + final String id; + final String node; + final XMLNode payload; + + @override + String toString() => '$id: ${payload.toXml()}'; +} + +class PubSubManager extends XmppManagerBase { + @override + String getId() => pubsubManager; + + @override + String getName() => 'PubsubManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'event', + tagXmlns: pubsubEventXmlns, + callback: _onPubsubMessage, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onPubsubMessage(Stanza message, StanzaHandlerData state) async { + logger.finest('Received PubSub event'); + final event = message.firstTag('event', xmlns: pubsubEventXmlns)!; + final items = event.firstTag('items')!; + final item = items.firstTag('item')!; + + getAttributes().sendEvent(PubSubNotificationEvent( + item: PubSubItem( + id: item.attributes['id']! as String, + node: items.attributes['node']! as String, + payload: item.children[0], + ), + from: message.attributes['from']! as String, + ),); + + return state.copyWith(done: true); + } + + Future _getNodeItemCount(String jid, String node) async { + final dm = getAttributes().getManagerById(discoManager)!; + final response = await dm.discoItemsQuery(jid, node: node); + var count = 0; + if (response.isType()) { + logger.warning('_getNodeItemCount: disco#items query failed. Assuming no items.'); + } else { + count = response.get>().length; + } + + return count; + } + + Future _preprocessPublishOptions(String jid, String node, PubSubPublishOptions options) async { + if (options.maxItems != null) { + final dm = getAttributes().getManagerById(discoManager)!; + final result = await dm.discoInfoQuery(jid); + if (result.isType()) { + if (options.maxItems == 'max') { + logger.severe('disco#info query failed and options.maxItems is set to "max".'); + return options; + } + } + + final nodeMultiItemsSupported = result.isType() && result.get().features.contains(pubsubNodeConfigMultiItems); + final nodeMaxSupported = result.isType() && result.get().features.contains(pubsubNodeConfigMax); + if (options.maxItems != null && !nodeMultiItemsSupported) { + // TODO(PapaTutuWawa): Here, we need to admit defeat + logger.finest('PubSub host does not support multi-items!'); + + return PubSubPublishOptions( + accessModel: options.accessModel, + ); + } else if (options.maxItems == 'max' && !nodeMaxSupported) { + logger.finest('PubSub host does not support node-config-max. Working around it'); + final count = await _getNodeItemCount(jid, node) + 1; + + return PubSubPublishOptions( + accessModel: options.accessModel, + maxItems: '$count', + ); + } + } + + return options; + } + + Future> subscribe(String jid, String node) async { + final attrs = getAttributes(); + final result = await attrs.sendStanza( + Stanza.iq( + type: 'set', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubXmlns, + children: [ + XMLNode( + tag: 'subscribe', + attributes: { + 'node': node, + 'jid': attrs.getFullJID().toBare().toString(), + }, + ), + ], + ), + ], + ), + ); + + if (result.attributes['type'] != 'result') return Result(UnknownPubSubError()); + + final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns); + if (pubsub == null) return Result(UnknownPubSubError()); + + final subscription = pubsub.firstTag('subscription'); + if (subscription == null) return Result(UnknownPubSubError()); + + return Result(subscription.attributes['subscription'] == 'subscribed'); + } + + Future> unsubscribe(String jid, String node) async { + final attrs = getAttributes(); + final result = await attrs.sendStanza( + Stanza.iq( + type: 'set', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubXmlns, + children: [ + XMLNode( + tag: 'unsubscribe', + attributes: { + 'node': node, + 'jid': attrs.getFullJID().toBare().toString(), + }, + ), + ], + ), + ], + ), + ); + + if (result.attributes['type'] != 'result') return Result(UnknownPubSubError()); + + final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns); + if (pubsub == null) return Result(UnknownPubSubError()); + + final subscription = pubsub.firstTag('subscription'); + if (subscription == null) return Result(UnknownPubSubError()); + + return Result(subscription.attributes['subscription'] == 'none'); + } + + /// Publish [payload] to the PubSub node [node] on JID [jid]. Returns true if it + /// was successful. False otherwise. + Future> publish( + String jid, + String node, + XMLNode payload, { + String? id, + PubSubPublishOptions? options, + } + ) async { + return _publish( + jid, + node, + payload, + id: id, + options: options, + ); + } + + Future> _publish( + String jid, + String node, + XMLNode payload, { + String? id, + PubSubPublishOptions? options, + // Should, if publishing fails, try to reconfigure and publish again? + bool tryConfigureAndPublish = true, + } + ) async { + PubSubPublishOptions? pubOptions; + if (options != null) { + pubOptions = await _preprocessPublishOptions(jid, node, options); + } + + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubXmlns, + children: [ + XMLNode( + tag: 'publish', + attributes: { 'node': node }, + children: [ + XMLNode( + tag: 'item', + attributes: id != null ? { 'id': id } : {}, + children: [ payload ], + ) + ], + ), + ...options != null ? [ + XMLNode( + tag: 'publish-options', + children: [options.toXml()], + ), + ] : [], + ], + ) + ], + ), + ); + if (result.attributes['type'] != 'result') { + final error = getPubSubError(result); + + // If preconditions are not met, configure the node + if (error is PreconditionsNotMetError && tryConfigureAndPublish) { + final configureResult = await configure(jid, node, pubOptions!); + if (configureResult.isType()) { + return Result(configureResult.get()); + } + + final publishResult = await _publish( + jid, + node, + payload, + id: id, + options: options, + tryConfigureAndPublish: false, + ); + if (publishResult.isType()) return publishResult; + } else if (error is EjabberdMaxItemsError && tryConfigureAndPublish && options != null) { + // TODO(Unknown): Remove once ejabberd fixes the bug. See errors.dart for more info. + logger.warning('Publish failed due to the server rejecting the usage of "max" for "max_items" in publish options. Configuring...'); + final count = await _getNodeItemCount(jid, node) + 1; + return publish( + jid, + node, + payload, + id: id, + options: PubSubPublishOptions( + accessModel: options.accessModel, + maxItems: '$count', + ), + ); + } else { + return Result(error); + } + } + + final pubsubElement = result.firstTag('pubsub', xmlns: pubsubXmlns); + if (pubsubElement == null) return Result(MalformedResponseError()); + + final publishElement = pubsubElement.firstTag('publish'); + if (publishElement == null) return Result(MalformedResponseError()); + + final item = publishElement.firstTag('item'); + if (item == null) return Result(MalformedResponseError()); + + if (id != null) return Result(item.attributes['id'] == id); + + return const Result(true); + } + + Future>> getItems(String jid, String node) async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'get', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubXmlns, + children: [ + XMLNode(tag: 'items', attributes: { 'node': node }), + ], + ) + ], + ), + ); + + if (result.attributes['type'] != 'result') return Result(getPubSubError(result)); + + final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns); + if (pubsub == null) return Result(getPubSubError(result)); + + final items = pubsub + .firstTag('items')! + .children.map((item) { + return PubSubItem( + id: item.attributes['id']! as String, + payload: item.children[0], + node: node, + ); + }) + .toList(); + + return Result(items); + } + + Future> getItem(String jid, String node, String id) async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'get', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubXmlns, + children: [ + XMLNode( + tag: 'items', + attributes: { 'node': node }, + children: [ + XMLNode( + tag: 'item', + attributes: { 'id': id }, + ), + ], + ), + ], + ), + ], + ), + ); + + if (result.attributes['type'] != 'result') return Result(getPubSubError(result)); + + final pubsub = result.firstTag('pubsub', xmlns: pubsubXmlns); + if (pubsub == null) return Result(getPubSubError(result)); + + final itemElement = pubsub.firstTag('items')?.firstTag('item'); + if (itemElement == null) return Result(NoItemReturnedError()); + + final item = PubSubItem( + id: itemElement.attributes['id']! as String, + payload: itemElement.children[0], + node: node, + ); + + return Result(item); + } + + Future> configure(String jid, String node, PubSubPublishOptions options) async { + final attrs = getAttributes(); + + // Request the form + final form = await attrs.sendStanza( + Stanza.iq( + type: 'get', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubOwnerXmlns, + children: [ + XMLNode( + tag: 'configure', + attributes: { + 'node': node, + }, + ), + ], + ), + ], + ), + ); + if (form.attributes['type'] != 'result') return Result(getPubSubError(form)); + + final submit = await attrs.sendStanza( + Stanza.iq( + type: 'set', + to: jid, + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubOwnerXmlns, + children: [ + XMLNode( + tag: 'configure', + attributes: { + 'node': node, + }, + children: [ + options.toXml(), + ], + ), + ], + ), + ], + ), + ); + if (submit.attributes['type'] != 'result') return Result(getPubSubError(form)); + + return const Result(true); + } + + Future> delete(JID host, String node) async { + final request = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + to: host.toString(), + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubOwnerXmlns, + children: [ + XMLNode( + tag: 'delete', + attributes: { + 'node': node, + }, + ), + ], + ), + ], + ), + ) as Stanza; + + if (request.type != 'result') { + // TODO(Unknown): Be more specific + return Result(UnknownPubSubError()); + } + + return const Result(true); + } + + Future> retract(JID host, String node, String itemId) async { + final request = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + to: host.toString(), + children: [ + XMLNode.xmlns( + tag: 'pubsub', + xmlns: pubsubXmlns, + children: [ + XMLNode( + tag: 'retract', + attributes: { + 'node': node, + }, + children: [ + XMLNode( + tag: 'item', + attributes: { + 'id': itemId, + }, + ), + ], + ), + ], + ), + ], + ), + ) as Stanza; + + if (request.type != 'result') { + // TODO(Unknown): Be more specific + return Result(UnknownPubSubError()); + } + + return const Result(true); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0066.dart b/moxxmpp/lib/src/xeps/xep_0066.dart new file mode 100644 index 0000000..7c9d283 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0066.dart @@ -0,0 +1,71 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +/// A data class representing the jabber:x:oob tag. +class OOBData { + + const OOBData({ this.url, this.desc }); + final String? url; + final String? desc; +} + +XMLNode constructOOBNode(OOBData data) { + final children = List.empty(growable: true); + + if (data.url != null) { + children.add(XMLNode(tag: 'url', text: data.url)); + } + if (data.desc != null) { + children.add(XMLNode(tag: 'desc', text: data.desc)); + } + + return XMLNode.xmlns( + tag: 'x', + xmlns: oobDataXmlns, + children: children, + ); +} + +class OOBManager extends XmppManagerBase { + @override + String getName() => 'OOBName'; + + @override + String getId() => oobManager; + + @override + List getDiscoFeatures() => [ oobDataXmlns ]; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'x', + tagXmlns: oobDataXmlns, + callback: _onMessage, + // Before the message manager + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza message, StanzaHandlerData state) async { + final x = message.firstTag('x', xmlns: oobDataXmlns)!; + final url = x.firstTag('url'); + final desc = x.firstTag('desc'); + + return state.copyWith( + oob: OOBData( + url: url?.innerText(), + desc: desc?.innerText(), + ), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0084.dart b/moxxmpp/lib/src/xeps/xep_0084.dart new file mode 100644 index 0000000..959ae6f --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0084.dart @@ -0,0 +1,173 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart'; + +class UserAvatar { + + const UserAvatar({ required this.base64, required this.hash }); + final String base64; + final String hash; +} + +class UserAvatarMetadata { + + const UserAvatarMetadata( + this.id, + this.length, + this.width, + this.height, + this.mime, + ); + /// The amount of bytes in the file + final int length; + /// The identifier of the avatar + final String id; + /// Image proportions + final int width; + final int height; + /// The MIME type of the avatar + final String mime; +} + +/// NOTE: This class requires a PubSubManager +class UserAvatarManager extends XmppManagerBase { + @override + String getId() => userAvatarManager; + + @override + String getName() => 'UserAvatarManager'; + + PubSubManager _getPubSubManager() => getAttributes().getManagerById(pubsubManager)! as PubSubManager; + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is PubSubNotificationEvent) { + getAttributes().sendEvent( + AvatarUpdatedEvent( + jid: event.from, + base64: event.item.payload.innerText(), + hash: event.item.id, + ), + ); + } + } + + // TODO(PapaTutuWawa): Check for PEP support + @override + Future isSupported() async => true; + + /// Requests the avatar from [jid]. Returns the avatar data if the request was + /// successful. Null otherwise + // TODO(Unknown): Migrate to Resultsv2 + Future getUserAvatar(String jid) async { + final pubsub = _getPubSubManager(); + final resultsRaw = await pubsub.getItems(jid, userAvatarDataXmlns); + if (resultsRaw.isType()) return null; + + final results = resultsRaw.get>(); + if (results.isEmpty) return null; + + final item = results[0]; + return UserAvatar( + base64: item.payload.innerText(), + hash: item.id, + ); + } + + /// Publish the avatar data, [base64], on the pubsub node using [hash] as + /// the item id. [hash] must be the SHA-1 hash of the image data, while + /// [base64] must be the base64-encoded version of the image data. + // TODO(Unknown): Migrate to Resultsv2 + Future publishUserAvatar(String base64, String hash, bool public) async { + final pubsub = _getPubSubManager(); + final result = await pubsub.publish( + getAttributes().getFullJID().toBare().toString(), + userAvatarDataXmlns, + XMLNode.xmlns( + tag: 'data', + xmlns: userAvatarDataXmlns, + text: base64, + ), + id: hash, + options: PubSubPublishOptions( + accessModel: public ? 'open' : 'roster', + ), + ); + + return !result.isType(); + } + + /// Publish avatar metadata [metadata] to the User Avatar's metadata node. If [public] + /// is true, then the node will be set to an 'open' access model. If [public] is false, + /// then the node will be set to an 'roster' access model. + // TODO(Unknown): Migrate to Resultsv2 + Future publishUserAvatarMetadata(UserAvatarMetadata metadata, bool public) async { + final pubsub = _getPubSubManager(); + final result = await pubsub.publish( + getAttributes().getFullJID().toBare().toString(), + userAvatarMetadataXmlns, + XMLNode.xmlns( + tag: 'metadata', + xmlns: userAvatarMetadataXmlns, + children: [ + XMLNode( + tag: 'info', + attributes: { + 'bytes': metadata.length.toString(), + 'height': metadata.height.toString(), + 'width': metadata.width.toString(), + 'type': metadata.mime, + 'id': metadata.id, + }, + ), + ], + ), + id: metadata.id, + options: PubSubPublishOptions( + accessModel: public ? 'open' : 'roster', + ), + ); + + return result.isType(); + } + + /// Subscribe the data and metadata node of [jid]. + // TODO(Unknown): Migrate to Resultsv2 + Future subscribe(String jid) async { + await _getPubSubManager().subscribe(jid, userAvatarDataXmlns); + await _getPubSubManager().subscribe(jid, userAvatarMetadataXmlns); + + return true; + } + + /// Unsubscribe the data and metadata node of [jid]. + // TODO(Unknown): Migrate to Resultsv2 + Future unsubscribe(String jid) async { + await _getPubSubManager().unsubscribe(jid, userAvatarDataXmlns); + await _getPubSubManager().subscribe(jid, userAvatarMetadataXmlns); + + return true; + } + + /// Returns the PubSub Id of an avatar after doing a disco#items query. + /// Note that this assumes that there is only one (1) item published on + /// the node. + // TODO(Unknown): Migrate to Resultsv2 + Future getAvatarId(String jid) async { + final disco = getAttributes().getManagerById(discoManager)! as DiscoManager; + final response = await disco.discoItemsQuery(jid, node: userAvatarDataXmlns); + if (response.isType()) return null; + + final items = response.get>(); + if (items.isEmpty) return null; + + return items.first.name; + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0085.dart b/moxxmpp/lib/src/xeps/xep_0085.dart new file mode 100644 index 0000000..9498c00 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0085.dart @@ -0,0 +1,111 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +enum ChatState { + active, + composing, + paused, + inactive, + gone +} + +ChatState chatStateFromString(String raw) { + switch(raw) { + case 'active': { + return ChatState.active; + } + case 'composing': { + return ChatState.composing; + } + case 'paused': { + return ChatState.paused; + } + case 'inactive': { + return ChatState.inactive; + } + case 'gone': { + return ChatState.gone; + } + default: { + return ChatState.gone; + } + } +} +String chatStateToString(ChatState state) => state.toString().split('.').last; + +class ChatStateManager extends XmppManagerBase { + @override + List getDiscoFeatures() => [ chatStateXmlns ]; + + @override + String getName() => 'ChatStateManager'; + + @override + String getId() => chatStateManager; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagXmlns: chatStateXmlns, + callback: _onChatStateReceived, + // Before the message handler + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onChatStateReceived(Stanza message, StanzaHandlerData state) async { + final element = state.stanza.firstTagByXmlns(chatStateXmlns)!; + ChatState? chatState; + + switch (element.tag) { + case 'active': { + chatState = ChatState.active; + } + break; + case 'composing': { + chatState = ChatState.composing; + } + break; + case 'paused': { + chatState = ChatState.paused; + } + break; + case 'inactive': { + chatState = ChatState.inactive; + } + break; + case 'gone': { + chatState = ChatState.gone; + } + break; + default: { + logger.warning("Received invalid chat state '${element.tag}'"); + } + } + + return state.copyWith(chatState: chatState); + } + + /// Send a chat state notification to [to]. You can specify the type attribute + /// of the message with [messageType]. + void sendChatState(ChatState state, String to, { String messageType = 'chat' }) { + final tagName = state.toString().split('.').last; + + getAttributes().sendStanza( + Stanza.message( + to: to, + type: messageType, + children: [ XMLNode.xmlns(tag: tagName, xmlns: chatStateXmlns) ], + ), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0115.dart b/moxxmpp/lib/src/xeps/xep_0115.dart new file mode 100644 index 0000000..e29e272 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0115.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; +import 'package:moxxmpp/src/rfcs/rfc_4790.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; + +class CapabilityHashInfo { + + const CapabilityHashInfo(this.ver, this.node, this.hash); + final String ver; + final String node; + final String hash; +} + +/// Calculates the Entitiy Capability hash according to XEP-0115 based on the +/// disco information. +Future calculateCapabilityHash(DiscoInfo info, HashAlgorithm algorithm) async { + final buffer = StringBuffer(); + final identitiesSorted = info.identities + .map((Identity i) => '${i.category}/${i.type}/${i.lang ?? ""}/${i.name ?? ""}') + .toList(); + // ignore: cascade_invocations + identitiesSorted.sort(ioctetSortComparator); + buffer.write('${identitiesSorted.join("<")}<'); + + final featuresSorted = List.from(info.features) + ..sort(ioctetSortComparator); + buffer.write('${featuresSorted.join("<")}<'); + + if (info.extendedInfo.isNotEmpty) { + final sortedExt = info.extendedInfo + ..sort((a, b) => ioctetSortComparator( + a.getFieldByVar('FORM_TYPE')!.values.first, + b.getFieldByVar('FORM_TYPE')!.values.first, + ), + ); + + for (final ext in sortedExt) { + buffer.write('${ext.getFieldByVar("FORM_TYPE")!.values.first}<'); + + final sortedFields = ext.fields..sort((a, b) => ioctetSortComparator( + a.varAttr!, + b.varAttr!, + ), + ); + + for (final field in sortedFields) { + if (field.varAttr == 'FORM_TYPE') continue; + + buffer.write('${field.varAttr!}<'); + final sortedValues = field.values..sort(ioctetSortComparator); + for (final value in sortedValues) { + buffer.write('$value<'); + } + } + } + } + + return base64.encode((await algorithm.hash(utf8.encode(buffer.toString()))).bytes); +} diff --git a/moxxmpp/lib/src/xeps/xep_0184.dart b/moxxmpp/lib/src/xeps/xep_0184.dart new file mode 100644 index 0000000..81d1727 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0184.dart @@ -0,0 +1,81 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +XMLNode makeMessageDeliveryRequest() { + return XMLNode.xmlns( + tag: 'request', + xmlns: deliveryXmlns, + ); +} + +XMLNode makeMessageDeliveryResponse(String id) { + return XMLNode.xmlns( + tag: 'received', + xmlns: deliveryXmlns, + attributes: { 'id': id }, + ); +} + +class MessageDeliveryReceiptManager extends XmppManagerBase { + @override + List getDiscoFeatures() => [ deliveryXmlns ]; + + @override + String getName() => 'MessageDeliveryReceiptManager'; + + @override + String getId() => messageDeliveryReceiptManager; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'received', + tagXmlns: deliveryXmlns, + callback: _onDeliveryReceiptReceived, + // Before the message handler + priority: -99, + ), + StanzaHandler( + stanzaTag: 'message', + tagName: 'request', + tagXmlns: deliveryXmlns, + callback: _onDeliveryRequestReceived, + // Before the message handler + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onDeliveryRequestReceived(Stanza message, StanzaHandlerData state) async { + return state.copyWith(deliveryReceiptRequested: true); + } + + Future _onDeliveryReceiptReceived(Stanza message, StanzaHandlerData state) async { + final received = message.firstTag('received', xmlns: deliveryXmlns)!; + for (final item in message.children) { + if (!['origin-id', 'stanza-id', 'delay', 'store', 'received'].contains(item.tag)) { + logger.info("Won't handle stanza as delivery receipt because we found an '${item.tag}' element"); + + return state.copyWith(done: true); + } + } + + getAttributes().sendEvent( + DeliveryReceiptReceivedEvent( + from: JID.fromString(message.attributes['from']! as String), + id: received.attributes['id']! as String, + ), + ); + return state.copyWith(done: true); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0191.dart b/moxxmpp/lib/src/xeps/xep_0191.dart new file mode 100644 index 0000000..530e0e8 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0191.dart @@ -0,0 +1,170 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; + +class BlockingManager extends XmppManagerBase { + BlockingManager() : _supported = false, _gotSupported = false, super(); + + bool _supported; + bool _gotSupported; + + @override + String getId() => blockingManager; + + @override + String getName() => 'BlockingManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'iq', + tagName: 'unblock', + tagXmlns: blockingXmlns, + callback: _unblockPush, + ), + StanzaHandler( + stanzaTag: 'iq', + tagName: 'block', + tagXmlns: blockingXmlns, + callback: _blockPush, + ) + ]; + + @override + Future isSupported() async { + if (_gotSupported) return _supported; + + // Query the server + final disco = getAttributes().getManagerById(discoManager)!; + _supported = await disco.supportsFeature( + getAttributes().getConnectionSettings().jid.toBare(), + blockingXmlns, + ); + _gotSupported = true; + return _supported; + } + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is StreamResumeFailedEvent) { + _gotSupported = false; + _supported = false; + } + } + + Future _blockPush(Stanza iq, StanzaHandlerData state) async { + final block = iq.firstTag('block', xmlns: blockingXmlns)!; + + getAttributes().sendEvent( + BlocklistBlockPushEvent( + items: block.findTags('item').map((i) => i.attributes['jid']! as String).toList(), + ), + ); + + return state.copyWith(done: true); + } + + Future _unblockPush(Stanza iq, StanzaHandlerData state) async { + final unblock = iq.firstTag('unblock', xmlns: blockingXmlns)!; + final items = unblock.findTags('item'); + + if (items.isNotEmpty) { + getAttributes().sendEvent( + BlocklistUnblockPushEvent( + items: items.map((i) => i.attributes['jid']! as String).toList(), + ), + ); + } else { + getAttributes().sendEvent( + BlocklistUnblockAllPushEvent(), + ); + } + + return state.copyWith(done: true); + } + + Future block(List items) async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'block', + xmlns: blockingXmlns, + children: items + .map((item) { + return XMLNode( + tag: 'item', + attributes: { 'jid': item }, + ); + }) + .toList(), + ) + ], + ), + ); + + return result.attributes['type'] == 'result'; + } + + Future unblockAll() async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'unblock', + xmlns: blockingXmlns, + ) + ], + ), + ); + + return result.attributes['type'] == 'result'; + } + + Future unblock(List items) async { + assert(items.isNotEmpty, 'The list of items to unblock must be non-empty'); + + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'unblock', + xmlns: blockingXmlns, + children: items.map((item) => XMLNode( + tag: 'item', + attributes: { 'jid': item }, + ),).toList(), + ) + ], + ), + ); + + return result.attributes['type'] == 'result'; + } + + Future> getBlocklist() async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'get', + children: [ + XMLNode.xmlns( + tag: 'blocklist', + xmlns: blockingXmlns, + ) + ], + ), + ); + + final blocklist = result.firstTag('blocklist', xmlns: blockingXmlns)!; + return blocklist.findTags('item').map((item) => item.attributes['jid']! as String).toList(); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart b/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart new file mode 100644 index 0000000..606428f --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0198/negotiator.dart @@ -0,0 +1,156 @@ +import 'package:logging/logging.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/state.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/xep_0198.dart'; +import 'package:moxxmpp/src/xeps/xep_0352.dart'; + +enum _StreamManagementNegotiatorState { + // We have not done anything yet + ready, + // The SM resume has been requested + resumeRequested, + // The SM enablement has been requested + enableRequested, +} + +/// NOTE: The stream management negotiator requires that loadState has been called on the +/// StreamManagementManager at least once before connecting, if stream resumption +/// is wanted. +class StreamManagementNegotiator extends XmppFeatureNegotiatorBase { + + StreamManagementNegotiator() + : _state = _StreamManagementNegotiatorState.ready, + _supported = false, + _resumeFailed = false, + _isResumed = false, + _log = Logger('StreamManagementNegotiator'), + super(10, false, smXmlns, streamManagementNegotiator); + _StreamManagementNegotiatorState _state; + bool _resumeFailed; + bool _isResumed; + + final Logger _log; + + /// True if Stream Management is supported on this stream. + bool _supported; + bool get isSupported => _supported; + + /// True if the current stream is resumed. False if not. + bool get isResumed => _isResumed; + + @override + bool matchesFeature(List features) { + final sm = attributes.getManagerById(smManager)!; + + if (sm.state.streamResumptionId != null && !_resumeFailed) { + // We could do Stream resumption + return super.matchesFeature(features) && attributes.isAuthenticated(); + } else { + // We cannot do a stream resumption + final br = attributes.getNegotiatorById(resourceBindingNegotiator); + return super.matchesFeature(features) && br?.state == NegotiatorState.done && attributes.isAuthenticated(); + } + } + + @override + Future negotiate(XMLNode nonza) async { + // negotiate is only called when we matched the stream feature, so we know + // that the server advertises it. + _supported = true; + + switch (_state) { + case _StreamManagementNegotiatorState.ready: + final sm = attributes.getManagerById(smManager)!; + final srid = sm.state.streamResumptionId; + final h = sm.state.s2c; + + // Attempt stream resumption first + if (srid != null) { + _log.finest('Found stream resumption Id. Attempting to perform stream resumption'); + _state = _StreamManagementNegotiatorState.resumeRequested; + attributes.sendNonza(StreamManagementResumeNonza(srid, h)); + } else { + _log.finest('Attempting to enable stream management'); + _state = _StreamManagementNegotiatorState.enableRequested; + attributes.sendNonza(StreamManagementEnableNonza()); + } + break; + case _StreamManagementNegotiatorState.resumeRequested: + if (nonza.tag == 'resumed') { + _log.finest('Stream Management resumption successful'); + + assert(attributes.getFullJID().resource != '', 'Resume only works when we already have a resource bound and know about it'); + + final csi = attributes.getManagerById(csiManager) as CSIManager?; + if (csi != null) { + csi.restoreCSIState(); + } + + final h = int.parse(nonza.attributes['h']! as String); + await attributes.sendEvent(StreamResumedEvent(h: h)); + + _resumeFailed = false; + _isResumed = true; + state = NegotiatorState.skipRest; + } else { + // We assume it is + _log.info('Stream resumption failed. Expected , got ${nonza.tag}, Proceeding with new stream...'); + await attributes.sendEvent(StreamResumeFailedEvent()); + final sm = attributes.getManagerById(smManager)!; + + // We have to do this because we otherwise get a stanza stuck in the queue, + // thus spamming the server on every nonza we receive. + // ignore: cascade_invocations + await sm.setState(StreamManagementState(0, 0)); + await sm.commitState(); + + _resumeFailed = true; + _isResumed = false; + _state = _StreamManagementNegotiatorState.ready; + state = NegotiatorState.retryLater; + } + break; + case _StreamManagementNegotiatorState.enableRequested: + if (nonza.tag == 'enabled') { + _log.finest('Stream Management enabled'); + + final id = nonza.attributes['id'] as String?; + if (id != null && ['true', '1'].contains(nonza.attributes['resume'])) { + _log.info('Stream Resumption available'); + } + + await attributes.sendEvent( + StreamManagementEnabledEvent( + resource: attributes.getFullJID().resource, + id: id, + location: nonza.attributes['location'] as String?, + ), + ); + + state = NegotiatorState.done; + } else { + // We assume a + _log.warning('Stream Management enablement failed'); + state = NegotiatorState.done; + } + + break; + } + } + + @override + void reset() { + _state = _StreamManagementNegotiatorState.ready; + _supported = false; + _resumeFailed = false; + _isResumed = false; + + super.reset(); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0198/nonzas.dart b/moxxmpp/lib/src/xeps/xep_0198/nonzas.dart new file mode 100644 index 0000000..0639ba2 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0198/nonzas.dart @@ -0,0 +1,42 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class StreamManagementEnableNonza extends XMLNode { + StreamManagementEnableNonza() : super( + tag: 'enable', + attributes: { + 'xmlns': smXmlns, + 'resume': 'true' + }, + ); +} + +class StreamManagementResumeNonza extends XMLNode { + StreamManagementResumeNonza(String id, int h) : super( + tag: 'resume', + attributes: { + 'xmlns': smXmlns, + 'previd': id, + 'h': h.toString() + }, + ); +} + +class StreamManagementAckNonza extends XMLNode { + StreamManagementAckNonza(int h) : super( + tag: 'a', + attributes: { + 'xmlns': smXmlns, + 'h': h.toString() + }, + ); +} + +class StreamManagementRequestNonza extends XMLNode { + StreamManagementRequestNonza() : super( + tag: 'r', + attributes: { + 'xmlns': smXmlns, + }, + ); +} diff --git a/moxxmpp/lib/src/xeps/xep_0198/state.dart b/moxxmpp/lib/src/xeps/xep_0198/state.dart new file mode 100644 index 0000000..4ddcec2 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0198/state.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'state.freezed.dart'; +part 'state.g.dart'; + +@freezed +class StreamManagementState with _$StreamManagementState { + factory StreamManagementState( + int c2s, + int s2c, + { + String? streamResumptionLocation, + String? streamResumptionId, + } + ) = _StreamManagementState; + + // JSON + factory StreamManagementState.fromJson(Map json) => _$StreamManagementStateFromJson(json); +} diff --git a/moxxmpp/lib/src/xeps/xep_0198/state.freezed.dart b/moxxmpp/lib/src/xeps/xep_0198/state.freezed.dart new file mode 100644 index 0000000..ce92658 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0198/state.freezed.dart @@ -0,0 +1,217 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target + +part of 'state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +StreamManagementState _$StreamManagementStateFromJson( + Map json) { + return _StreamManagementState.fromJson(json); +} + +/// @nodoc +mixin _$StreamManagementState { + int get c2s => throw _privateConstructorUsedError; + int get s2c => throw _privateConstructorUsedError; + String? get streamResumptionLocation => throw _privateConstructorUsedError; + String? get streamResumptionId => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $StreamManagementStateCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $StreamManagementStateCopyWith<$Res> { + factory $StreamManagementStateCopyWith(StreamManagementState value, + $Res Function(StreamManagementState) then) = + _$StreamManagementStateCopyWithImpl<$Res>; + $Res call( + {int c2s, + int s2c, + String? streamResumptionLocation, + String? streamResumptionId}); +} + +/// @nodoc +class _$StreamManagementStateCopyWithImpl<$Res> + implements $StreamManagementStateCopyWith<$Res> { + _$StreamManagementStateCopyWithImpl(this._value, this._then); + + final StreamManagementState _value; + // ignore: unused_field + final $Res Function(StreamManagementState) _then; + + @override + $Res call({ + Object? c2s = freezed, + Object? s2c = freezed, + Object? streamResumptionLocation = freezed, + Object? streamResumptionId = freezed, + }) { + return _then(_value.copyWith( + c2s: c2s == freezed + ? _value.c2s + : c2s // ignore: cast_nullable_to_non_nullable + as int, + s2c: s2c == freezed + ? _value.s2c + : s2c // ignore: cast_nullable_to_non_nullable + as int, + streamResumptionLocation: streamResumptionLocation == freezed + ? _value.streamResumptionLocation + : streamResumptionLocation // ignore: cast_nullable_to_non_nullable + as String?, + streamResumptionId: streamResumptionId == freezed + ? _value.streamResumptionId + : streamResumptionId // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +abstract class _$$_StreamManagementStateCopyWith<$Res> + implements $StreamManagementStateCopyWith<$Res> { + factory _$$_StreamManagementStateCopyWith(_$_StreamManagementState value, + $Res Function(_$_StreamManagementState) then) = + __$$_StreamManagementStateCopyWithImpl<$Res>; + @override + $Res call( + {int c2s, + int s2c, + String? streamResumptionLocation, + String? streamResumptionId}); +} + +/// @nodoc +class __$$_StreamManagementStateCopyWithImpl<$Res> + extends _$StreamManagementStateCopyWithImpl<$Res> + implements _$$_StreamManagementStateCopyWith<$Res> { + __$$_StreamManagementStateCopyWithImpl(_$_StreamManagementState _value, + $Res Function(_$_StreamManagementState) _then) + : super(_value, (v) => _then(v as _$_StreamManagementState)); + + @override + _$_StreamManagementState get _value => + super._value as _$_StreamManagementState; + + @override + $Res call({ + Object? c2s = freezed, + Object? s2c = freezed, + Object? streamResumptionLocation = freezed, + Object? streamResumptionId = freezed, + }) { + return _then(_$_StreamManagementState( + c2s == freezed + ? _value.c2s + : c2s // ignore: cast_nullable_to_non_nullable + as int, + s2c == freezed + ? _value.s2c + : s2c // ignore: cast_nullable_to_non_nullable + as int, + streamResumptionLocation: streamResumptionLocation == freezed + ? _value.streamResumptionLocation + : streamResumptionLocation // ignore: cast_nullable_to_non_nullable + as String?, + streamResumptionId: streamResumptionId == freezed + ? _value.streamResumptionId + : streamResumptionId // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$_StreamManagementState implements _StreamManagementState { + _$_StreamManagementState(this.c2s, this.s2c, + {this.streamResumptionLocation, this.streamResumptionId}); + + factory _$_StreamManagementState.fromJson(Map json) => + _$$_StreamManagementStateFromJson(json); + + @override + final int c2s; + @override + final int s2c; + @override + final String? streamResumptionLocation; + @override + final String? streamResumptionId; + + @override + String toString() { + return 'StreamManagementState(c2s: $c2s, s2c: $s2c, streamResumptionLocation: $streamResumptionLocation, streamResumptionId: $streamResumptionId)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_StreamManagementState && + const DeepCollectionEquality().equals(other.c2s, c2s) && + const DeepCollectionEquality().equals(other.s2c, s2c) && + const DeepCollectionEquality().equals( + other.streamResumptionLocation, streamResumptionLocation) && + const DeepCollectionEquality() + .equals(other.streamResumptionId, streamResumptionId)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(c2s), + const DeepCollectionEquality().hash(s2c), + const DeepCollectionEquality().hash(streamResumptionLocation), + const DeepCollectionEquality().hash(streamResumptionId)); + + @JsonKey(ignore: true) + @override + _$$_StreamManagementStateCopyWith<_$_StreamManagementState> get copyWith => + __$$_StreamManagementStateCopyWithImpl<_$_StreamManagementState>( + this, _$identity); + + @override + Map toJson() { + return _$$_StreamManagementStateToJson( + this, + ); + } +} + +abstract class _StreamManagementState implements StreamManagementState { + factory _StreamManagementState(final int c2s, final int s2c, + {final String? streamResumptionLocation, + final String? streamResumptionId}) = _$_StreamManagementState; + + factory _StreamManagementState.fromJson(Map json) = + _$_StreamManagementState.fromJson; + + @override + int get c2s; + @override + int get s2c; + @override + String? get streamResumptionLocation; + @override + String? get streamResumptionId; + @override + @JsonKey(ignore: true) + _$$_StreamManagementStateCopyWith<_$_StreamManagementState> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/moxxmpp/lib/src/xeps/xep_0198/state.g.dart b/moxxmpp/lib/src/xeps/xep_0198/state.g.dart new file mode 100644 index 0000000..22c28b4 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0198/state.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$_StreamManagementState _$$_StreamManagementStateFromJson( + Map json) => + _$_StreamManagementState( + json['c2s'] as int, + json['s2c'] as int, + streamResumptionLocation: json['streamResumptionLocation'] as String?, + streamResumptionId: json['streamResumptionId'] as String?, + ); + +Map _$$_StreamManagementStateToJson( + _$_StreamManagementState instance) => + { + 'c2s': instance.c2s, + 's2c': instance.s2c, + 'streamResumptionLocation': instance.streamResumptionLocation, + 'streamResumptionId': instance.streamResumptionId, + }; diff --git a/moxxmpp/lib/src/xeps/xep_0198/xep_0198.dart b/moxxmpp/lib/src/xeps/xep_0198/xep_0198.dart new file mode 100644 index 0000000..e1d099b --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0198/xep_0198.dart @@ -0,0 +1,393 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:meta/meta.dart'; +import 'package:moxxmpp/src/connection.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/negotiator.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/nonzas.dart'; +import 'package:moxxmpp/src/xeps/xep_0198/state.dart'; +import 'package:synchronized/synchronized.dart'; + +const xmlUintMax = 4294967296; // 2**32 + +typedef StanzaAckedCallback = bool Function(Stanza stanza); + +class StreamManagementManager extends XmppManagerBase { + + StreamManagementManager({ + this.ackTimeout = const Duration(seconds: 30), + }) + : _state = StreamManagementState(0, 0), + _unackedStanzas = {}, + _stateLock = Lock(), + _streamManagementEnabled = false, + _lastAckTimestamp = -1, + _pendingAcks = 0, + _streamResumed = false, + _ackLock = Lock(); + /// The queue of stanzas that are not (yet) acked + final Map _unackedStanzas; + /// Commitable state of the StreamManagementManager + StreamManagementState _state; + /// Mutex lock for _state + final Lock _stateLock; + /// If the have enabled SM on the stream yet + bool _streamManagementEnabled; + /// If the current stream has been resumed; + bool _streamResumed; + /// The time in which the response to an ack is still valid. Counts as a timeout + /// otherwise + @internal + final Duration ackTimeout; + /// The time at which the last ack has been sent + int _lastAckTimestamp; + /// The timer to see if we timed the connection out + Timer? _ackTimer; + /// Counts how many acks we're waiting for + int _pendingAcks; + /// Lock for both [_lastAckTimestamp] and [_pendingAcks]. + final Lock _ackLock; + + /// Functions for testing + @visibleForTesting + Map getUnackedStanzas() => _unackedStanzas; + + @visibleForTesting + Future getPendingAcks() async { + var acks = 0; + + await _ackLock.synchronized(() async { + acks = _pendingAcks; + }); + + return acks; + } + + /// Called when a stanza has been acked to decide whether we should trigger a + /// StanzaAckedEvent. + /// + /// Return true when the stanza should trigger this event. Return false if not. + @visibleForOverriding + bool shouldTriggerAckedEvent(Stanza stanza) { + return false; + } + + @override + Future isSupported() async { + return getAttributes().getNegotiatorById(streamManagementNegotiator)!.isSupported; + } + + /// Returns the amount of stanzas waiting to get acked + int getUnackedStanzaCount() => _unackedStanzas.length; + + /// May be overwritten by a subclass. Should save [state] so that it can be loaded again + /// with [this.loadState]. + Future commitState() async {} + Future loadState() async {} + + Future setState(StreamManagementState state) async { + await _stateLock.synchronized(() async { + _state = state; + await commitState(); + }); + } + + /// Resets the state such that a resumption is no longer possible without creating + /// a new session. Primarily useful for clearing the state after disconnecting + Future resetState() async { + await setState( + _state.copyWith( + c2s: 0, + s2c: 0, + streamResumptionLocation: null, + streamResumptionId: null, + ), + ); + + await _ackLock.synchronized(() async { + _pendingAcks = 0; + }); + } + + StreamManagementState get state => _state; + + bool get streamResumed => _streamResumed; + + @override + String getId() => smManager; + + @override + String getName() => 'StreamManagementManager'; + + @override + List getNonzaHandlers() => [ + NonzaHandler( + nonzaTag: 'r', + nonzaXmlns: smXmlns, + callback: _handleAckRequest, + ), + NonzaHandler( + nonzaTag: 'a', + nonzaXmlns: smXmlns, + callback: _handleAckResponse, + ) + ]; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + callback: _onServerStanzaReceived, + priority: 9999, + ) + ]; + + @override + List getOutgoingPostStanzaHandlers() => [ + StanzaHandler( + callback: _onClientStanzaSent, + ) + ]; + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is StreamResumedEvent) { + _enableStreamManagement(); + + await _ackLock.synchronized(() async { + _pendingAcks = 0; + }); + + await onStreamResumed(event.h); + } else if (event is StreamManagementEnabledEvent) { + _enableStreamManagement(); + + await _ackLock.synchronized(() async { + _pendingAcks = 0; + }); + + await setState( + StreamManagementState( + 0, + 0, + streamResumptionId: event.id, + streamResumptionLocation: event.location, + ), + ); + } else if (event is ConnectingEvent) { + _disableStreamManagement(); + _streamResumed = false; + } else if (event is ConnectionStateChangedEvent) { + if (event.state == XmppConnectionState.connected) { + // Push out all pending stanzas + await onStreamResumed(0); + } + } + } + + /// Starts the timer to detect timeouts based on ack responses, if the timer + /// is not already running. + void _startAckTimer() { + if (_ackTimer != null) return; + + logger.fine('Starting ack timer'); + _ackTimer = Timer.periodic( + ackTimeout, + _ackTimerCallback, + ); + } + + /// Stops the timer, if it is running. + void _stopAckTimer() { + if (_ackTimer == null) return; + + logger.fine('Stopping ack timer'); + _ackTimer!.cancel(); + _ackTimer = null; + } + + /// Timer callback that checks if all acks have been answered. If not and the last + /// response has been more that [ackTimeout] in the past, declare the session dead. + void _ackTimerCallback(Timer timer) { + _ackLock.synchronized(() async { + final now = DateTime.now().millisecondsSinceEpoch; + + if (now - _lastAckTimestamp >= ackTimeout.inMilliseconds && _pendingAcks > 0) { + _stopAckTimer(); + await getAttributes().getConnection().reconnectionPolicy.onFailure(); + } + }); + } + + /// Wrapper around sending an nonza that starts the ack timeout timer. + Future _sendAckRequest() async { + logger.fine('_sendAckRequest: Waiting to acquire lock...'); + await _ackLock.synchronized(() async { + logger.fine('_sendAckRequest: Done...'); + final now = DateTime.now().millisecondsSinceEpoch; + + _lastAckTimestamp = now; + _pendingAcks++; + _startAckTimer(); + + logger.fine('_pendingAcks is now at $_pendingAcks'); + + getAttributes().sendNonza(StreamManagementRequestNonza()); + + logger.fine('_sendAckRequest: Releasing lock...'); + }); + } + + /// Resets the enablement of stream management, but __NOT__ the internal state. + /// This is to prevent ack requests being sent before we resume or re-enable + /// stream management. + void _disableStreamManagement() { + _streamManagementEnabled = false; + logger.finest('Stream Management disabled'); + } + + /// Enables support for XEP-0198 stream management + void _enableStreamManagement() { + _streamManagementEnabled = true; + logger.finest('Stream Management enabled'); + } + + /// Returns whether XEP-0198 stream management is enabled + bool isStreamManagementEnabled() => _streamManagementEnabled; + + /// To be called when receiving a nonza. + Future _handleAckRequest(XMLNode nonza) async { + final attrs = getAttributes(); + logger.finest('Sending ack response'); + await _stateLock.synchronized(() async { + attrs.sendNonza(StreamManagementAckNonza(_state.s2c)); + }); + + return true; + } + + /// Called when we receive an nonza from the server. + /// This is a response to the question "How many of my stanzas have you handled". + Future _handleAckResponse(XMLNode nonza) async { + final h = int.parse(nonza.attributes['h']! as String); + + await _ackLock.synchronized(() async { + await _stateLock.synchronized(() async { + if (_pendingAcks > 0) { + // Prevent diff from becoming negative + final diff = max(_state.c2s - h, 0); + _pendingAcks = diff; + } else { + _stopAckTimer(); + } + + logger.fine('_pendingAcks is now at $_pendingAcks'); + }); + }); + + // Return early if we acked nothing. + // Taken from slixmpp's stream management code + logger.fine('_handleAckResponse: Waiting to aquire lock...'); + await _stateLock.synchronized(() async { + logger.fine('_handleAckResponse: Done...'); + if (h == _state.c2s && _unackedStanzas.isEmpty) { + logger.fine('_handleAckResponse: Releasing lock...'); + return; + } + + final attrs = getAttributes(); + final sequences = _unackedStanzas.keys.toList()..sort(); + for (final height in sequences) { + // Do nothing if the ack does not concern this stanza + if (height > h) continue; + + final stanza = _unackedStanzas[height]!; + _unackedStanzas.remove(height); + + // Create a StanzaAckedEvent if the stanza is correct + if (shouldTriggerAckedEvent(stanza)) { + attrs.sendEvent(StanzaAckedEvent(stanza)); + } + } + + if (h > _state.c2s) { + logger.info('C2S height jumped from ${_state.c2s} (local) to $h (remote).'); + // ignore: cascade_invocations + logger.info('Proceeding with $h as local C2S counter.'); + + _state = _state.copyWith(c2s: h); + await commitState(); + } + + logger.fine('_handleAckResponse: Releasing lock...'); + }); + + return true; + } + + // Just a helper function to not increment the counters above xmlUintMax + Future _incrementC2S() async { + logger.fine('_incrementC2S: Waiting to aquire lock...'); + await _stateLock.synchronized(() async { + logger.fine('_incrementC2S: Done'); + _state = _state.copyWith(c2s: _state.c2s + 1 % xmlUintMax); + await commitState(); + logger.fine('_incrementC2S: Releasing lock...'); + }); + } + Future _incrementS2C() async { + logger.fine('_incrementS2C: Waiting to aquire lock...'); + await _stateLock.synchronized(() async { + logger.fine('_incrementS2C: Done'); + _state = _state.copyWith(s2c: _state.s2c + 1 % xmlUintMax); + await commitState(); + logger.fine('_incrementS2C: Releasing lock...'); + }); + } + + /// Called whenever we receive a stanza from the server. + Future _onServerStanzaReceived(Stanza stanza, StanzaHandlerData state) async { + await _incrementS2C(); + return state; + } + + /// Called whenever we send a stanza. + Future _onClientStanzaSent(Stanza stanza, StanzaHandlerData state) async { + await _incrementC2S(); + _unackedStanzas[_state.c2s] = stanza; + + if (isStreamManagementEnabled()) { + await _sendAckRequest(); + } + + return state; + } + + /// To be called when the stream has been resumed + @visibleForTesting + Future onStreamResumed(int h) async { + _streamResumed = true; + await _handleAckResponse(StreamManagementAckNonza(h)); + + final stanzas = _unackedStanzas.values.toList(); + _unackedStanzas.clear(); + + // Retransmit the rest of the queue + final attrs = getAttributes(); + for (final stanza in stanzas) { + await attrs.sendStanza(stanza, awaitable: false); + } + } + + /// Pings the connection open by send an ack request + void sendAckRequestPing() { + _sendAckRequest(); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0203.dart b/moxxmpp/lib/src/xeps/xep_0203.dart new file mode 100644 index 0000000..2bf4fc5 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0203.dart @@ -0,0 +1,48 @@ +import 'package:meta/meta.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; + +@immutable +class DelayedDelivery { + + const DelayedDelivery(this.from, this.timestamp); + final DateTime timestamp; + final String from; +} + +class DelayedDeliveryManager extends XmppManagerBase { + + @override + String getId() => delayedDeliveryManager; + + @override + String getName() => 'DelayedDeliveryManager'; + + @override + Future isSupported() async => true; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + callback: _onIncomingMessage, + priority: 200, + ), + ]; + + Future _onIncomingMessage(Stanza stanza, StanzaHandlerData state) async { + final delay = stanza.firstTag('delay', xmlns: delayedDeliveryXmlns); + if (delay == null) return state; + + return state.copyWith( + delayedDelivery: DelayedDelivery( + delay.attributes['from']! as String, + DateTime.parse(delay.attributes['stamp']! as String), + ), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0280.dart b/moxxmpp/lib/src/xeps/xep_0280.dart new file mode 100644 index 0000000..f40d82a --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0280.dart @@ -0,0 +1,170 @@ +import 'package:meta/meta.dart'; +import 'package:moxxmpp/src/connection.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0297.dart'; + +class CarbonsManager extends XmppManagerBase { + + CarbonsManager() : _isEnabled = false, _supported = false, _gotSupported = false, super(); + bool _isEnabled; + bool _supported; + bool _gotSupported; + + @override + String getId() => carbonsManager; + + @override + String getName() => 'CarbonsManager'; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'received', + tagXmlns: carbonsXmlns, + callback: _onMessageReceived, + // Before all managers the message manager depends on + priority: -98, + ), + StanzaHandler( + stanzaTag: 'message', + tagName: 'sent', + tagXmlns: carbonsXmlns, + callback: _onMessageSent, + // Before all managers the message manager depends on + priority: -98, + ) + ]; + + @override + Future isSupported() async { + if (_gotSupported) return _supported; + + // Query the server + final disco = getAttributes().getManagerById(discoManager)!; + _supported = await disco.supportsFeature( + getAttributes().getConnectionSettings().jid.toBare(), + carbonsXmlns, + ); + _gotSupported = true; + return _supported; + } + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is ServerDiscoDoneEvent && !_isEnabled) { + final attrs = getAttributes(); + + if (attrs.isFeatureSupported(carbonsXmlns)) { + logger.finest('Message carbons supported. Enabling...'); + await enableCarbons(); + logger.finest('Message carbons enabled'); + } else { + logger.info('Message carbons not supported.'); + } + } else if (event is StreamResumeFailedEvent) { + _gotSupported = false; + _supported = false; + } + } + + Future _onMessageReceived(Stanza message, StanzaHandlerData state) async { + final from = JID.fromString(message.attributes['from']! as String); + final received = message.firstTag('received', xmlns: carbonsXmlns)!; + if (!isCarbonValid(from)) return state.copyWith(done: true); + + final forwarded = received.firstTag('forwarded', xmlns: forwardedXmlns)!; + final carbon = unpackForwarded(forwarded); + + return state.copyWith( + isCarbon: true, + stanza: carbon, + ); + } + + Future _onMessageSent(Stanza message, StanzaHandlerData state) async { + final from = JID.fromString(message.attributes['from']! as String); + final sent = message.firstTag('sent', xmlns: carbonsXmlns)!; + if (!isCarbonValid(from)) return state.copyWith(done: true); + + final forwarded = sent.firstTag('forwarded', xmlns: forwardedXmlns)!; + final carbon = unpackForwarded(forwarded); + + return state.copyWith( + isCarbon: true, + stanza: carbon, + ); + } + + Future enableCarbons() async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'enable', + xmlns: carbonsXmlns, + ) + ], + ), + addFrom: StanzaFromType.full, + addId: true, + ); + + if (result.attributes['type'] != 'result') { + logger.warning('Failed to enable message carbons'); + + return false; + } + + logger.fine('Successfully enabled message carbons'); + + _isEnabled = true; + return true; + } + + Future disableCarbons() async { + final result = await getAttributes().sendStanza( + Stanza.iq( + type: 'set', + children: [ + XMLNode.xmlns( + tag: 'disable', + xmlns: carbonsXmlns, + ) + ], + ), + addFrom: StanzaFromType.full, + addId: true, + ); + + if (result.attributes['type'] != 'result') { + logger.warning('Failed to disable message carbons'); + + return false; + } + + logger.fine('Successfully disabled message carbons'); + + _isEnabled = false; + return true; + } + + @visibleForTesting + void forceEnable() { + _isEnabled = true; + } + + bool isCarbonValid(JID senderJid) { + return _isEnabled && senderJid == getAttributes().getConnectionSettings().jid.toBare(); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0297.dart b/moxxmpp/lib/src/xeps/xep_0297.dart new file mode 100644 index 0000000..4d060d1 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0297.dart @@ -0,0 +1,21 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +/// Extracts the message stanza from the node. +Stanza unpackForwarded(XMLNode forwarded) { + assert(forwarded.attributes['xmlns'] == forwardedXmlns, 'Invalid element xmlns'); + assert(forwarded.tag == 'forwarded', 'Invalid element name'); + + // NOTE: We only use this XEP (for now) in the context of Message Carbons + final stanza = forwarded.firstTag('message', xmlns: stanzaXmlns)!; + return Stanza( + to: stanza.attributes['to']! as String, + from: stanza.attributes['from']! as String, + type: stanza.attributes['type']! as String, + id: stanza.attributes['id']! as String, + tag: stanza.tag, + attributes: stanza.attributes as Map, + children: stanza.children, + ); +} diff --git a/moxxmpp/lib/src/xeps/xep_0300.dart b/moxxmpp/lib/src/xeps/xep_0300.dart new file mode 100644 index 0000000..e2e031f --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0300.dart @@ -0,0 +1,104 @@ +import 'package:cryptography/cryptography.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +XMLNode constructHashElement(String algo, String base64Hash) { + return XMLNode.xmlns( + tag: 'hash', + xmlns: hashXmlns, + attributes: { 'algo': algo }, + text: base64Hash, + ); +} + +enum HashFunction { + sha256, + sha512, + sha3_256, + sha3_512, + blake2b256, + blake2b512, +} + +extension HashNameToEnumExtension on HashFunction { + String toName() { + switch (this) { + case HashFunction.sha256: + return hashSha256; + case HashFunction.sha512: + return hashSha512; + case HashFunction.sha3_256: + return hashSha3512; + case HashFunction.sha3_512: + return hashSha3512; + case HashFunction.blake2b256: + return hashBlake2b256; + case HashFunction.blake2b512: + return hashBlake2b512; + } + } +} + +HashFunction hashFunctionFromName(String name) { + switch (name) { + case hashSha256: + return HashFunction.sha256; + case hashSha512: + return HashFunction.sha512; + case hashSha3256: + return HashFunction.sha3_256; + case hashSha3512: + return HashFunction.sha3_512; + case hashBlake2b256: + return HashFunction.blake2b256; + case hashBlake2b512: + return HashFunction.blake2b512; + } + + throw Exception(); +} + +class CryptographicHashManager extends XmppManagerBase { + @override + String getId() => cryptographicHashManager; + + @override + String getName() => 'CryptographicHashManager'; + + @override + Future isSupported() async => true; + + @override + List getDiscoFeatures() => [ + '$hashFunctionNameBaseXmlns:$hashSha256', + '$hashFunctionNameBaseXmlns:$hashSha512', + //'$hashFunctionNameBaseXmlns:$hashSha3256', + //'$hashFunctionNameBaseXmlns:$hashSha3512', + //'$hashFunctionNameBaseXmlns:$hashBlake2b256', + '$hashFunctionNameBaseXmlns:$hashBlake2b512', + ]; + + static Future> hashFromData(List data, HashFunction function) async { + // TODO(PapaTutuWawa): Implemen the others as well + HashAlgorithm algo; + switch (function) { + case HashFunction.sha256: + algo = Sha256(); + break; + case HashFunction.sha512: + algo = Sha512(); + break; + case HashFunction.blake2b512: + algo = Blake2b(); + break; + // ignore: no_default_cases + default: + throw Exception(); + } + + final digest = await algo.hash(data); + return digest.bytes; + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0333.dart b/moxxmpp/lib/src/xeps/xep_0333.dart new file mode 100644 index 0000000..ecec5f3 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0333.dart @@ -0,0 +1,69 @@ +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +XMLNode makeChatMarkerMarkable() { + return XMLNode.xmlns( + tag: 'markable', + xmlns: chatMarkersXmlns, + ); +} + +XMLNode makeChatMarker(String tag, String id) { + assert(['received', 'displayed', 'acknowledged'].contains(tag), 'Invalid chat marker'); + return XMLNode.xmlns( + tag: tag, + xmlns: chatMarkersXmlns, + attributes: { 'id': id }, + ); +} + +class ChatMarkerManager extends XmppManagerBase { + @override + String getName() => 'ChatMarkerManager'; + + @override + String getId() => chatMarkerManager; + + @override + List getDiscoFeatures() => [ chatMarkersXmlns ]; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagXmlns: chatMarkersXmlns, + callback: _onMessage, + // Before the message handler + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza message, StanzaHandlerData state) async { + final marker = message.firstTagByXmlns(chatMarkersXmlns)!; + + // Handle the explicitly + if (marker.tag == 'markable') return state.copyWith(isMarkable: true); + + if (!['received', 'displayed', 'acknowledged'].contains(marker.tag)) { + logger.warning("Unknown message marker '${marker.tag}' found."); + } else { + getAttributes().sendEvent(ChatMarkerEvent( + from: JID.fromString(message.from!), + type: marker.tag, + id: marker.attributes['id']! as String, + ),); + } + + return state.copyWith(done: true); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0334.dart b/moxxmpp/lib/src/xeps/xep_0334.dart new file mode 100644 index 0000000..61da7ef --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0334.dart @@ -0,0 +1,36 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +enum MessageProcessingHint { + noPermanentStore, + noStore, + noCopies, + store, +} + +/// NOTE: We do not define a function for turning a Message Processing Hint element into +/// an enum value since the elements do not concern us as a client. +extension XmlExtension on MessageProcessingHint { + XMLNode toXml() { + String tag; + switch (this) { + case MessageProcessingHint.noPermanentStore: + tag = 'no-permanent-store'; + break; + case MessageProcessingHint.noStore: + tag = 'no-store'; + break; + case MessageProcessingHint.noCopies: + tag = 'no-copy'; + break; + case MessageProcessingHint.store: + tag = 'store'; + break; + } + + return XMLNode.xmlns( + tag: tag, + xmlns: messageProcessingHintsXmlns, + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0352.dart b/moxxmpp/lib/src/xeps/xep_0352.dart new file mode 100644 index 0000000..92d4ba7 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0352.dart @@ -0,0 +1,96 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/namespaces.dart'; +import 'package:moxxmpp/src/negotiators/negotiator.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +class CSIActiveNonza extends XMLNode { + CSIActiveNonza() : super( + tag: 'active', + attributes: { + 'xmlns': csiXmlns + }, + ); +} + +class CSIInactiveNonza extends XMLNode { + CSIInactiveNonza() : super( + tag: 'inactive', + attributes: { + 'xmlns': csiXmlns + }, + ); +} + +/// A Stub negotiator that is just for "intercepting" the stream feature. +class CSINegotiator extends XmppFeatureNegotiatorBase { + CSINegotiator() : _supported = false, super(11, false, csiXmlns, csiNegotiator); + + /// True if CSI is supported. False otherwise. + bool _supported; + bool get isSupported => _supported; + + @override + Future negotiate(XMLNode nonza) async { + // negotiate is only called when the negotiator matched, meaning the server + // advertises CSI. + _supported = true; + state = NegotiatorState.done; + } + + @override + void reset() { + _supported = false; + + super.reset(); + } +} + +/// The manager requires a CSINegotiator to be registered as a feature negotiator. +class CSIManager extends XmppManagerBase { + + CSIManager() : _isActive = true, super(); + bool _isActive; + + @override + String getId() => csiManager; + + @override + String getName() => 'CSIManager'; + + @override + Future isSupported() async { + return getAttributes().getNegotiatorById(csiNegotiator)!.isSupported; + } + + /// To be called after a stream has been resumed as CSI does not + /// survive a stream resumption. + void restoreCSIState() { + if (_isActive) { + setActive(); + } else { + setInactive(); + } + } + + /// Tells the server to top optimizing traffic + Future setActive() async { + _isActive = true; + + final attrs = getAttributes(); + if (await isSupported()) { + attrs.sendNonza(CSIActiveNonza()); + } + } + + /// Tells the server to optimize traffic following XEP-0352 + Future setInactive() async { + _isActive = false; + + final attrs = getAttributes(); + if (await isSupported()) { + attrs.sendNonza(CSIInactiveNonza()); + } + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0359.dart b/moxxmpp/lib/src/xeps/xep_0359.dart new file mode 100644 index 0000000..516b0bc --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0359.dart @@ -0,0 +1,96 @@ +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; + +/// Represents data provided by XEP-0359. +/// NOTE: [StableStanzaId.stanzaId] must not be confused with the actual id attribute of +/// the message stanza. +class StableStanzaId { + + const StableStanzaId({ this.originId, this.stanzaId, this.stanzaIdBy }); + final String? originId; + final String? stanzaId; + final String? stanzaIdBy; +} + +XMLNode makeOriginIdElement(String id) { + return XMLNode.xmlns( + tag: 'origin-id', + xmlns: stableIdXmlns, + attributes: { 'id': id }, + ); +} + +class StableIdManager extends XmppManagerBase { + @override + String getName() => 'StableIdManager'; + + @override + String getId() => stableIdManager; + + @override + List getDiscoFeatures() => [ stableIdXmlns ]; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + callback: _onMessage, + // Before the MessageManager + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza message, StanzaHandlerData state) async { + final from = JID.fromString(message.attributes['from']! as String); + String? originId; + String? stanzaId; + String? stanzaIdBy; + final originIdTag = message.firstTag('origin-id', xmlns: stableIdXmlns); + final stanzaIdTag = message.firstTag('stanza-id', xmlns: stableIdXmlns); + if (originIdTag != null || stanzaIdTag != null) { + logger.finest('Found Unique and Stable Stanza Id tag'); + final attrs = getAttributes(); + final disco = attrs.getManagerById(discoManager)!; + final result = await disco.discoInfoQuery(from.toString()); + if (result.isType()) { + final info = result.get(); + logger.finest('Got info for ${from.toString()}'); + if (info.features.contains(stableIdXmlns)) { + logger.finest('${from.toString()} supports $stableIdXmlns.'); + + if (originIdTag != null) { + originId = originIdTag.attributes['id']! as String; + } + + if (stanzaIdTag != null) { + stanzaId = stanzaIdTag.attributes['id']! as String; + stanzaIdBy = stanzaIdTag.attributes['by']! as String; + } + } else { + logger.finest('${from.toString()} does not support $stableIdXmlns. Ignoring... '); + } + } else { + logger.finest('Failed to find out if ${from.toString()} supports $stableIdXmlns. Ignoring... '); + } + } + + return state.copyWith( + stableId: StableStanzaId( + originId: originId, + stanzaId: stanzaId, + stanzaIdBy: stanzaIdBy, + ), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0363.dart b/moxxmpp/lib/src/xeps/xep_0363.dart new file mode 100644 index 0000000..3cfa1f6 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0363.dart @@ -0,0 +1,180 @@ +import 'package:meta/meta.dart'; +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/types/error.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; + +const errorNoUploadServer = 1; +const errorFileTooBig = 2; +const errorGeneric = 3; + +const allowedHTTPHeaders = [ 'authorization', 'cookie', 'expires' ]; + +class HttpFileUploadSlot { + + const HttpFileUploadSlot(this.putUrl, this.getUrl, this.headers); + final String putUrl; + final String getUrl; + final Map headers; +} + +/// Strips out all newlines from [value]. +String _stripNewlinesFromString(String value) { + return value.replaceAll('\n', '').replaceAll('\r', ''); +} + +/// Prepares a list of headers by removing newlines from header names and values +/// and also removes any headers that are not allowed by the XEP. +@visibleForTesting +Map prepareHeaders(Map headers) { + return headers.map((key, value) { + return MapEntry( + _stripNewlinesFromString(key), + _stripNewlinesFromString(value), + ); + }) + ..removeWhere((key, _) => !allowedHTTPHeaders.contains(key.toLowerCase())); +} + +class HttpFileUploadManager extends XmppManagerBase { + + HttpFileUploadManager() : _gotSupported = false, _supported = false, super(); + JID? _entityJid; + int? _maxUploadSize; + bool _gotSupported; + bool _supported; + + @override + String getId() => httpFileUploadManager; + + @override + String getName() => 'HttpFileUploadManager'; + + /// Returns whether the entity provided an identity that tells us that we can ask it + /// for an HTTP upload slot. + bool _containsFileUploadIdentity(DiscoInfo info) { + return listContains(info.identities, (Identity id) => id.category == 'store' && id.type == 'file'); + } + + /// Extract the maximum filesize in octets from the disco response. Returns null + /// if none was specified. + int? _getMaxFileSize(DiscoInfo info) { + for (final form in info.extendedInfo) { + for (final field in form.fields) { + if (field.varAttr == 'max-file-size') { + return int.parse(field.values.first); + } + } + } + + return null; + } + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is StreamResumeFailedEvent) { + _gotSupported = false; + _supported = false; + _entityJid = null; + _maxUploadSize = null; + } + } + + @override + Future isSupported() async { + if (_gotSupported) return _supported; + + final result = await getAttributes().getManagerById(discoManager)!.performDiscoSweep(); + if (result.isType()) { + _gotSupported = false; + _supported = false; + return false; + } + + final infos = result.get>(); + _gotSupported = true; + for (final info in infos) { + if (_containsFileUploadIdentity(info) && info.features.contains(httpFileUploadXmlns)) { + logger.info('Discovered HTTP File Upload for ${info.jid}'); + + _entityJid = info.jid; + _maxUploadSize = _getMaxFileSize(info); + _supported = true; + break; + } + } + + return _supported; + } + + /// Request a slot to upload a file to. [filename] is the file's name and [filesize] is + /// the file's size in octets. [contentType] is optional and refers to the file's + /// Mime type. + /// Returns an [HttpFileUploadSlot] if the request was successful; null otherwise. + Future> requestUploadSlot(String filename, int filesize, { String? contentType }) async { + if (!(await isSupported())) return MayFail.failure(errorNoUploadServer); + + if (_entityJid == null) { + logger.warning('Attempted to request HTTP File Upload slot but no entity is known to send this request to.'); + return MayFail.failure(errorNoUploadServer); + } + + if (_maxUploadSize != null && filesize > _maxUploadSize!) { + logger.warning('Attempted to request HTTP File Upload slot for a file that exceeds the filesize limit'); + return MayFail.failure(errorFileTooBig); + } + + final attrs = getAttributes(); + final response = await attrs.sendStanza( + Stanza.iq( + to: _entityJid.toString(), + type: 'get', + children: [ + XMLNode.xmlns( + tag: 'request', + xmlns: httpFileUploadXmlns, + attributes: { + 'filename': filename, + 'size': filesize.toString(), + ...contentType != null ? { 'content-type': contentType } : {} + }, + ) + ], + ), + ); + + if (response.attributes['type']! != 'result') { + logger.severe('Failed to request HTTP File Upload slot.'); + // TODO(Unknown): Be more precise + return MayFail.failure(errorGeneric); + } + + final slot = response.firstTag('slot', xmlns: httpFileUploadXmlns)!; + final putUrl = slot.firstTag('put')!.attributes['url']! as String; + final getUrl = slot.firstTag('get')!.attributes['url']! as String; + final headers = Map.fromEntries( + slot.findTags('header').map((tag) { + return MapEntry( + tag.attributes['name']! as String, + tag.innerText(), + ); + }), + ); + + return MayFail.success( + HttpFileUploadSlot( + putUrl, + getUrl, + prepareHeaders(headers), + ), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0380.dart b/moxxmpp/lib/src/xeps/xep_0380.dart new file mode 100644 index 0000000..5c6e113 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0380.dart @@ -0,0 +1,91 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +enum ExplicitEncryptionType { + otr, + legacyOpenPGP, + openPGP, + omemo, + omemo1, + omemo2, + unknown, +} + +String _explicitEncryptionTypeToString(ExplicitEncryptionType type) { + switch (type) { + case ExplicitEncryptionType.otr: return emeOtr; + case ExplicitEncryptionType.legacyOpenPGP: return emeLegacyOpenPGP; + case ExplicitEncryptionType.openPGP: return emeOpenPGP; + case ExplicitEncryptionType.omemo: return emeOmemo; + case ExplicitEncryptionType.omemo1: return emeOmemo1; + case ExplicitEncryptionType.omemo2: return emeOmemo2; + case ExplicitEncryptionType.unknown: return ''; + } +} + +ExplicitEncryptionType _explicitEncryptionTypeFromString(String str) { + switch (str) { + case emeOtr: return ExplicitEncryptionType.otr; + case emeLegacyOpenPGP: return ExplicitEncryptionType.legacyOpenPGP; + case emeOpenPGP: return ExplicitEncryptionType.openPGP; + case emeOmemo: return ExplicitEncryptionType.omemo; + case emeOmemo1: return ExplicitEncryptionType.omemo1; + case emeOmemo2: return ExplicitEncryptionType.omemo2; + default: return ExplicitEncryptionType.unknown; + } +} + +/// Create an element with [type] indicating which type of encryption was +/// used. +XMLNode buildEmeElement(ExplicitEncryptionType type) { + return XMLNode.xmlns( + tag: 'encryption', + xmlns: emeXmlns, + attributes: { + 'namespace': _explicitEncryptionTypeToString(type), + }, + ); +} + +class EmeManager extends XmppManagerBase { + + EmeManager() : super(); + + @override + String getId() => emeManager; + + @override + String getName() => 'EmeManager'; + + @override + Future isSupported() async => true; + + @override + List getDiscoFeatures() => [emeXmlns]; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + tagName: 'encryption', + tagXmlns: emeXmlns, + callback: _onStanzaReceived, + // Before the message handler + priority: -99, + ), + ]; + + Future _onStanzaReceived(Stanza message, StanzaHandlerData state) async { + final encryption = message.firstTag('encryption', xmlns: emeXmlns)!; + + return state.copyWith( + encryptionType: _explicitEncryptionTypeFromString( + encryption.attributes['namespace']! as String, + ), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0384/crypto.dart b/moxxmpp/lib/src/xeps/xep_0384/crypto.dart new file mode 100644 index 0000000..f374836 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0384/crypto.dart @@ -0,0 +1,20 @@ +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/stringxml.dart'; + +/// Checks the OMEMO affix elements. [envelope] refers to the element we get +/// after decrypting the payload. [sender] refers to the "to" attribute of the stanza. +/// [ourJid] is our current full Jid. +/// +/// Returns true if the affix elements are all valid and as expected. Returns false if not. +bool checkAffixElements(XMLNode envelope, String sender, JID ourJid) { + final from = envelope.firstTag('from')?.attributes['jid'] as String?; + if (from == null) return false; + final encSender = JID.fromString(from); + + final to = envelope.firstTag('to')?.attributes['jid'] as String?; + if (to == null) return false; + final encReceiver = JID.fromString(to); + + return encSender.toBare().toString() == JID.fromString(sender).toBare().toString() && + encReceiver.toBare().toString() == ourJid.toBare().toString(); +} diff --git a/moxxmpp/lib/src/xeps/xep_0384/errors.dart b/moxxmpp/lib/src/xeps/xep_0384/errors.dart new file mode 100644 index 0000000..ddfd568 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0384/errors.dart @@ -0,0 +1,9 @@ +abstract class OmemoError {} + +class UnknownOmemoError extends OmemoError {} + +class InvalidAffixElementsException with Exception {} + +class OmemoNotSupportedForContactException extends OmemoError {} + +class EncryptionFailedException with Exception {} diff --git a/moxxmpp/lib/src/xeps/xep_0384/helpers.dart b/moxxmpp/lib/src/xeps/xep_0384/helpers.dart new file mode 100644 index 0000000..1ed902b --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0384/helpers.dart @@ -0,0 +1,82 @@ +import 'dart:math'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:omemo_dart/omemo_dart.dart'; +import 'package:random_string/random_string.dart'; + +/// Generate a random alpha-numeric string with a random length between 0 and 200 in +/// accordance to XEP-0420's rpad affix element. +String generateRpad() { + final random = Random.secure(); + final length = random.nextInt(200); + return randomAlphaNumeric(length, provider: CoreRandomProvider.from(random)); +} + +/// Convert the XML representation of an OMEMO bundle into an OmemoBundle object. +/// [jid] refers to the JID the bundle belongs to. [id] refers to the bundle's device +/// identifier. [bundle] refers to the element. +/// +/// Returns the OmemoBundle. +OmemoBundle bundleFromXML(JID jid, int id, XMLNode bundle) { + assert(bundle.attributes['xmlns'] == omemoXmlns, 'Invalid xmlns'); + + final spk = bundle.firstTag('spk')!; + final prekeys = {}; + for (final pk in bundle.firstTag('prekeys')!.findTags('pk')) { + prekeys[int.parse(pk.attributes['id']! as String)] = pk.innerText(); + } + + return OmemoBundle( + jid.toBare().toString(), + id, + spk.innerText(), + int.parse(spk.attributes['id']! as String), + bundle.firstTag('spks')!.innerText(), + bundle.firstTag('ik')!.innerText(), + prekeys, + ); +} + +/// Converts an OmemoBundle [bundle] into its XML representation. +/// +/// Returns the XML element. +XMLNode bundleToXML(OmemoBundle bundle) { + final prekeys = List.empty(growable: true); + for (final pk in bundle.opksEncoded.entries) { + prekeys.add( + XMLNode( + tag: 'pk', attributes: { + 'id': '${pk.key}', + }, + text: pk.value, + ), + ); + } + + return XMLNode.xmlns( + tag: 'bundle', + xmlns: omemoXmlns, + children: [ + XMLNode( + tag: 'spk', + attributes: { + 'id': '${bundle.spkId}', + }, + text: bundle.spkEncoded, + ), + XMLNode( + tag: 'spks', + text: bundle.spkSignatureEncoded, + ), + XMLNode( + tag: 'ik', + text: bundle.ikEncoded, + ), + XMLNode( + tag: 'prekeys', + children: prekeys, + ), + ], + ); +} diff --git a/moxxmpp/lib/src/xeps/xep_0384/types.dart b/moxxmpp/lib/src/xeps/xep_0384/types.dart new file mode 100644 index 0000000..fdfd872 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0384/types.dart @@ -0,0 +1,7 @@ +/// A simple wrapper class for defining elements that should not be encrypted. +class DoNotEncrypt { + + const DoNotEncrypt(this.tag, this.xmlns); + final String tag; + final String xmlns; +} diff --git a/moxxmpp/lib/src/xeps/xep_0384/xep_0384.dart b/moxxmpp/lib/src/xeps/xep_0384/xep_0384.dart new file mode 100644 index 0000000..e88db31 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0384/xep_0384.dart @@ -0,0 +1,953 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'package:meta/meta.dart'; +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/events.dart'; +import 'package:moxxmpp/src/jid.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/types/resultv2.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/types.dart'; +import 'package:moxxmpp/src/xeps/xep_0030/xep_0030.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0060/xep_0060.dart'; +import 'package:moxxmpp/src/xeps/xep_0334.dart'; +import 'package:moxxmpp/src/xeps/xep_0380.dart'; +import 'package:moxxmpp/src/xeps/xep_0384/crypto.dart'; +import 'package:moxxmpp/src/xeps/xep_0384/errors.dart'; +import 'package:moxxmpp/src/xeps/xep_0384/helpers.dart'; +import 'package:moxxmpp/src/xeps/xep_0384/types.dart'; +import 'package:omemo_dart/omemo_dart.dart'; +import 'package:synchronized/synchronized.dart'; + +const _doNotEncryptList = [ + // XEP-0033 + DoNotEncrypt('addresses', extendedAddressingXmlns), + // XEP-0060 + DoNotEncrypt('pubsub', pubsubXmlns), + DoNotEncrypt('pubsub', pubsubOwnerXmlns), + // XEP-0334 + DoNotEncrypt('no-permanent-store', messageProcessingHintsXmlns), + DoNotEncrypt('no-store', messageProcessingHintsXmlns), + DoNotEncrypt('no-copy', messageProcessingHintsXmlns), + DoNotEncrypt('store', messageProcessingHintsXmlns), + // XEP-0359 + DoNotEncrypt('origin-id', stableIdXmlns), + DoNotEncrypt('stanza-id', stableIdXmlns), +]; + +abstract class OmemoManager extends XmppManagerBase { + + OmemoManager() : _handlerLock = Lock(), _handlerFutures = {}, super(); + + final Lock _handlerLock; + final Map>> _handlerFutures; + + final Map> _deviceMap = {}; + + // Mapping whether we already tried to subscribe to the JID's devices node + final Map _subscriptionMap = {}; + + @override + String getId() => omemoManager; + + @override + String getName() => 'OmemoManager'; + + // TODO(Unknown): Technically, this is not always true + @override + Future isSupported() async => true; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'iq', + tagXmlns: omemoXmlns, + tagName: 'encrypted', + callback: _onIncomingStanza, + priority: 9999, + ), + StanzaHandler( + stanzaTag: 'presence', + tagXmlns: omemoXmlns, + tagName: 'encrypted', + callback: _onIncomingStanza, + priority: 9999, + ), + StanzaHandler( + stanzaTag: 'message', + tagXmlns: omemoXmlns, + tagName: 'encrypted', + callback: _onIncomingStanza, + priority: -98, + ), + ]; + + @override + List getOutgoingPreStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'iq', + callback: _onOutgoingStanza, + ), + StanzaHandler( + stanzaTag: 'presence', + callback: _onOutgoingStanza, + ), + StanzaHandler( + stanzaTag: 'message', + callback: _onOutgoingStanza, + priority: 100, + ), + ]; + + @override + Future onXmppEvent(XmppEvent event) async { + if (event is PubSubNotificationEvent) { + if (event.item.node != omemoDevicesXmlns) return; + + logger.finest('Received PubSub device notification for ${event.from}'); + final ownJid = getAttributes().getFullJID().toBare().toString(); + final jid = JID.fromString(event.from).toBare(); + final ids = event.item.payload.children + .map((child) => int.parse(child.attributes['id']! as String)) + .toList(); + + if (event.from == ownJid) { + // Another client published to our device list node + if (!ids.contains(await _getDeviceId())) { + // Attempt to publish again + unawaited(publishBundle(await _getDeviceBundle())); + } + } else { + // Someone published to their device list node + logger.finest('Got devices $ids'); + _deviceMap[jid] = ids; + } + + // Generate an event + getAttributes().sendEvent(OmemoDeviceListUpdatedEvent(jid, ids)); + } + } + + @visibleForOverriding + Future getSessionManager(); + + /// Wrapper around using getSessionManager and then calling encryptToJids on it. + Future _encryptToJids(List jids, String? plaintext, { List? newSessions }) async { + final session = await getSessionManager(); + return session.encryptToJids(jids, plaintext, newSessions: newSessions); + } + + /// Wrapper around using getSessionManager and then calling encryptToJids on it. + Future _decryptMessage(List? ciphertext, String senderJid, int senderDeviceId, List keys, int sendTimestamp) async { + final session = await getSessionManager(); + return session.decryptMessage( + ciphertext, + senderJid, + senderDeviceId, + keys, + sendTimestamp, + ); + } + + /// Wrapper around using getSessionManager and then calling getDeviceId on it. + Future _getDeviceId() async { + final session = await getSessionManager(); + return session.getDeviceId(); + } + + /// Wrapper around using getSessionManager and then calling getDeviceId on it. + Future _getDeviceBundle() async { + final session = await getSessionManager(); + return session.getDeviceBundle(); + } + + /// Wrapper around using getSessionManager and then calling isRatchetAcknowledged on it. + Future _isRatchetAcknowledged(String jid, int deviceId) async { + final session = await getSessionManager(); + return session.isRatchetAcknowledged(jid, deviceId); + } + + /// Wrapper around checking if [jid] appears in the session manager's device map. + Future _hasSessionWith(String jid) async { + final session = await getSessionManager(); + final deviceMap = await session.getDeviceMap(); + return deviceMap.containsKey(jid); + } + + /// Determines what child elements of a stanza should be encrypted. If shouldEncrypt + /// returns true for [element], then [element] will be encrypted. If shouldEncrypt + /// returns false, then [element] won't be encrypted. + /// + /// The default implementation ignores all elements that are mentioned in XEP-0420, i.e.: + /// - XEP-0033 elements () + /// - XEP-0334 elements (, , , ) + /// - XEP-0359 elements (, ) + @visibleForOverriding + bool shouldEncryptElement(XMLNode element) { + for (final ignore in _doNotEncryptList) { + final xmlns = element.attributes['xmlns'] ?? ''; + if (element.tag == ignore.tag && xmlns == ignore.xmlns) { + return false; + } + } + + return true; + } + + /// Encrypt [children] using OMEMO. This either produces an element with + /// an attached payload, if [children] is not null, or an empty OMEMO message if + /// [children] is null. This function takes care of creating the affix elements as + /// specified by both XEP-0420 and XEP-0384. + /// [jids] is the list of JIDs the payload should be encrypted for. + Future _encryptChildren(List? children, List jids, String toJid, List newSessions) async { + XMLNode? payload; + if (children != null) { + payload = XMLNode.xmlns( + tag: 'envelope', + xmlns: sceXmlns, + children: [ + XMLNode( + tag: 'content', + children: children, + ), + + XMLNode( + tag: 'rpad', + text: generateRpad(), + ), + XMLNode( + tag: 'to', + attributes: { + 'jid': toJid, + }, + ), + XMLNode( + tag: 'from', + attributes: { + 'jid': getAttributes().getFullJID().toString(), + }, + ), + /* + XMLNode( + tag: 'time', + // TODO(Unknown): Implement + attributes: { + 'stamp': '', + }, + ), + */ + ], + ); + } + + final encryptedEnvelope = await _encryptToJids( + jids, + payload?.toXml(), + newSessions: newSessions, + ); + + final keyElements = >{}; + for (final key in encryptedEnvelope.encryptedKeys) { + final keyElement = XMLNode( + tag: 'key', + attributes: { + 'rid': '${key.rid}', + 'kex': key.kex ? 'true' : 'false', + }, + text: key.value, + ); + + if (keyElements.containsKey(key.jid)) { + keyElements[key.jid]!.add(keyElement); + } else { + keyElements[key.jid] = [keyElement]; + } + } + + final keysElements = keyElements.entries.map((entry) { + return XMLNode( + tag: 'keys', + attributes: { + 'jid': entry.key, + }, + children: entry.value, + ); + }).toList(); + + var payloadElement = []; + if (payload != null) { + payloadElement = [ + XMLNode( + tag: 'payload', + text: base64.encode(encryptedEnvelope.ciphertext!), + ), + ]; + } + + return XMLNode.xmlns( + tag: 'encrypted', + xmlns: omemoXmlns, + children: [ + ...payloadElement, + XMLNode( + tag: 'header', + attributes: { + 'sid': (await _getDeviceId()).toString(), + }, + children: keysElements, + ), + ], + ); + } + + /// A logging wrapper around acking the ratchet with [jid] with identifier [deviceId]. + Future _ackRatchet(String jid, int deviceId) async { + logger.finest('Acking ratchet $jid:$deviceId'); + final session = await getSessionManager(); + await session.ratchetAcknowledged(jid, deviceId); + } + + /// Figure out if new sessions need to be built. [toJid] is the JID of the entity we + /// want to send a message to. [children] refers to the unencrypted children of the + /// message. They are required to be passed because shouldIgnoreUnackedRatchets is + /// called here. + /// + /// Either returns a list of bundles we "need" to build a session with or an OmemoError. + Future>> _findNewSessions(JID toJid, List children) async { + final ownJid = getAttributes().getFullJID().toBare(); + final session = await getSessionManager(); + final ownId = await session.getDeviceId(); + + // Ignore our own device if it is the only published device on our devices node + if (toJid.toBare() == ownJid) { + final deviceList = await getDeviceList(ownJid); + if (deviceList.isType>()) { + final devices = deviceList.get>(); + if (devices.length == 1 && devices.first == ownId) { + return const Result([]); + } + } + } + + final newSessions = List.empty(growable: true); + final sessionAvailable = await _hasSessionWith(toJid.toString()); + if (!sessionAvailable) { + logger.finest('No session for $toJid. Retrieving bundles to build a new session.'); + final result = await retrieveDeviceBundles(toJid); + if (result.isType>()) { + final bundles = result.get>(); + + if (ownJid == toJid) { + logger.finest('Requesting bundles for own JID. Ignoring current device'); + newSessions.addAll(bundles.where((bundle) => bundle.id != ownId)); + } else { + newSessions.addAll(bundles); + } + } else { + logger.warning('Failed to retrieve device bundles for $toJid'); + return Result(OmemoNotSupportedForContactException()); + } + + if (!_subscriptionMap.containsKey(toJid)) { + await subscribeToDeviceList(toJid); + } + } else { + final toBare = toJid.toBare(); + final ratchetSessions = (await session.getDeviceMap())[toBare.toString()]!; + final deviceMapRaw = await getDeviceList(toBare); + if (!_subscriptionMap.containsKey(toBare)) { + unawaited(subscribeToDeviceList(toBare)); + } + + if (deviceMapRaw.isType()) { + logger.warning('Failed to get device list'); + return Result(UnknownOmemoError()); + } + + final deviceList = deviceMapRaw.get>(); + for (final id in deviceList) { + // We already have a session with that device + if (ratchetSessions.contains(id)) continue; + + // Ignore requests for our own device. + if (toJid == ownJid && id == ownId) { + logger.finest('Attempted to request bundle for our own device $id, which is the current device. Skipping request...'); + continue; + } + + logger.finest('Retrieving bundle for $toJid:$id'); + final bundle = await retrieveDeviceBundle(toJid, id); + if (bundle.isType()) { + newSessions.add(bundle.get()); + } else { + logger.warning('Failed to retrieve bundle for $toJid:$id'); + } + } + } + + return Result(newSessions); + } + + /// Sends an empty Omemo message to [toJid]. + /// + /// If [findNewSessions] is true, then + /// new devices will be looked for first before sending the message. This means that + /// the new sessions will be included in the empty Omemo message. If false, then no + /// new sessions will be looked for before encrypting. + /// + /// [calledFromCriticalSection] MUST NOT be used from outside the manager. If true, then + /// sendEmptyMessage will not attempt to enter the critical section guarding the + /// encryption and decryption. If false, then the critical section will be entered before + /// encryption and left after sending the message. + Future sendEmptyMessage(JID toJid, { + bool findNewSessions = false, + @protected + bool calledFromCriticalSection = false, + }) async { + if (!calledFromCriticalSection) { + final completer = await _handlerEntry(toJid); + if (completer != null) { + await completer.future; + } + } + + var newSessions = []; + if (findNewSessions) { + final result = await _findNewSessions(toJid, []); + if (!result.isType()) newSessions = result.get>(); + } + + final empty = await _encryptChildren( + null, + [toJid.toString()], + toJid.toString(), + newSessions, + ); + + await getAttributes().sendStanza( + Stanza.message( + to: toJid.toString(), + type: 'chat', + children: [ + empty, + + // Add a storage hint in case this is a message + // Taken from the example at + // https://xmpp.org/extensions/xep-0384.html#message-structure-description. + MessageProcessingHint.store.toXml(), + ], + ), + awaitable: false, + encrypted: true, + ); + + if (!calledFromCriticalSection) { + await _handlerExit(toJid); + } + } + + Future _onOutgoingStanza(Stanza stanza, StanzaHandlerData state) async { + if (state.encrypted) { + logger.finest('Not encrypting since state.encrypted is true'); + return state; + } + + if (stanza.to == null) { + // We cannot encrypt in this case. + return state; + } + + final toJid = JID.fromString(stanza.to!).toBare(); + if (!(await shouldEncryptStanza(toJid, stanza))) { + logger.finest('shouldEncryptStanza returned false for message to $toJid. Not encrypting.'); + return state; + } else { + logger.finest('shouldEncryptStanza returned true for message to $toJid.'); + } + + final completer = await _handlerEntry(toJid); + if (completer != null) { + await completer.future; + } + + final newSessions = List.empty(growable: true); + // Try to find new sessions for [toJid]. + final resultToJid = await _findNewSessions(toJid, stanza.children); + if (resultToJid.isType>()) { + newSessions.addAll(resultToJid.get>()); + } else { + if (resultToJid.isType()) { + await _handlerExit(toJid); + return state.copyWith( + cancel: true, + cancelReason: resultToJid.get(), + ); + } + } + + // Try to find new sessions for our own Jid. + final ownJid = getAttributes().getFullJID().toBare(); + final resultOwnJid = await _findNewSessions(ownJid, stanza.children); + if (resultOwnJid.isType>()) { + newSessions.addAll(resultOwnJid.get>()); + } + + final toEncrypt = List.empty(growable: true); + final children = List.empty(growable: true); + for (final child in stanza.children) { + if (!shouldEncryptElement(child)) { + children.add(child); + } else { + toEncrypt.add(child); + } + } + + final jidsToEncryptFor = [JID.fromString(stanza.to!).toBare().toString()]; + // Prevent encrypting to self if there is only one device (ours). + if (await _hasSessionWith(ownJid.toString())) { + jidsToEncryptFor.add(ownJid.toString()); + } + + try { + logger.finest('Encrypting stanza'); + final encrypted = await _encryptChildren( + toEncrypt, + jidsToEncryptFor, + stanza.to!, + newSessions, + ); + logger.finest('Encryption done'); + + children.add(encrypted); + + // Only add EME when sending a message + if (stanza.tag == 'message') { + children.add(buildEmeElement(ExplicitEncryptionType.omemo2)); + } + + // Add a storage hint in case this is a message + // Taken from the example at + // https://xmpp.org/extensions/xep-0384.html#message-structure-description. + if (stanza.tag == 'message') { + children.add(MessageProcessingHint.store.toXml()); + } + + await _handlerExit(toJid); + return state.copyWith( + stanza: state.stanza.copyWith( + children: children, + ), + encrypted: true, + ); + } catch (ex) { + logger.severe('Encryption failed! $ex'); + await _handlerExit(toJid); + return state.copyWith( + cancel: true, + cancelReason: EncryptionFailedException(), + other: { + ...state.other, + 'encryption_error': ex, + }, + ); + } + } + + /// This function returns true if the encryption scheme should ignore unacked ratchets + /// and don't try to build a new ratchet even though there are unacked ones. + /// The current logic is that chat states with no body ignore the "ack" state of the + /// ratchets. + /// + /// This function may be overriden. By default, the ack status of the ratchet is ignored + /// if we're sending a message containing chatstates or chat markers and the message does + /// not contain a element. + @visibleForOverriding + bool shouldIgnoreUnackedRatchets(List children) { + return listContains( + children, + (XMLNode child) { + return child.attributes['xmlns'] == chatStateXmlns || child.attributes['xmlns'] == chatMarkersXmlns; + }, + ) && !listContains( + children, + (XMLNode child) => child.tag == 'body', + ); + } + + /// This function is called whenever a message is to be encrypted. If it returns true, + /// then the message will be encrypted. If it returns false, the message won't be + /// encrypted. + @visibleForOverriding + Future shouldEncryptStanza(JID toJid, Stanza stanza); + + /// Wrapper function that attempts to enter the encryption/decryption critical section. + /// In case the critical section could be entered, null is returned. If not, then a + /// Completer is returned whose future will resolve once the critical section can be + /// entered. + Future?> _handlerEntry(JID fromJid) async { + return _handlerLock.synchronized(() { + if (_handlerFutures.containsKey(fromJid)) { + final c = Completer(); + _handlerFutures[fromJid]!.addLast(c); + return c; + } + + _handlerFutures[fromJid] = Queue(); + return null; + }); + } + + /// Wrapper function that exits the critical section. + Future _handlerExit(JID fromJid) async { + await _handlerLock.synchronized(() { + if (_handlerFutures.containsKey(fromJid)) { + if (_handlerFutures[fromJid]!.isEmpty) { + _handlerFutures.remove(fromJid); + return; + } + + _handlerFutures[fromJid]!.removeFirst().complete(); + } + }); + } + + Future _onIncomingStanza(Stanza stanza, StanzaHandlerData state) async { + final encrypted = stanza.firstTag('encrypted', xmlns: omemoXmlns); + if (encrypted == null) return state; + if (stanza.from == null) return state; + + final fromJid = JID.fromString(stanza.from!).toBare(); + final completer = await _handlerEntry(fromJid); + if (completer != null) { + await completer.future; + } + + final header = encrypted.firstTag('header')!; + final payloadElement = encrypted.firstTag('payload'); + final keys = List.empty(growable: true); + for (final keysElement in header.findTags('keys')) { + final jid = keysElement.attributes['jid']! as String; + for (final key in keysElement.findTags('key')) { + keys.add( + EncryptedKey( + jid, + int.parse(key.attributes['rid']! as String), + key.innerText(), + key.attributes['kex'] == 'true', + ), + ); + } + } + + final ourJid = getAttributes().getFullJID(); + final sid = int.parse(header.attributes['sid']! as String); + + // Ensure that if we receive a message from a device that we don't know about, we + // ensure that _deviceMap is up-to-date. + final devices = _deviceMap[fromJid] ?? []; + if (!devices.contains(sid)) { + await getDeviceList(fromJid); + } + + String? decrypted; + try { + decrypted = await _decryptMessage( + payloadElement != null ? base64.decode(payloadElement.innerText()) : null, + fromJid.toString(), + sid, + keys, + state.delayedDelivery?.timestamp.millisecondsSinceEpoch ?? DateTime.now().millisecondsSinceEpoch, + ); + } catch (ex) { + logger.warning('Error occurred during message decryption: $ex'); + + await _handlerExit(fromJid); + return state.copyWith( + other: { + ...state.other, + 'encryption_error': ex, + }, + ); + } + + final isAcked = await _isRatchetAcknowledged(fromJid.toString(), sid); + if (!isAcked) { + // Unacked ratchet decrypted this message + if (decrypted != null) { + // The message is not empty, i.e. contains content + logger.finest('Received non-empty OMEMO encrypted message for unacked ratchet. Acking with empty OMEMO message.'); + + await _ackRatchet(fromJid.toString(), sid); + await sendEmptyMessage(fromJid, calledFromCriticalSection: true); + + final envelope = XMLNode.fromString(decrypted); + final children = stanza.children.where( + (child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns, + ).toList() + ..addAll(envelope.firstTag('content')!.children); + + final other = Map.from(state.other); + if (!checkAffixElements(envelope, stanza.from!, ourJid)) { + other['encryption_error'] = InvalidAffixElementsException(); + } + + await _handlerExit(fromJid); + return state.copyWith( + encrypted: true, + stanza: Stanza( + to: stanza.to, + from: stanza.from, + id: stanza.id, + type: stanza.type, + children: children, + tag: stanza.tag, + attributes: Map.from(stanza.attributes), + ), + other: other, + ); + } else { + logger.info('Received empty OMEMO message for unacked ratchet. Marking $fromJid:$sid as acked'); + await _ackRatchet(fromJid.toString(), sid); + + final ownId = await (await getSessionManager()).getDeviceId(); + final kex = keys.any((key) => key.kex && key.rid == ownId); + if (kex) { + logger.info('Empty OMEMO message contained a kex. Answering.'); + await sendEmptyMessage(fromJid, calledFromCriticalSection: true); + } + + await _handlerExit(fromJid); + return state; + } + } else { + // The ratchet that decrypted the message was acked + if (decrypted != null) { + final envelope = XMLNode.fromString(decrypted); + + final children = stanza.children.where( + (child) => child.tag != 'encrypted' || child.attributes['xmlns'] != omemoXmlns, + ).toList() + ..addAll(envelope.firstTag('content')!.children); + + final other = Map.from(state.other); + if (!checkAffixElements(envelope, stanza.from!, ourJid)) { + other['encryption_error'] = InvalidAffixElementsException(); + } + + await _handlerExit(fromJid); + return state.copyWith( + encrypted: true, + stanza: Stanza( + to: stanza.to, + from: stanza.from, + id: stanza.id, + type: stanza.type, + children: children, + tag: stanza.tag, + attributes: Map.from(stanza.attributes), + ), + other: other, + ); + } else { + logger.info('Received empty OMEMO message on acked ratchet. Doing nothing'); + await _handlerExit(fromJid); + return state; + } + } + } + + /// Convenience function that attempts to retrieve the raw XML payload from the + /// device list PubSub node. + /// + /// On success, returns the XML data. On failure, returns an OmemoError. + Future> _retrieveDeviceListPayload(JID jid) async { + final pm = getAttributes().getManagerById(pubsubManager)!; + final result = await pm.getItems(jid.toBare().toString(), omemoDevicesXmlns); + if (result.isType()) return Result(UnknownOmemoError()); + return Result(result.get>().first.payload); + } + + /// Retrieves the OMEMO device list from [jid]. + Future>> getDeviceList(JID jid) async { + if (_deviceMap.containsKey(jid)) return Result(_deviceMap[jid]); + + final itemsRaw = await _retrieveDeviceListPayload(jid); + if (itemsRaw.isType()) return Result(UnknownOmemoError()); + + final ids = itemsRaw.get().children + .map((child) => int.parse(child.attributes['id']! as String)) + .toList(); + _deviceMap[jid] = ids; + return Result(ids); + } + + /// Retrieve all device bundles for the JID [jid]. + /// + /// On success, returns a list of devices. On failure, returns am OmemoError. + Future>> retrieveDeviceBundles(JID jid) async { + // TODO(Unknown): Should we query the device list first? + final pm = getAttributes().getManagerById(pubsubManager)!; + final bundlesRaw = await pm.getItems(jid.toString(), omemoBundlesXmlns); + if (bundlesRaw.isType()) return Result(UnknownOmemoError()); + + final bundles = bundlesRaw.get>().map( + (bundle) => bundleFromXML(jid, int.parse(bundle.id), bundle.payload), + ).toList(); + + return Result(bundles); + } + + /// Retrieves a bundle from entity [jid] with the device id [deviceId]. + /// + /// On success, returns the device bundle. On failure, returns an OmemoError. + Future> retrieveDeviceBundle(JID jid, int deviceId) async { + final pm = getAttributes().getManagerById(pubsubManager)!; + final bareJid = jid.toBare().toString(); + final item = await pm.getItem(bareJid, omemoBundlesXmlns, '$deviceId'); + if (item.isType()) return Result(UnknownOmemoError()); + + return Result(bundleFromXML(jid, deviceId, item.get().payload)); + } + + /// Attempts to publish a device bundle to the device list and device bundle PubSub + /// nodes. + /// + /// On success, returns true. On failure, returns an OmemoError. + Future> publishBundle(OmemoBundle bundle) async { + final attrs = getAttributes(); + final pm = attrs.getManagerById(pubsubManager)!; + final bareJid = attrs.getFullJID().toBare(); + + XMLNode? deviceList; + final deviceListRaw = await _retrieveDeviceListPayload(bareJid); + if (!deviceListRaw.isType()) { + deviceList = deviceListRaw.get(); + } + + deviceList ??= XMLNode.xmlns( + tag: 'devices', + xmlns: omemoDevicesXmlns, + ); + + final ids = deviceList.children + .map((child) => int.parse(child.attributes['id']! as String)); + + if (!ids.contains(bundle.id)) { + // Only update the device list if the device Id is not there + final newDeviceList = XMLNode.xmlns( + tag: 'devices', + xmlns: omemoDevicesXmlns, + children: [ + ...deviceList.children, + XMLNode( + tag: 'device', + attributes: { + 'id': '${bundle.id}', + }, + ), + ], + ); + + final deviceListPublish = await pm.publish( + bareJid.toString(), + omemoDevicesXmlns, + newDeviceList, + id: 'current', + options: const PubSubPublishOptions( + accessModel: 'open', + ), + ); + if (deviceListPublish.isType()) return const Result(false); + } + + final deviceBundlePublish = await pm.publish( + bareJid.toString(), + omemoBundlesXmlns, + bundleToXML(bundle), + id: '${bundle.id}', + options: const PubSubPublishOptions( + accessModel: 'open', + maxItems: 'max', + ), + ); + + return Result(deviceBundlePublish.isType()); + } + + /// Subscribes to the device list PubSub node of [jid]. + Future subscribeToDeviceList(JID jid) async { + final pm = getAttributes().getManagerById(pubsubManager)!; + final result = await pm.subscribe(jid.toString(), omemoDevicesXmlns); + + if (!result.isType()) { + _subscriptionMap[jid] = true; + } + } + + /// Attempts to find out if [jid] supports omemo:2. + /// + /// On success, returns whether [jid] has published a device list and device bundles. + /// On failure, returns an OmemoError. + Future> supportsOmemo(JID jid) async { + final dm = getAttributes().getManagerById(discoManager)!; + final items = await dm.discoItemsQuery(jid.toBare().toString()); + + if (items.isType()) return Result(UnknownOmemoError()); + + final nodes = items.get>(); + final result = nodes.any((item) => item.node == omemoDevicesXmlns) && nodes.any((item) => item.node == omemoBundlesXmlns); + return Result(result); + } + + /// Attempts to delete a device with device id [deviceId] from the device bundles node + /// and then the device list node. This allows a device that was accidentally removed + /// to republish without any race conditions. + /// Note that this does not delete a possibly existent ratchet session. + /// + /// On success, returns true. On failure, returns an OmemoError. + Future> deleteDevice(int deviceId) async { + final pm = getAttributes().getManagerById(pubsubManager)!; + final jid = getAttributes().getFullJID().toBare(); + + final bundleResult = await pm.retract(jid, omemoBundlesXmlns, '$deviceId'); + if (bundleResult.isType()) { + // TODO(Unknown): Be more specific + return Result(UnknownOmemoError()); + } + + final deviceListResult = await _retrieveDeviceListPayload(jid); + if (deviceListResult.isType()) { + return Result(bundleResult.get()); + } + + final payload = deviceListResult.get(); + final newPayload = XMLNode.xmlns( + tag: 'devices', + xmlns: omemoDevicesXmlns, + children: payload.children + .where((child) => child.attributes['id'] != '$deviceId') + .toList(), + ); + final publishResult = await pm.publish( + jid.toString(), + omemoDevicesXmlns, + newPayload, + id: 'current', + options: const PubSubPublishOptions( + accessModel: 'open', + ), + ); + + if (publishResult.isType()) return Result(UnknownOmemoError()); + + return const Result(true); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0385.dart b/moxxmpp/lib/src/xeps/xep_0385.dart new file mode 100644 index 0000000..e395189 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0385.dart @@ -0,0 +1,99 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart'; + +class StatelessMediaSharingData { + + const StatelessMediaSharingData({ required this.mediaType, required this.size, required this.description, required this.hashes, required this.url, required this.thumbnails }); + final String mediaType; + final int size; + final String description; + final Map hashes; // algo -> hash value + final List thumbnails; + + final String url; +} + +StatelessMediaSharingData parseSIMSElement(XMLNode node) { + assert(node.attributes['xmlns'] == simsXmlns, 'Invalid element xmlns'); + assert(node.tag == 'media-sharing', 'Invalid element name'); + + final file = node.firstTag('file', xmlns: jingleFileTransferXmlns)!; + final hashes = {}; + for (final i in file.findTags('hash', xmlns: hashXmlns)) { + hashes[i.attributes['algo']! as String] = i.innerText(); + } + + var url = ''; + final references = file.firstTag('sources')!.findTags('reference', xmlns: referenceXmlns); + for (final i in references) { + if (i.attributes['type'] != 'data') continue; + + final uri = i.attributes['uri']! as String; + if (!uri.startsWith('https://')) continue; + + url = uri; + break; + } + + final thumbnails = List.empty(growable: true); + for (final child in file.children) { + // TODO(Unknown): Handle other thumbnails + if (child.tag == 'file-thumbnail' && child.attributes['xmlns'] == fileThumbnailsXmlns) { + final thumb = parseFileThumbnailElement(child); + if (thumb != null) { + thumbnails.add(thumb); + } + } + } + + return StatelessMediaSharingData( + mediaType: file.firstTag('media-type')!.innerText(), + size: int.parse(file.firstTag('size')!.innerText()), + description: file.firstTag('description')!.innerText(), + url: url, + hashes: hashes, + thumbnails: thumbnails, + ); +} + +class SIMSManager extends XmppManagerBase { + @override + String getName() => 'SIMSManager'; + + @override + String getId() => simsManager; + + @override + List getDiscoFeatures() => [ simsXmlns ]; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + callback: _onMessage, + tagName: 'reference', + tagXmlns: referenceXmlns, + // Before the message handler + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza message, StanzaHandlerData state) async { + final references = message.findTags('reference', xmlns: referenceXmlns); + for (final ref in references) { + final sims = ref.firstTag('media-sharing', xmlns: simsXmlns); + if (sims != null) return state.copyWith(sims: parseSIMSElement(sims)); + } + + return state; + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0414.dart b/moxxmpp/lib/src/xeps/xep_0414.dart new file mode 100644 index 0000000..3a41183 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0414.dart @@ -0,0 +1,32 @@ +import 'package:cryptography/cryptography.dart'; + +class InvalidHashAlgorithmException implements Exception { + + InvalidHashAlgorithmException(this.name); + final String name; + + String errMsg() => 'Invalid hash algorithm: $name'; +} + +/// Returns the hash algorithm specified by its name, according to XEP-0414. +HashAlgorithm? getHashByName(String name) { + switch (name) { + case 'sha-1': return Sha1(); + case 'sha-256': return Sha256(); + case 'sha-512': return Sha512(); + // NOTE: cryptography provides an implementation of blake2b, however, + // I have no idea what it's output length is and you cannot set + // one. => New dependency + // TODO(Unknown): Implement + //case "blake2b-256": ; + // hashLengthInBytes == 64 => 512? + case 'blake2b-512': Blake2b(); + // NOTE: cryptography does not provide SHA3 hashes => New dependency + // TODO(Unknown): Implement + //case "sha3-256": ; + // TODO(Unknown): Implement + //case "sha3-512": ; + } + + throw InvalidHashAlgorithmException(name); +} diff --git a/moxxmpp/lib/src/xeps/xep_0446.dart b/moxxmpp/lib/src/xeps/xep_0446.dart new file mode 100644 index 0000000..c2d2b70 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0446.dart @@ -0,0 +1,108 @@ +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/staging/extensible_file_thumbnails.dart'; +import 'package:moxxmpp/src/xeps/xep_0300.dart'; + +class FileMetadataData { + + const FileMetadataData({ + this.mediaType, + this.width, + this.height, + this.desc, + this.length, + this.name, + this.size, + required this.thumbnails, + Map? hashes, + }) : hashes = hashes ?? const {}; + + /// Parse [node] as a FileMetadataData element. + factory FileMetadataData.fromXML(XMLNode node) { + assert(node.attributes['xmlns'] == fileMetadataXmlns, 'Invalid element xmlns'); + assert(node.tag == 'file', 'Invalid element anme'); + + final lengthElement = node.firstTag('length'); + final length = lengthElement != null ? int.parse(lengthElement.innerText()) : null; + final sizeElement = node.firstTag('size'); + final size = sizeElement != null ? int.parse(sizeElement.innerText()) : null; + + final hashes = {}; + for (final e in node.findTags('hash')) { + hashes[e.attributes['algo']! as String] = e.innerText(); + } + + // Thumbnails + final thumbnails = List.empty(growable: true); + for (final i in node.findTags('file-thumbnail')) { + final thumbnail = parseFileThumbnailElement(i); + if (thumbnail != null) { + thumbnails.add(thumbnail); + } + } + + // Length and height + final widthString = node.firstTag('length'); + final heightString = node.firstTag('height'); + int? width; + int? height; + if (widthString != null) { + width = int.parse(widthString.innerText()); + } + if (heightString != null) { + height = int.parse(heightString.innerText()); + } + + return FileMetadataData( + mediaType: node.firstTag('media-type')?.innerText(), + width: width, + height: height, + desc: node.firstTag('desc')?.innerText(), + hashes: hashes, + length: length, + name: node.firstTag('name')?.innerText(), + size: size, + thumbnails: thumbnails, + ); + } + + final String? mediaType; + final int? width; + final int? height; + final List thumbnails; + final String? desc; + final Map hashes; + final int? length; + final String? name; + final int? size; + + XMLNode toXML() { + final node = XMLNode.xmlns( + tag: 'file', + xmlns: fileMetadataXmlns, + children: List.empty(growable: true), + ); + + if (mediaType != null) node.addChild(XMLNode(tag: 'media-type', text: mediaType)); + if (width != null) node.addChild(XMLNode(tag: 'width', text: '$width')); + if (height != null) node.addChild(XMLNode(tag: 'height', text: '$height')); + if (desc != null) node.addChild(XMLNode(tag: 'desc', text: desc)); + if (length != null) node.addChild(XMLNode(tag: 'length', text: length.toString())); + if (name != null) node.addChild(XMLNode(tag: 'name', text: name)); + if (size != null) node.addChild(XMLNode(tag: 'size', text: size.toString())); + + for (final hash in hashes.entries) { + node.addChild( + constructHashElement(hash.key, hash.value), + ); + } + + for (final thumbnail in thumbnails) { + node.addChild( + constructFileThumbnailElement(thumbnail), + ); + } + + return node; + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0447.dart b/moxxmpp/lib/src/xeps/xep_0447.dart new file mode 100644 index 0000000..d5933c9 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0447.dart @@ -0,0 +1,126 @@ +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0446.dart'; +import 'package:moxxmpp/src/xeps/xep_0448.dart'; + +/// The base class for sources for StatelessFileSharing +// ignore: one_member_abstracts +abstract class StatelessFileSharingSource { + /// Turn the source into an XML element. + XMLNode toXml(); +} + +/// Implementation for url-data source elements. +class StatelessFileSharingUrlSource extends StatelessFileSharingSource { + + StatelessFileSharingUrlSource(this.url); + + factory StatelessFileSharingUrlSource.fromXml(XMLNode element) { + assert(element.attributes['xmlns'] == urlDataXmlns, 'Element has the wrong xmlns'); + + return StatelessFileSharingUrlSource(element.attributes['target']! as String); + } + + final String url; + + @override + XMLNode toXml() { + return XMLNode.xmlns( + tag: 'url-data', + xmlns: urlDataXmlns, + attributes: { + 'target': url, + }, + ); + } +} + +class StatelessFileSharingData { + + const StatelessFileSharingData(this.metadata, this.sources); + + /// Parse [node] as a StatelessFileSharingData element. + factory StatelessFileSharingData.fromXML(XMLNode node) { + assert(node.attributes['xmlns'] == sfsXmlns, 'Invalid element xmlns'); + assert(node.tag == 'file-sharing', 'Invalid element name'); + + final sources = List.empty(growable: true); + + final sourcesElement = node.firstTag('sources')!; + for (final source in sourcesElement.children) { + if (source.attributes['xmlns'] == urlDataXmlns) { + sources.add(StatelessFileSharingUrlSource.fromXml(source)); + } else if (source.attributes['xmlns'] == sfsEncryptionXmlns) { + sources.add(StatelessFileSharingEncryptedSource.fromXml(source)); + } + } + + return StatelessFileSharingData( + FileMetadataData.fromXML(node.firstTag('file')!), + sources, + ); + } + + final FileMetadataData metadata; + final List sources; + + XMLNode toXML() { + return XMLNode.xmlns( + tag: 'file-sharing', + xmlns: sfsXmlns, + children: [ + metadata.toXML(), + XMLNode( + tag: 'sources', + children: sources + .map((source) => source.toXml()) + .toList(), + ), + ], + ); + } + + StatelessFileSharingUrlSource? getFirstUrlSource() { + return firstWhereOrNull( + sources, + (StatelessFileSharingSource source) => source is StatelessFileSharingUrlSource, + ) as StatelessFileSharingUrlSource?; + } +} + +class SFSManager extends XmppManagerBase { + @override + String getName() => 'SFSManager'; + + @override + String getId() => sfsManager; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'file-sharing', + tagXmlns: sfsXmlns, + callback: _onMessage, + // Before the message handler + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza message, StanzaHandlerData state) async { + final sfs = message.firstTag('file-sharing', xmlns: sfsXmlns)!; + + return state.copyWith( + sfs: StatelessFileSharingData.fromXML(sfs), + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0448.dart b/moxxmpp/lib/src/xeps/xep_0448.dart new file mode 100644 index 0000000..97dac00 --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0448.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'package:moxlib/moxlib.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stringxml.dart'; +import 'package:moxxmpp/src/xeps/xep_0300.dart'; +import 'package:moxxmpp/src/xeps/xep_0447.dart'; + +enum SFSEncryptionType { + aes128GcmNoPadding, + aes256GcmNoPadding, + aes256CbcPkcs7, +} + +extension SFSEncryptionTypeNamespaceExtension on SFSEncryptionType { + String toNamespace() { + switch (this) { + case SFSEncryptionType.aes128GcmNoPadding: + return sfsEncryptionAes128GcmNoPaddingXmlns; + case SFSEncryptionType.aes256GcmNoPadding: + return sfsEncryptionAes256GcmNoPaddingXmlns; + case SFSEncryptionType.aes256CbcPkcs7: + return sfsEncryptionAes256CbcPkcs7Xmlns; + } + } +} + +SFSEncryptionType encryptionTypeFromNamespace(String xmlns) { + switch (xmlns) { + case sfsEncryptionAes128GcmNoPaddingXmlns: + return SFSEncryptionType.aes128GcmNoPadding; + case sfsEncryptionAes256GcmNoPaddingXmlns: + return SFSEncryptionType.aes256GcmNoPadding; + case sfsEncryptionAes256CbcPkcs7Xmlns: + return SFSEncryptionType.aes256CbcPkcs7; + } + + throw Exception(); +} + +class StatelessFileSharingEncryptedSource extends StatelessFileSharingSource { + + StatelessFileSharingEncryptedSource(this.encryption, this.key, this.iv, this.hashes, this.source); + factory StatelessFileSharingEncryptedSource.fromXml(XMLNode element) { + assert(element.attributes['xmlns'] == sfsEncryptionXmlns, 'Element has invalid xmlns'); + + final key = base64Decode(element.firstTag('key')!.text!); + final iv = base64Decode(element.firstTag('iv')!.text!); + final sources = element.firstTag('sources', xmlns: sfsXmlns)!.children; + + // Find the first URL source + final source = firstWhereOrNull( + sources, + (XMLNode child) => child.tag == 'url-data' && child.attributes['xmlns'] == urlDataXmlns, + )!; + + // Find hashes + final hashes = {}; + for (final hash in element.findTags('hash', xmlns: hashXmlns)) { + hashes[hash.attributes['algo']! as String] = hash.text!; + } + + return StatelessFileSharingEncryptedSource( + encryptionTypeFromNamespace(element.attributes['cipher']! as String), + key, + iv, + hashes, + StatelessFileSharingUrlSource.fromXml(source), + ); + } + + final List key; + final List iv; + final SFSEncryptionType encryption; + final Map hashes; + final StatelessFileSharingUrlSource source; + + @override + XMLNode toXml() { + return XMLNode.xmlns( + tag: 'encrypted', + xmlns: sfsEncryptionXmlns, + attributes: { + 'cipher': encryption.toNamespace(), + }, + children: [ + XMLNode( + tag: 'key', + text: base64Encode(key), + ), + XMLNode( + tag: 'iv', + text: base64Encode(iv), + ), + ...hashes.entries.map((hash) => constructHashElement(hash.key, hash.value)), + XMLNode.xmlns( + tag: 'sources', + xmlns: sfsXmlns, + children: [source.toXml()], + ), + ], + ); + } +} diff --git a/moxxmpp/lib/src/xeps/xep_0461.dart b/moxxmpp/lib/src/xeps/xep_0461.dart new file mode 100644 index 0000000..39abf5b --- /dev/null +++ b/moxxmpp/lib/src/xeps/xep_0461.dart @@ -0,0 +1,66 @@ +import 'package:moxxmpp/src/managers/base.dart'; +import 'package:moxxmpp/src/managers/data.dart'; +import 'package:moxxmpp/src/managers/handlers.dart'; +import 'package:moxxmpp/src/managers/namespaces.dart'; +import 'package:moxxmpp/src/namespaces.dart'; +import 'package:moxxmpp/src/stanza.dart'; + +class ReplyData { + + const ReplyData({ + required this.to, + required this.id, + this.start, + this.end, + }); + final String to; + final String id; + final int? start; + final int? end; +} + +class MessageRepliesManager extends XmppManagerBase { + @override + String getName() => 'MessageRepliesManager'; + + @override + String getId() => messageRepliesManager; + + @override + List getIncomingStanzaHandlers() => [ + StanzaHandler( + stanzaTag: 'message', + tagName: 'reply', + tagXmlns: replyXmlns, + callback: _onMessage, + // Before the message handler + priority: -99, + ) + ]; + + @override + Future isSupported() async => true; + + Future _onMessage(Stanza stanza, StanzaHandlerData state) async { + final reply = stanza.firstTag('reply', xmlns: replyXmlns)!; + final id = reply.attributes['id']! as String; + final to = reply.attributes['to']! as String; + int? start; + int? end; + + // TODO(Unknown): Maybe extend firstTag to also look for attributes + final fallback = stanza.firstTag('fallback', xmlns: fallbackXmlns); + if (fallback != null) { + final body = fallback.firstTag('body')!; + start = int.parse(body.attributes['start']! as String); + end = int.parse(body.attributes['end']! as String); + } + + return state.copyWith(reply: ReplyData( + id: id, + to: to, + start: start, + end: end, + ),); + } +} diff --git a/moxxmpp/pubspec.yaml b/moxxmpp/pubspec.yaml new file mode 100644 index 0000000..f285050 --- /dev/null +++ b/moxxmpp/pubspec.yaml @@ -0,0 +1,36 @@ +name: moxxmpp +description: A pure-Dart XMPP library +version: 0.1.0 +homepage: https://codeberg.org/moxxy/moxxmpp + +environment: + sdk: '>=2.18.0 <3.0.0' + +dependencies: + cryptography: 2.0.5 + hex: 0.2.0 + logging: 1.0.2 + moxlib: + hosted: https://git.polynom.me/api/packages/Moxxy/pub + version: 0.1.5 + omemo_dart: + hosted: https://git.polynom.me/api/packages/PapaTutuWawa/pub + version: 0.3.1 + random_string: 2.3.1 + saslprep: 1.0.2 + uuid: 3.0.5 + xml: ^6.1.0 + +dev_dependencies: + build_runner: ^2.1.11 + freezed: ^2.1.0+1 + json_serializable: ^6.3.1 + meta: ^1.7.0 + test: ^1.16.0 + very_good_analysis: ^3.0.1 + +dependency_overrides: + omemo_dart: + git: + url: https://codeberg.org/PapaTutuWawa/omemo_dart.git + rev: c68471349ab1b347ec9ad54651265710842c50b7 diff --git a/moxxmpp/test/moxxmpp_test.dart b/moxxmpp/test/moxxmpp_test.dart new file mode 100644 index 0000000..6ad5c46 --- /dev/null +++ b/moxxmpp/test/moxxmpp_test.dart @@ -0,0 +1,16 @@ +import 'package:moxxmpp/moxxmpp.dart'; +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + final awesome = Awesome(); + + setUp(() { + // Additional setup goes here. + }); + + test('First Test', () { + expect(awesome.isAwesome, isTrue); + }); + }); +}