MythTV master
upnpsubscription.cpp
Go to the documentation of this file.
1/*
2An HttpServer Extension that manages subscriptions to UPnP services.
3
4An object wishing to subscribe to a service needs to register as a listener for
5events and subscribe using a valid usn and subscription path. The subscriber
6is responsible for requesting a renewal before the subscription expires,
7removing any stale subscriptions, unsubsubscribing on exit and must re-implement
8QObject::customEvent to receive event notifications for subscribed services.
9*/
10
11#include "upnpsubscription.h"
12
13#if QT_VERSION >= QT_VERSION_CHECK(6,0,0)
14#include <QStringConverter>
15#else
16#include <QTextCodec>
17#endif
18#include <algorithm>
19#include <utility>
20
21#include <QHostAddress>
22#include <QString>
23#include <QUrl>
24#include <QDomDocument>
25
28
29#include "blockingtcpsocket.h"
30#include "upnp.h"
31
32// default requested time for subscription (actual is dictated by server)
33static constexpr uint16_t SUBSCRIPTION_TIME { 1800 };
34// maximum time to wait for responses to subscription requests (UPnP spec. 30s)
35static constexpr std::chrono::milliseconds MAX_WAIT { 30s };
36
37#define LOC QString("UPnPSub: ")
38
40{
41 public:
42 Subscription(QUrl url, QString path)
43 : m_url(std::move(url)), m_path(std::move(path)) { }
44 QUrl m_url;
45 QString m_path;
46 QString m_uuid;
47};
48
49UPNPSubscription::UPNPSubscription(const QString &share_path, int port)
50 : HttpServerExtension("UPnPSubscriptionManager", share_path)
51{
52 m_nSupportedMethods = (uint)RequestTypeNotify; // Only NOTIFY supported
53
54 auto it = std::ranges::find_if(std::as_const(UPnp::g_IPAddrList),
55 [](const QHostAddress& tmp) {return !tmp.isLoopback(); });
56 if (it == UPnp::g_IPAddrList.cend())
57 return;
58 const QHostAddress& addr = *it;
59
60 QString host;
61 if (addr.protocol() == QAbstractSocket::IPv6Protocol)
62 host = "[" + addr.toString() + "]";
63 else
64 host = addr.toString();
65
66 m_callback = QString("http://%1:%2/Subscriptions/event?usn=")
67 .arg(host, QString::number(port));
68}
69
71{
72 m_subscriptionLock.lock();
73 QList<QString> usns = m_subscriptions.keys();
74 while (!usns.isEmpty())
75 Unsubscribe(usns.takeLast());
76 m_subscriptions.clear();
77 m_subscriptionLock.unlock();
78
79 LOG(VB_UPNP, LOG_DEBUG, LOC + "Finished");
80}
81
82std::chrono::seconds UPNPSubscription::Subscribe(const QString &usn, const QUrl &url,
83 const QString &path)
84{
85 LOG(VB_UPNP, LOG_DEBUG, LOC + QString("Subscribe %1 %2 %3")
86 .arg(usn, url.toString(), path));
87
88 // N.B. this is called from the client object's thread. Hence we have to
89 // lock until the subscription request has returned, otherwise we may
90 // receive the first event notification (in the HttpServer thread)
91 // before the subscription is processed and the event will fail
92
93 QMutexLocker lock(&m_subscriptionLock);
94 if (m_subscriptions.contains(usn))
95 {
96 if (m_subscriptions[usn]->m_url != url ||
97 m_subscriptions[usn]->m_path != path)
98 {
99 LOG(VB_GENERAL, LOG_WARNING, LOC +
100 "Re-subscribing with different url and path.");
101 m_subscriptions[usn]->m_url = url;
102 m_subscriptions[usn]->m_path = path;
103 m_subscriptions[usn]->m_uuid = QString();
104 }
105 }
106 else
107 {
108 m_subscriptions.insert(usn, new Subscription(url, path));
109 }
110
111 return SendSubscribeRequest(m_callback, usn, url, path, QString(),
112 m_subscriptions[usn]->m_uuid);
113}
114
115void UPNPSubscription::Unsubscribe(const QString &usn)
116{
117 QUrl url;
118 QString path;
119 QString uuid = QString();
120 m_subscriptionLock.lock();
121 if (m_subscriptions.contains(usn))
122 {
123 url = m_subscriptions[usn]->m_url;
124 path = m_subscriptions[usn]->m_path;
125 uuid = m_subscriptions[usn]->m_uuid;
126 delete m_subscriptions.value(usn);
127 m_subscriptions.remove(usn);
128 }
129 m_subscriptionLock.unlock();
130
131 if (!uuid.isEmpty())
132 SendUnsubscribeRequest(usn, url, path, uuid);
133}
134
135std::chrono::seconds UPNPSubscription::Renew(const QString &usn)
136{
137 LOG(VB_UPNP, LOG_DEBUG, LOC + QString("Renew: %1").arg(usn));
138
139 QUrl url;
140 QString path;
141 QString sid;
142
143 // see locking comment in Subscribe
144 QMutexLocker lock(&m_subscriptionLock);
145 if (m_subscriptions.contains(usn))
146 {
147 url = m_subscriptions[usn]->m_url;
148 path = m_subscriptions[usn]->m_path;
149 sid = m_subscriptions[usn]->m_uuid;
150 }
151 else
152 {
153 LOG(VB_UPNP, LOG_ERR, LOC + QString("Unrecognised renewal usn: %1")
154 .arg(usn));
155 return 0s;
156 }
157
158 if (!sid.isEmpty())
159 {
160 return SendSubscribeRequest(m_callback, usn, url, path, sid,
161 m_subscriptions[usn]->m_uuid);
162 }
163
164 LOG(VB_UPNP, LOG_ERR, LOC + QString("No uuid - not renewing usn: %1")
165 .arg(usn));
166 return 0s;
167}
168
169void UPNPSubscription::Remove(const QString &usn)
170{
171 // this could be handled by hooking directly into the SSDPCache updates
172 // but the subscribing object will also be doing so. Having the that
173 // object initiate the removal avoids temoporary race conditions during
174 // periods of UPnP/SSDP activity
175 m_subscriptionLock.lock();
176 if (m_subscriptions.contains(usn))
177 {
178 LOG(VB_UPNP, LOG_INFO, LOC + QString("Removing %1").arg(usn));
179 delete m_subscriptions.value(usn);
180 m_subscriptions.remove(usn);
181 }
182 m_subscriptionLock.unlock();
183}
184
186{
187 if (!pRequest)
188 return false;
189
190 if (pRequest->m_sBaseUrl != "/Subscriptions")
191 return false;
192 if (pRequest->m_sMethod != "event")
193 return false;
194
195 LOG(VB_UPNP, LOG_DEBUG, LOC + QString("%1\n%2")
196 .arg(pRequest->m_sRawRequest, pRequest->m_sPayload));
197
198 if (pRequest->m_sPayload.isEmpty())
199 return true;
200
202
203 QString nt = pRequest->GetLastHeader("nt");
204 QString nts = pRequest->GetLastHeader("nts");
205 bool no = (pRequest->m_eType == RequestTypeNotify);
206
207 if (nt.isEmpty() || nts.isEmpty() || !no)
208 {
209 pRequest->m_nResponseStatus = 400;
210 return true;
211 }
212
213 pRequest->m_nResponseStatus = 412;
214 if (nt != "upnp:event" || nts != "upnp:propchange")
215 return true;
216
217 QString usn = pRequest->m_mapParams["usn"];
218 QString sid = pRequest->GetLastHeader("sid");
219 if (usn.isEmpty() || sid.isEmpty())
220 return true;
221
222 // N.B. Validating the usn and uuid here might mean blocking for some time
223 // while waiting for a subscription to complete. While this operates in a
224 // worker thread, worker threads are a limited resource which we could
225 // rapidly overload if a number of events arrive. Instead let the
226 // subscribing objects validate the usn - the uuid should be superfluous.
227
228 QString seq = pRequest->GetLastHeader("seq");
229
230 // mediatomb sends some extra character(s) at the end of the payload
231 // which throw Qt, so try and trim them off
232 int loc = pRequest->m_sPayload.lastIndexOf("propertyset>");
233 QString payload = (loc > -1) ? pRequest->m_sPayload.left(loc + 12) :
234 pRequest->m_sPayload;
235
236 LOG(VB_UPNP, LOG_DEBUG, LOC + QString("Payload:\n%1").arg(payload));
237
238 pRequest->m_nResponseStatus = 400;
239 QDomDocument body;
240#if QT_VERSION < QT_VERSION_CHECK(6,5,0)
241 QString error;
242 int errorCol = 0;
243 int errorLine = 0;
244 if (!body.setContent(payload, true, &error, &errorLine, &errorCol))
245 {
246 LOG(VB_GENERAL, LOG_ERR, LOC +
247 QString("Failed to parse event: Line: %1 Col: %2 Error: '%3'")
248 .arg(errorLine).arg(errorCol).arg(error));
249 return true;
250 }
251#else
252 auto parseResult =
253 body.setContent(payload,
254 QDomDocument::ParseOption::UseNamespaceProcessing);
255 if (!parseResult)
256 {
257 LOG(VB_GENERAL, LOG_ERR, LOC +
258 QString("Failed to parse event: Line: %1 Col: %2 Error: '%3'")
259 .arg(parseResult.errorLine).arg(parseResult.errorColumn)
260 .arg(parseResult.errorMessage));
261 return true;
262 }
263#endif
264
265 LOG(VB_UPNP, LOG_DEBUG, LOC + "/n/n" + body.toString(4) + "/n/n");
266
267 QDomNodeList properties = body.elementsByTagName("property");
269
270 // this deals with both one argument per property (compliant) and mutliple
271 // arguments per property as sent by mediatomb
272 for (int i = 0; i < properties.size(); i++)
273 {
274 QDomNodeList arguments = properties.at(i).childNodes();
275 for (int j = 0; j < arguments.size(); j++)
276 {
277 QDomElement e = arguments.at(j).toElement();
278 if (!e.isNull() && !e.text().isEmpty() && !e.tagName().isEmpty())
279 results.insert(e.tagName(), e.text());
280 }
281 }
282
283 // using MythObservable allows multiple objects to subscribe to the same
284 // service but is less efficient from an eventing perspective, especially
285 // if multiple objects are subscribing
286 if (!results.isEmpty())
287 {
288 pRequest->m_nResponseStatus = 200;
289 results.insert("usn", usn);
290 results.insert("seq", seq);
291 MythInfoMapEvent me("UPNP_EVENT", results);
292 dispatch(me);
293 }
294
295 return true;
296}
297
299 const QUrl &url,
300 const QString &path,
301 const QString &uuid)
302{
303 bool success = false;
304 QString host = url.host();
305 int port = url.port();
306
307 QByteArray sub;
308 QTextStream data(&sub);
309#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
310 data.setCodec(QTextCodec::codecForName("UTF-8"));
311#else
312 data.setEncoding(QStringConverter::Utf8);
313#endif
314 // N.B. Play On needs an extra space between UNSUBSCRIBE and path...
315 data << QString("UNSUBSCRIBE %1 HTTP/1.1\r\n").arg(path);
316 data << QString("HOST: %1:%2\r\n").arg(host, QString::number(port));
317 data << QString("SID: uuid:%1\r\n").arg(uuid);
318 data << "\r\n";
319 data.flush();
320
321 LOG(VB_UPNP, LOG_DEBUG, LOC + "\n\n" + sub);
322
323 BlockingTcpSocket socket;
324 if (socket.connect(QHostAddress(host), port, MAX_WAIT))
325 {
326 if (socket.write(sub.constData(), sub.size(), MAX_WAIT) != -1)
327 {
328 QString line = socket.readLine(MAX_WAIT);
329 success = !line.isEmpty();
330 }
331 }
332
333 if (success)
334 LOG(VB_GENERAL, LOG_INFO, LOC + QString("Unsubscribed to %1").arg(usn));
335 else
336 LOG(VB_UPNP, LOG_WARNING, LOC + QString("Failed to unsubscribe to %1")
337 .arg(usn));
338 return success;
339}
340
341std::chrono::seconds UPNPSubscription::SendSubscribeRequest(const QString &callback,
342 const QString &usn,
343 const QUrl &url,
344 const QString &path,
345 const QString &uuidin,
346 QString &uuidout)
347{
348 QString host = url.host();
349 int port = url.port();
350
351 QByteArray sub;
352 QTextStream data(&sub);
353#if QT_VERSION < QT_VERSION_CHECK(6,0,0)
354 data.setCodec(QTextCodec::codecForName("UTF-8"));
355#else
356 data.setEncoding(QStringConverter::Utf8);
357#endif
358 // N.B. Play On needs an extra space between SUBSCRIBE and path...
359 data << QString("SUBSCRIBE %1 HTTP/1.1\r\n").arg(path);
360 data << QString("HOST: %1:%2\r\n").arg(host, QString::number(port));
361
362
363 if (uuidin.isEmpty()) // new subscription
364 {
365 data << QString("CALLBACK: <%1%2>\r\n")
366 .arg(callback, usn);
367 data << "NT: upnp:event\r\n";
368 }
369 else
370 {
371 // renewal
372 data << QString("SID: uuid:%1\r\n").arg(uuidin);
373 }
374
375 data << QString("TIMEOUT: Second-%1\r\n").arg(SUBSCRIPTION_TIME);
376 data << "\r\n";
377 data.flush();
378
379 LOG(VB_UPNP, LOG_DEBUG, LOC + "\n\n" + sub);
380
381 QString error {"unknown"};
382 QString uuid;
383 QString timeout;
384 std::chrono::seconds result = 0s;
385
386 BlockingTcpSocket socket;
387 if (socket.connect(QHostAddress(host), port, MAX_WAIT))
388 {
389 if (socket.write(sub.constData(), sub.size(), MAX_WAIT) != -1)
390 {
391 bool ok = false;
392 QString line = socket.readLine(MAX_WAIT);
393 while (!line.isEmpty())
394 {
395 LOG(VB_UPNP, LOG_DEBUG, LOC + line.trimmed());
396 if (line.contains("HTTP/1.1 200 OK", Qt::CaseInsensitive))
397 ok = true;
398 else if (line.contains("HTTP/1.1", Qt::CaseInsensitive))
399 error = line.mid(8).trimmed();
400 if (line.startsWith("SID:", Qt::CaseInsensitive))
401 uuid = line.mid(4).trimmed().mid(5).trimmed();
402 if (line.startsWith("TIMEOUT:", Qt::CaseInsensitive))
403 timeout = line.mid(8).trimmed().mid(7).trimmed();
404 if (ok && !uuid.isEmpty() && !timeout.isEmpty())
405 break;
406 line = socket.readLine(MAX_WAIT);
407 }
408
409 if (ok && !uuid.isEmpty() && !timeout.isEmpty())
410 {
411 uuidout = uuid;
412 result = std::chrono::seconds(timeout.toUInt());
413 }
414 else
415 {
416 LOG(VB_GENERAL, LOG_ERR, LOC +
417 QString("Error '%1' subscribing to %2").arg(error,usn));
418 }
419 }
420 }
421
422 return result;
423}
Wraps a QTcpSocket to provide a blocking connect(), readLine(), and write() with a timeout.
bool connect(const QHostAddress &address, quint16 port, std::chrono::milliseconds timeout)
Connect the socket to a host.
qint64 write(const char *data, qint64 size, std::chrono::milliseconds timeout)
QString readLine(std::chrono::milliseconds timeout)
Read a whole line from the socket.
HttpResponseType m_eResponseType
Definition: httprequest.h:150
long m_nResponseStatus
Definition: httprequest.h:153
QString m_sMethod
Definition: httprequest.h:131
HttpRequestType m_eType
Definition: httprequest.h:122
QString GetLastHeader(const QString &sType) const
QString m_sBaseUrl
Definition: httprequest.h:129
QString m_sRawRequest
Definition: httprequest.h:125
QStringMap m_mapParams
Definition: httprequest.h:133
QString m_sPayload
Definition: httprequest.h:137
void dispatch(const MythEvent &event)
Dispatch an event to all listeners.
Subscription(QUrl url, QString path)
~UPNPSubscription() override
QRecursiveMutex m_subscriptionLock
bool ProcessRequest(HTTPRequest *pRequest) override
UPNPSubscription(const QString &share_path, int port)
void Unsubscribe(const QString &usn)
static std::chrono::seconds SendSubscribeRequest(const QString &callback, const QString &usn, const QUrl &url, const QString &path, const QString &uuidin, QString &uuidout)
void Remove(const QString &usn)
std::chrono::seconds Renew(const QString &usn)
QHash< QString, Subscription * > m_subscriptions
std::chrono::seconds Subscribe(const QString &usn, const QUrl &url, const QString &path)
static bool SendUnsubscribeRequest(const QString &usn, const QUrl &url, const QString &path, const QString &uuid)
static QList< QHostAddress > g_IPAddrList
Definition: upnp.h:49
unsigned int uint
Definition: compat.h:60
@ ResponseTypeHTML
Definition: httprequest.h:81
@ RequestTypeNotify
Definition: httprequest.h:62
unsigned short uint16_t
Definition: iso6937tables.h:3
#define LOG(_MASK_, _LEVEL_, _QSTRING_)
Definition: mythlogging.h:39
QHash< QString, QString > InfoMap
Definition: mythtypes.h:15
def error(message)
Definition: smolt.py:409
#define LOC
static constexpr std::chrono::milliseconds MAX_WAIT
static constexpr uint16_t SUBSCRIPTION_TIME