1 /*
2  * Copyright (C) by Felix Weilbach <felix.weilbach@nextcloud.com>
3  *
4  * This program is free software; you can redistribute it and/or modify
5  * it under the terms of the GNU General Public License as published by
6  * the Free Software Foundation; either version 2 of the License, or
7  * (at your option) any later version.
8  *
9  * This program is distributed in the hope that it will be useful, but
10  * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
11  * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
12  * for more details.
13  */
14 
15 #include "pushnotifications.h"
16 #include "creds/abstractcredentials.h"
17 #include "account.h"
18 
19 namespace {
20 static constexpr int MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS = 3;
21 static constexpr int PING_INTERVAL = 30 * 1000;
22 }
23 
24 namespace OCC {
25 
26 Q_LOGGING_CATEGORY(lcPushNotifications, "nextcloud.sync.pushnotifications", QtInfoMsg)
27 
PushNotifications(Account * account,QObject * parent)28 PushNotifications::PushNotifications(Account *account, QObject *parent)
29     : QObject(parent)
30     , _account(account)
31     , _webSocket(new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this))
32 {
33     connect(_webSocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), this, &PushNotifications::onWebSocketError);
34     connect(_webSocket, &QWebSocket::sslErrors, this, &PushNotifications::onWebSocketSslErrors);
35     connect(_webSocket, &QWebSocket::connected, this, &PushNotifications::onWebSocketConnected);
36     connect(_webSocket, &QWebSocket::disconnected, this, &PushNotifications::onWebSocketDisconnected);
37     connect(_webSocket, &QWebSocket::pong, this, &PushNotifications::onWebSocketPongReceived);
38 
39     connect(&_pingTimer, &QTimer::timeout, this, &PushNotifications::pingWebSocketServer);
40     _pingTimer.setSingleShot(true);
41     _pingTimer.setInterval(PING_INTERVAL);
42 
43     connect(&_pingTimedOutTimer, &QTimer::timeout, this, &PushNotifications::onPingTimedOut);
44     _pingTimedOutTimer.setSingleShot(true);
45     _pingTimedOutTimer.setInterval(PING_INTERVAL);
46 }
47 
~PushNotifications()48 PushNotifications::~PushNotifications()
49 {
50     closeWebSocket();
51 }
52 
setup()53 void PushNotifications::setup()
54 {
55     qCInfo(lcPushNotifications) << "Setup push notifications";
56     _failedAuthenticationAttemptsCount = 0;
57     reconnectToWebSocket();
58 }
59 
reconnectToWebSocket()60 void PushNotifications::reconnectToWebSocket()
61 {
62     closeWebSocket();
63     openWebSocket();
64 }
65 
closeWebSocket()66 void PushNotifications::closeWebSocket()
67 {
68     qCInfo(lcPushNotifications) << "Close websocket for account" << _account->url();
69 
70     _pingTimer.stop();
71     _pingTimedOutTimer.stop();
72     _isReady = false;
73 
74     // Maybe there run some reconnection attempts
75     if (_reconnectTimer) {
76         _reconnectTimer->stop();
77     }
78 
79     disconnect(_webSocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), this, &PushNotifications::onWebSocketError);
80     disconnect(_webSocket, &QWebSocket::sslErrors, this, &PushNotifications::onWebSocketSslErrors);
81 
82     _webSocket->close();
83 }
84 
onWebSocketConnected()85 void PushNotifications::onWebSocketConnected()
86 {
87     qCInfo(lcPushNotifications) << "Connected to websocket for account" << _account->url();
88 
89     connect(_webSocket, &QWebSocket::textMessageReceived, this, &PushNotifications::onWebSocketTextMessageReceived, Qt::UniqueConnection);
90 
91     authenticateOnWebSocket();
92 }
93 
authenticateOnWebSocket()94 void PushNotifications::authenticateOnWebSocket()
95 {
96     const auto credentials = _account->credentials();
97     const auto username = credentials->user();
98     const auto password = credentials->password();
99 
100     // Authenticate
101     _webSocket->sendTextMessage(username);
102     _webSocket->sendTextMessage(password);
103 }
104 
onWebSocketDisconnected()105 void PushNotifications::onWebSocketDisconnected()
106 {
107     qCInfo(lcPushNotifications) << "Disconnected from websocket for account" << _account->url();
108 }
109 
onWebSocketTextMessageReceived(const QString & message)110 void PushNotifications::onWebSocketTextMessageReceived(const QString &message)
111 {
112     qCInfo(lcPushNotifications) << "Received push notification:" << message;
113 
114     if (message == "notify_file") {
115         handleNotifyFile();
116     } else if (message == "notify_activity") {
117         handleNotifyActivity();
118     } else if (message == "notify_notification") {
119         handleNotifyNotification();
120     } else if (message == "authenticated") {
121         handleAuthenticated();
122     } else if (message == "err: Invalid credentials") {
123         handleInvalidCredentials();
124     }
125 }
126 
onWebSocketError(QAbstractSocket::SocketError error)127 void PushNotifications::onWebSocketError(QAbstractSocket::SocketError error)
128 {
129     // This error gets thrown in testSetup_maxConnectionAttemptsReached_deletePushNotifications after
130     // the second connection attempt. I have no idea why this happens. Maybe the socket gets not closed correctly?
131     // I think it's fine to ignore this error.
132     if (error == QAbstractSocket::UnfinishedSocketOperationError) {
133         return;
134     }
135 
136     qCWarning(lcPushNotifications) << "Websocket error on with account" << _account->url() << error;
137     closeWebSocket();
138     emit connectionLost();
139 }
140 
tryReconnectToWebSocket()141 bool PushNotifications::tryReconnectToWebSocket()
142 {
143     ++_failedAuthenticationAttemptsCount;
144     if (_failedAuthenticationAttemptsCount >= MAX_ALLOWED_FAILED_AUTHENTICATION_ATTEMPTS) {
145         qCInfo(lcPushNotifications) << "Max authentication attempts reached";
146         return false;
147     }
148 
149     if (!_reconnectTimer) {
150         _reconnectTimer = new QTimer(this);
151     }
152 
153     _reconnectTimer->setInterval(_reconnectTimerInterval);
154     _reconnectTimer->setSingleShot(true);
155     connect(_reconnectTimer, &QTimer::timeout, [this]() {
156         reconnectToWebSocket();
157     });
158     _reconnectTimer->start();
159 
160     return true;
161 }
162 
onWebSocketSslErrors(const QList<QSslError> & errors)163 void PushNotifications::onWebSocketSslErrors(const QList<QSslError> &errors)
164 {
165     qCWarning(lcPushNotifications) << "Websocket ssl errors on with account" << _account->url() << errors;
166     closeWebSocket();
167     emit authenticationFailed();
168 }
169 
openWebSocket()170 void PushNotifications::openWebSocket()
171 {
172     // Open websocket
173     const auto capabilities = _account->capabilities();
174     const auto webSocketUrl = capabilities.pushNotificationsWebSocketUrl();
175 
176     qCInfo(lcPushNotifications) << "Open connection to websocket on" << webSocketUrl << "for account" << _account->url();
177     connect(_webSocket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), this, &PushNotifications::onWebSocketError);
178     connect(_webSocket, &QWebSocket::sslErrors, this, &PushNotifications::onWebSocketSslErrors);
179     _webSocket->open(webSocketUrl);
180 }
181 
setReconnectTimerInterval(uint32_t interval)182 void PushNotifications::setReconnectTimerInterval(uint32_t interval)
183 {
184     _reconnectTimerInterval = interval;
185 }
186 
isReady() const187 bool PushNotifications::isReady() const
188 {
189     return _isReady;
190 }
191 
handleAuthenticated()192 void PushNotifications::handleAuthenticated()
193 {
194     qCInfo(lcPushNotifications) << "Authenticated successful on websocket";
195     _failedAuthenticationAttemptsCount = 0;
196     _isReady = true;
197     startPingTimer();
198     emit ready();
199 
200     // We maybe reconnected to websocket while being offline for a
201     // while. To not miss any notifications that may have happend,
202     // emit all the signals once.
203     emitFilesChanged();
204     emitNotificationsChanged();
205     emitActivitiesChanged();
206 }
207 
handleNotifyFile()208 void PushNotifications::handleNotifyFile()
209 {
210     qCInfo(lcPushNotifications) << "Files push notification arrived";
211     emitFilesChanged();
212 }
213 
handleInvalidCredentials()214 void PushNotifications::handleInvalidCredentials()
215 {
216     qCInfo(lcPushNotifications) << "Invalid credentials submitted to websocket";
217     if (!tryReconnectToWebSocket()) {
218         closeWebSocket();
219         emit authenticationFailed();
220     }
221 }
222 
handleNotifyNotification()223 void PushNotifications::handleNotifyNotification()
224 {
225     qCInfo(lcPushNotifications) << "Push notification arrived";
226     emitNotificationsChanged();
227 }
228 
handleNotifyActivity()229 void PushNotifications::handleNotifyActivity()
230 {
231     qCInfo(lcPushNotifications) << "Push activity arrived";
232     emitActivitiesChanged();
233 }
234 
onWebSocketPongReceived(quint64,const QByteArray &)235 void PushNotifications::onWebSocketPongReceived(quint64 /*elapsedTime*/, const QByteArray & /*payload*/)
236 {
237     qCDebug(lcPushNotifications) << "Pong received in time";
238     // We are fine with every kind of pong and don't care about the
239     // payload. As long as we receive pongs the server is still alive.
240     _pongReceivedFromWebSocketServer = true;
241     startPingTimer();
242 }
243 
startPingTimer()244 void PushNotifications::startPingTimer()
245 {
246     _pingTimedOutTimer.stop();
247     _pingTimer.start();
248 }
249 
startPingTimedOutTimer()250 void PushNotifications::startPingTimedOutTimer()
251 {
252     _pingTimedOutTimer.start();
253 }
254 
pingWebSocketServer()255 void PushNotifications::pingWebSocketServer()
256 {
257     qCDebug(lcPushNotifications, "Ping websocket server");
258 
259     _pongReceivedFromWebSocketServer = false;
260 
261     _webSocket->ping({});
262     startPingTimedOutTimer();
263 }
264 
onPingTimedOut()265 void PushNotifications::onPingTimedOut()
266 {
267     if (_pongReceivedFromWebSocketServer) {
268         qCDebug(lcPushNotifications) << "Websocket respond with a pong in time.";
269         return;
270     }
271 
272     qCInfo(lcPushNotifications) << "Websocket did not respond with a pong in time. Try to reconnect.";
273     // Try again to connect
274     setup();
275 }
276 
setPingInterval(int timeoutInterval)277 void PushNotifications::setPingInterval(int timeoutInterval)
278 {
279     _pingTimer.setInterval(timeoutInterval);
280     _pingTimedOutTimer.setInterval(timeoutInterval);
281 }
282 
emitFilesChanged()283 void PushNotifications::emitFilesChanged()
284 {
285     emit filesChanged(_account);
286 }
287 
emitNotificationsChanged()288 void PushNotifications::emitNotificationsChanged()
289 {
290     emit notificationsChanged(_account);
291 }
292 
emitActivitiesChanged()293 void PushNotifications::emitActivitiesChanged()
294 {
295     emit activitiesChanged(_account);
296 }
297 }
298