1 /*
2    Drawpile - a collaborative drawing program.
3 
4    Copyright (C) 2006-2020 Calle Laakkonen
5 
6    Drawpile is free software: you can redistribute it and/or modify
7    it under the terms of the GNU General Public License as published by
8    the Free Software Foundation, either version 3 of the License, or
9    (at your option) any later version.
10 
11    Drawpile is distributed in the hope that it will be useful,
12    but WITHOUT ANY WARRANTY; without even the implied warranty of
13    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14    GNU General Public License for more details.
15 
16    You should have received a copy of the GNU General Public License
17    along with Drawpile.  If not, see <http://www.gnu.org/licenses/>.
18 */
19 
20 #include "joindialog.h"
21 #include "addserverdialog.h"
22 
23 #include "utils/mandatoryfields.h"
24 #include "utils/usernamevalidator.h"
25 #include "utils/listservermodel.h"
26 #include "utils/sessionfilterproxymodel.h"
27 #include "utils/images.h"
28 #include "utils/newversion.h"
29 #include "utils/icon.h"
30 #include "../libshared/listings/announcementapi.h"
31 #include "parentalcontrols/parentalcontrols.h"
32 
33 #ifdef HAVE_DNSSD
34 #include "../libshared/listings/zeroconfdiscovery.h"
35 #endif
36 
37 #include "ui_joindialog.h"
38 
39 #include <QPushButton>
40 #include <QSettings>
41 #include <QTimer>
42 #include <QUrl>
43 #include <QUrlQuery>
44 #include <QDebug>
45 #include <QFileDialog>
46 
47 namespace dialogs {
48 
49 // Height below which the session listing widgets are hidden.
50 static const int COMPACT_MODE_THRESHOLD = 300;
51 
52 // How often the listing view should be refreshed (in seconds)
53 static const int REFRESH_INTERVAL = 60;
54 
JoinDialog(const QUrl & url,QWidget * parent)55 JoinDialog::JoinDialog(const QUrl &url, QWidget *parent)
56 	: QDialog(parent), m_lastRefresh(0)
57 {
58 	m_ui = new Ui_JoinDialog;
59 	m_ui->setupUi(this);
60 	m_ui->buttons->button(QDialogButtonBox::Ok)->setText(tr("Join"));
61 	m_ui->buttons->button(QDialogButtonBox::Ok)->setDefault(true);
62 
63 	m_addServerButton = m_ui->buttons->addButton(tr("Add Server"), QDialogButtonBox::ActionRole);
64 	m_addServerButton->setIcon(icon::fromTheme("list-add"));
65 	connect(m_addServerButton, &QPushButton::clicked, this, &JoinDialog::addListServer);
66 
67 	connect(m_ui->address, &QComboBox::editTextChanged, this, &JoinDialog::addressChanged);
68 	connect(m_ui->autoRecord, &QAbstractButton::clicked, this, &JoinDialog::recordingToggled);
69 
70 	// New version notification (cached)
71 	m_ui->newVersionNotification->setVisible(NewVersionCheck::isThereANewSeries());
72 
73 	// Session listing
74 	if(parentalcontrols::level() != parentalcontrols::Level::Unrestricted)
75 		m_ui->showNsfw->setEnabled(false);
76 
77 	m_sessions = new SessionListingModel(this);
78 
79 #ifdef HAVE_DNSSD
80 	if(ZeroconfDiscovery::isAvailable()) {
81 		auto zeroconfDiscovery = new ZeroconfDiscovery(this);
82 		m_sessions->setMessage(tr("Nearby"), tr("Loading..."));
83 		connect(zeroconfDiscovery, &ZeroconfDiscovery::serverListUpdated, this, [this](const QVector<sessionlisting::Session> &servers) {
84 			m_sessions->setList(tr("Nearby"), servers);
85 		});
86 		zeroconfDiscovery->discover();
87 	}
88 #endif
89 
90 	const auto servers = sessionlisting::ListServerModel::listServers(true);
91 	for(const sessionlisting::ListServer &ls : servers) {
92 		if(ls.publicListings)
93 			m_sessions->setMessage(ls.name, tr("Loading..."));
94 	}
95 	m_ui->noListServersNotification->setVisible(servers.isEmpty());
96 
97 	m_filteredSessions = new SessionFilterProxyModel(this);
98 	m_filteredSessions->setSourceModel(m_sessions);
99 	m_filteredSessions->setFilterCaseSensitivity(Qt::CaseInsensitive);
100 	m_filteredSessions->setSortCaseSensitivity(Qt::CaseInsensitive);
101 	m_filteredSessions->setFilterKeyColumn(-1);
102 	m_filteredSessions->setSortRole(Qt::UserRole);
103 
104 	m_filteredSessions->setShowNsfw(false);
105 	m_filteredSessions->setShowPassworded(false);
106 
107 	connect(m_ui->showPassworded, &QAbstractButton::toggled,
108 			m_filteredSessions, &SessionFilterProxyModel::setShowPassworded);
109 	connect(m_ui->showNsfw, &QAbstractButton::toggled,
110 			m_filteredSessions, &SessionFilterProxyModel::setShowNsfw);
111 	connect(m_ui->showClosed, &QAbstractButton::toggled,
112 			m_filteredSessions, &SessionFilterProxyModel::setShowClosed);
113 	connect(m_ui->filter, &QLineEdit::textChanged,
114 			m_filteredSessions, &SessionFilterProxyModel::setFilterFixedString);
115 
116 	m_ui->listing->setModel(m_filteredSessions);
117 	m_ui->listing->expandAll();
118 
119 	QHeaderView *header = m_ui->listing->header();
120 	header->setSectionResizeMode(0, QHeaderView::Stretch);
121 	header->setSectionResizeMode(1, QHeaderView::ResizeToContents);
122 	header->setSectionResizeMode(2, QHeaderView::ResizeToContents);
123 	header->setSectionResizeMode(3, QHeaderView::ResizeToContents);
124 	header->setSectionResizeMode(4, QHeaderView::ResizeToContents);
125 
126 	connect(m_ui->listing, &QTreeView::clicked, this, [this](const QModelIndex &index) {
127 		// Set the server URL when clicking on an item
128 		if((index.flags() & Qt::ItemIsEnabled))
129 			m_ui->address->setCurrentText(index.data(SessionListingModel::UrlRole).value<QUrl>().toString());
130 	});
131 
132 	connect(m_ui->listing, &QTreeView::doubleClicked, [this](const QModelIndex &index) {
133 		// Shortcut: double click to OK
134 		if((index.flags() & Qt::ItemIsEnabled) && m_ui->buttons->button(QDialogButtonBox::Ok)->isEnabled())
135 			accept();
136 	});
137 
138 	new MandatoryFields(this, m_ui->buttons->button(QDialogButtonBox::Ok));
139 
140 	restoreSettings();
141 
142 	if(!url.isEmpty()) {
143 		m_ui->address->setCurrentText(url.toString());
144 
145 		const QUrlQuery q(url);
146 		QUrl addListServer = q.queryItemValue("list-server");
147 		if(addListServer.isValid() && !addListServer.isEmpty()) {
148 			addListServerUrl(addListServer);
149 		}
150 	}
151 
152 	// Periodically refresh the session listing
153 	auto refreshTimer = new QTimer(this);
154 	connect(refreshTimer, &QTimer::timeout,
155 			this, &JoinDialog::refreshListing);
156 	refreshTimer->setSingleShot(false);
157 	refreshTimer->start(1000 * (REFRESH_INTERVAL + 1));
158 
159 	refreshListing();
160 }
161 
~JoinDialog()162 JoinDialog::~JoinDialog()
163 {
164 	// Always remember these settings
165 	QSettings cfg;
166 	cfg.beginGroup("history");
167 	cfg.setValue("filterlocked", m_ui->showPassworded->isChecked());
168 	cfg.setValue("filternsfw", m_ui->showNsfw->isChecked());
169 	cfg.setValue("filterclosed", m_ui->showClosed->isChecked());
170 
171 	delete m_ui;
172 }
173 
resizeEvent(QResizeEvent * event)174 void JoinDialog::resizeEvent(QResizeEvent *event)
175 {
176 	QDialog::resizeEvent(event);
177 	bool show = false;
178 	bool change = false;
179 	if(height() < COMPACT_MODE_THRESHOLD && !m_ui->filter->isHidden()) {
180 		show = false;
181 		change = true;
182 	} else if(height() > COMPACT_MODE_THRESHOLD && m_ui->filter->isHidden()) {
183 		show = true;
184 		change = true;
185 	}
186 
187 	if(change)
188 		setListingVisible(show);
189 }
190 
setListingVisible(bool show)191 void JoinDialog::setListingVisible(bool show)
192 {
193 	m_ui->filter->setVisible(show);
194 	m_ui->filterLabel->setVisible(show);
195 	m_ui->showPassworded->setVisible(show);
196 	m_ui->showNsfw->setVisible(show);
197 	m_ui->showClosed->setVisible(show);
198 	m_ui->listing->setVisible(show);
199 	m_ui->line->setVisible(show);
200 
201 	if(show)
202 		refreshListing();
203 }
204 
cleanAddress(const QString & addr)205 static QString cleanAddress(const QString &addr)
206 {
207 	if(addr.startsWith("drawpile://")) {
208 		QUrl url(addr);
209 		if(url.isValid()) {
210 			QString a = url.host();
211 			if(url.port()!=-1)
212 				a += ":" + QString::number(url.port());
213 			return a;
214 		}
215 	}
216 	return addr;
217 }
218 
isRoomcode(const QString & str)219 static bool isRoomcode(const QString &str) {
220 	// Roomcodes are always exactly 5 letters long
221 	if(str.length() != 5)
222 		return false;
223 
224 	// And consist of characters in range A-Z
225 	for(int i=0;i<str.length();++i)
226 		if(str.at(i) < 'A' || str.at(i) > 'Z')
227 			return false;
228 
229 	return true;
230 }
231 
addressChanged(const QString & addr)232 void JoinDialog::addressChanged(const QString &addr)
233 {
234 	m_addServerButton->setEnabled(!addr.isEmpty());
235 
236 	if(isRoomcode(addr)) {
237 		// A room code was just entered. Trigger session URL query
238 		m_ui->address->setEditText(QString());
239 		m_ui->address->lineEdit()->setPlaceholderText(tr("Searching..."));
240 		m_ui->address->lineEdit()->setReadOnly(true);
241 
242 		QStringList servers;
243 		for(const auto &s : sessionlisting::ListServerModel::listServers(true)) {
244 			if(s.privateListings)
245 				servers << s.url;
246 		}
247 		resolveRoomcode(addr, servers);
248 	}
249 }
250 
recordingToggled(bool checked)251 void JoinDialog::recordingToggled(bool checked)
252 {
253 	if(checked) {
254 		m_recordingFilename = QFileDialog::getSaveFileName(
255 			this,
256 			tr("Record"),
257 			m_recordingFilename,
258 			utils::fileFormatFilter(utils::FileFormatOption::SaveRecordings)
259 		);
260 		if(m_recordingFilename.isEmpty())
261 			m_ui->autoRecord->setChecked(false);
262 	}
263 }
264 
autoRecordFilename() const265 QString JoinDialog::autoRecordFilename() const
266 {
267 	return m_ui->autoRecord->isChecked() ? m_recordingFilename : QString();
268 }
269 
refreshListing()270 void JoinDialog::refreshListing()
271 {
272 	if(m_ui->listing->isHidden() || QDateTime::currentSecsSinceEpoch() - m_lastRefresh < REFRESH_INTERVAL)
273 		return;
274 	m_lastRefresh = QDateTime::currentSecsSinceEpoch();
275 
276 	auto listservers = sessionlisting::ListServerModel::listServers(true);
277 	for(const sessionlisting::ListServer &ls : listservers) {
278 		if(!ls.publicListings)
279 			continue;
280 
281 		const QUrl url = ls.url;
282 		if(!url.isValid()) {
283 			qWarning("Invalid list server URL: %s", qPrintable(ls.url));
284 			continue;
285 		}
286 
287 		auto response = sessionlisting::getSessionList(url);
288 
289 		connect(response, &sessionlisting::AnnouncementApiResponse::serverGone,
290 			[ls]() {
291 				qInfo() << "List server at" << ls.url << "is gone. Removing.";
292 				sessionlisting::ListServerModel servers(true);
293 				if(servers.removeServer(ls.url))
294 					servers.saveServers();
295 			});
296 		connect(response, &sessionlisting::AnnouncementApiResponse::finished,
297 			this, [this, ls](const QVariant &result, const QString &message, const QString &error)
298 			{
299 				Q_UNUSED(message)
300 				if(error.isEmpty())
301 					m_sessions->setList(ls.name, result.value<QVector<sessionlisting::Session>>());
302 				else
303 					m_sessions->setMessage(ls.name, error);
304 			});
305 		connect(response, &sessionlisting::AnnouncementApiResponse::finished, response, &QObject::deleteLater);
306 	}
307 }
308 
resolveRoomcode(const QString & roomcode,const QStringList & servers)309 void JoinDialog::resolveRoomcode(const QString &roomcode, const QStringList &servers)
310 {
311 	if(servers.isEmpty()) {
312 		// Tried all the servers and didn't find the code
313 		m_ui->address->lineEdit()->setPlaceholderText(tr("Room code not found!"));
314 		QTimer::singleShot(1500, this, [this]() {
315 			m_ui->address->setEditText(QString());
316 			m_ui->address->lineEdit()->setReadOnly(false);
317 			m_ui->address->lineEdit()->setPlaceholderText(QString());
318 			m_ui->address->setFocus();
319 		});
320 
321 		return;
322 	}
323 
324 	const QUrl listServer = servers.first();
325 	qDebug() << "Querying join code" << roomcode << "at server:" << listServer;
326 	auto response = sessionlisting::queryRoomcode(listServer, roomcode);
327 	connect(response, &sessionlisting::AnnouncementApiResponse::finished,
328 		this, [this, roomcode, servers](const QVariant &result, const QString &message, const QString &error)
329 		{
330 			Q_UNUSED(message)
331 			if(!error.isEmpty()) {
332 				// Not found. Try the next server.
333 				resolveRoomcode(roomcode, servers.mid(1));
334 				return;
335 			}
336 
337 			auto session = result.value<sessionlisting::Session>();
338 
339 			QString url = "drawpile://" + session.host;
340 			if(session.port != 27750)
341 				url += QStringLiteral(":%1").arg(session.port);
342 			url += '/';
343 			url += session.id;
344 			m_ui->address->lineEdit()->setReadOnly(false);
345 			m_ui->address->lineEdit()->setPlaceholderText(QString());
346 			m_ui->address->setEditText(url);
347 			m_ui->address->setEnabled(true);
348 		}
349 	);
350 	connect(response, &sessionlisting::AnnouncementApiResponse::finished, response, &QObject::deleteLater);
351 }
352 
restoreSettings()353 void JoinDialog::restoreSettings()
354 {
355 	QSettings cfg;
356 	cfg.beginGroup("history");
357 
358 	const QSize oldSize = cfg.value("joindlgsize").toSize();
359 	if(oldSize.isValid()) {
360 		if(oldSize.height() < COMPACT_MODE_THRESHOLD)
361 			setListingVisible(false);
362 		resize(oldSize);
363 	}
364 
365 	m_ui->address->insertItems(0, cfg.value("recenthosts").toStringList());
366 
367 	m_ui->showPassworded->setChecked(cfg.value("filterlocked", true).toBool());
368 	m_ui->showClosed->setChecked(cfg.value("filterclosed", true).toBool());
369 
370 	if(m_ui->showNsfw->isEnabled())
371 		m_ui->showNsfw->setChecked(cfg.value("filternsfw", true).toBool());
372 	else
373 		m_ui->showNsfw->setChecked(false);
374 
375 	const int sortColumn = cfg.value("listsortcol", 1).toInt();
376 	m_ui->listing->sortByColumn(
377 		qAbs(sortColumn)-1,
378 		sortColumn > 0 ? Qt::AscendingOrder : Qt::DescendingOrder
379 	);
380 }
381 
rememberSettings() const382 void JoinDialog::rememberSettings() const
383 {
384 	QSettings cfg;
385 	cfg.beginGroup("history");
386 
387 	cfg.setValue("joindlgsize", size());
388 
389 	QStringList hosts;
390 	// Move current item to the top of the list
391 	const QString current = cleanAddress(m_ui->address->currentText());
392 	int curindex = m_ui->address->findText(current);
393 	if(curindex>=0)
394 		m_ui->address->removeItem(curindex);
395 	hosts << current;
396 	for(int i=0;i<qMin(8, m_ui->address->count());++i) {
397 		if(!m_ui->address->itemText(i).isEmpty())
398 			hosts << m_ui->address->itemText(i);
399 	}
400 	cfg.setValue("recenthosts", hosts);
401 
402 	const auto *listingHeader = m_ui->listing->header();
403 
404 	cfg.setValue("listsortcol",
405 		(listingHeader->sortIndicatorSection() + 1) *
406 		(listingHeader->sortIndicatorOrder() == Qt::AscendingOrder ? 1 : -1)
407 	);
408 }
409 
getAddress() const410 QString JoinDialog::getAddress() const {
411 	return m_ui->address->currentText().trimmed();
412 }
413 
getUrl() const414 QUrl JoinDialog::getUrl() const
415 {
416 	const QString address = getAddress();
417 
418 	QString scheme;
419 	if(!address.startsWith("drawpile://"))
420 		scheme = "drawpile://";
421 
422 	const QUrl url = QUrl(scheme + address, QUrl::TolerantMode);
423 	if(!url.isValid() || url.host().isEmpty())
424 		return QUrl();
425 
426 	return url;
427 }
428 
addListServer()429 void JoinDialog::addListServer()
430 {
431 	// This is the "simplified" way of adding list servers:
432 	// The application will fetch the server's root page (http://DOMAIN/)
433 	// and see if there is a <meta name="drawpile:list-server"> tag.
434 	// If there is, it will follow it and add the list server.
435 	QString urlStr = m_ui->address->currentText();
436 	QUrl url;
437 	if(!urlStr.contains('/')) {
438 		url = QUrl { "http://" + urlStr };
439 	} else {
440 		url = QUrl { "http://" + QUrl{urlStr}.host() };
441 	}
442 
443 	addListServerUrl(url);
444 }
445 
addListServerUrl(const QUrl & url)446 void JoinDialog::addListServerUrl(const QUrl &url)
447 {
448 	m_addServerButton->setEnabled(false);
449 
450 	auto *dlg = new AddServerDialog(this);
451 
452 	connect(dlg, &QObject::destroyed, [this]() { m_addServerButton->setEnabled(true); });
453 
454 	connect(dlg, &AddServerDialog::serverAdded, this, [this](const QString &name) {
455 		m_sessions->setMessage(name, tr("Loading..."));
456 		m_ui->noListServersNotification->hide();
457 
458 		if(height() < COMPACT_MODE_THRESHOLD)
459 			resize(width(), COMPACT_MODE_THRESHOLD + 10);
460 
461 		const auto index = m_sessions->index(m_sessions->rowCount()-1, 0);
462 		m_ui->listing->expandAll();
463 		m_ui->listing->scrollTo(index);
464 
465 		m_lastRefresh = 0;
466 		refreshListing();
467 	});
468 
469 	dlg->query(url);
470 }
471 
472 }
473 
474