1 /*
2 * DesktopWebPage.cpp
3 *
4 * Copyright (C) 2021 by RStudio, PBC
5 *
6 * Unless you have received this program directly from RStudio pursuant
7 * to the terms of a commercial license agreement with RStudio, then
8 * this program is licensed to you under the terms of version 3 of the
9 * GNU Affero General Public License. This program is distributed WITHOUT
10 * ANY EXPRESS OR IMPLIED WARRANTY, INCLUDING THOSE OF NON-INFRINGEMENT,
11 * MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Please refer to the
12 * AGPL (http://www.gnu.org/licenses/agpl-3.0.txt) for more details.
13 *
14 */
15
16 #include "DesktopWebPage.hpp"
17
18 #include <boost/algorithm/string.hpp>
19 #include <boost/thread/once.hpp>
20
21 #include <core/http/URL.hpp>
22 #include <core/Thread.hpp>
23
24 #include <QFileDialog>
25 #include <QWebEngineSettings>
26
27 #include "DesktopInfo.hpp"
28 #include "DesktopDownloadItemHelper.hpp"
29 #include "DesktopMainWindow.hpp"
30 #include "DesktopOptions.hpp"
31 #include "DesktopSatelliteWindow.hpp"
32 #include "DesktopSecondaryWindow.hpp"
33 #include "DesktopWebProfile.hpp"
34 #include "DesktopWindowTracker.hpp"
35 #include "DesktopSessionServersOverlay.hpp"
36
37 using namespace rstudio::core;
38
39 extern QString sharedSecret;
40
41 namespace rstudio {
42 namespace desktop {
43
44 namespace {
45
46 WindowTracker s_windowTracker;
47 std::mutex s_mutex;
48
49 // NOTE: This variable is static and hence shared by all of the web pages
50 // created by RStudio. This is intentional as it's possible that the
51 // request interceptor from one page will handle a request, and a _different_
52 // page will handle the new navigation attempt -- so both pages will need
53 // access to the same data.
54 std::map<QUrl, int> s_subframes;
55
56 // NOTE: To work around a bug where Qt thinks that attempts to click a link
57 // are attempts to open that link within a sub-frame, monitor which link was
58 // last hovered and allow navigation to links in that case. This terrible hack
59 // works around https://bugreports.qt.io/browse/QTBUG-56805.
60 QString s_hoveredUrl;
61
onLinkHovered(const QString & url)62 void onLinkHovered(const QString& url)
63 {
64 s_hoveredUrl = url;
65 }
66
handlePdfDownload(QWebEngineDownloadItem * downloadItem,const QString & downloadPath)67 void handlePdfDownload(QWebEngineDownloadItem* downloadItem,
68 const QString& downloadPath)
69 {
70 QObject::connect(
71 downloadItem, &QWebEngineDownloadItem::finished,
72 [=]()
73 {
74 desktop::openFile(downloadPath);
75 });
76
77 downloadItem->setPath(downloadPath);
78 downloadItem->accept();
79 }
80
onPdfDownloadRequested(QWebEngineDownloadItem * downloadItem)81 void onPdfDownloadRequested(QWebEngineDownloadItem* downloadItem)
82 {
83 QString scratchDir =
84 QString::fromStdString(options().scratchTempDir().getAbsolutePath());
85
86 // if we're requesting the download of a file with a '.pdf' extension,
87 // re-use that file name (since most desktop applications will display the
88 // associated filename somewhere visible)
89 QString fileName = downloadItem->url().fileName();
90 if (fileName.endsWith(QStringLiteral(".pdf"), Qt::CaseInsensitive))
91 {
92 QTemporaryDir tempDir(QStringLiteral("%1/").arg(scratchDir));
93 tempDir.setAutoRemove(false);
94 if (tempDir.isValid())
95 {
96 QString downloadPath = QStringLiteral("%1/%2")
97 .arg(tempDir.path())
98 .arg(fileName);
99
100 return handlePdfDownload(downloadItem, downloadPath);
101 }
102 }
103
104 // otherwise, create a temporary file
105 QString fileTemplate = QStringLiteral("%1/RStudio-XXXXXX.pdf").arg(scratchDir);
106 QTemporaryFile tempFile(fileTemplate);
107 tempFile.setAutoRemove(false);
108 if (tempFile.open())
109 return handlePdfDownload(downloadItem, tempFile.fileName());
110 }
111
onDownloadRequested(QWebEngineDownloadItem * downloadItem)112 void onDownloadRequested(QWebEngineDownloadItem* downloadItem)
113 {
114 // download and then open PDF files requested by the user
115 if (downloadItem->mimeType() == QStringLiteral("application/pdf"))
116 return onPdfDownloadRequested(downloadItem);
117
118 // request directory from user
119 QString downloadPath = QFileDialog::getSaveFileName(
120 nullptr,
121 QStringLiteral("Save File"),
122 downloadItem->path());
123
124 if (downloadPath.isEmpty())
125 return;
126
127 DownloadHelper::manageDownload(downloadItem, downloadPath);
128 }
129
130 } // anonymous namespace
131
WebPage(QUrl baseUrl,QWidget * parent,bool allowExternalNavigate)132 WebPage::WebPage(QUrl baseUrl, QWidget *parent, bool allowExternalNavigate) :
133 QWebEnginePage(new WebProfile(baseUrl), parent),
134 baseUrl_(baseUrl),
135 allowExternalNav_(allowExternalNavigate)
136 {
137 init();
138 }
139
WebPage(QWebEngineProfile * profile,QUrl baseUrl,QWidget * parent,bool allowExternalNavigate)140 WebPage::WebPage(QWebEngineProfile *profile,
141 QUrl baseUrl,
142 QWidget *parent,
143 bool allowExternalNavigate) :
144 QWebEnginePage(profile, parent),
145 baseUrl_(baseUrl),
146 allowExternalNav_(allowExternalNavigate)
147 {
148 init();
149 }
150
init()151 void WebPage::init()
152 {
153 settings()->setAttribute(QWebEngineSettings::PluginsEnabled, true);
154 settings()->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, true);
155 settings()->setAttribute(QWebEngineSettings::JavascriptCanOpenWindows, true);
156 settings()->setAttribute(QWebEngineSettings::JavascriptCanAccessClipboard, true);
157 settings()->setAttribute(QWebEngineSettings::LocalStorageEnabled, true);
158 settings()->setAttribute(QWebEngineSettings::WebGLEnabled, true);
159
160 #ifdef __APPLE__
161 settings()->setFontFamily(QWebEngineSettings::FixedFont, QStringLiteral("Courier"));
162 settings()->setFontFamily(QWebEngineSettings::SansSerifFont, QStringLiteral("Helvetica"));
163 #endif
164
165 defaultSaveDir_ = QDir::home();
166 connect(this, SIGNAL(windowCloseRequested()), SLOT(closeRequested()));
167 connect(this, &QWebEnginePage::linkHovered, onLinkHovered);
168 connect(profile(), &QWebEngineProfile::downloadRequested, onDownloadRequested);
169 connect(profile(), &WebProfile::urlIntercepted, this, &WebPage::onUrlIntercepted, Qt::DirectConnection);
170 }
171
setBaseUrl(const QUrl & baseUrl)172 void WebPage::setBaseUrl(const QUrl& baseUrl)
173 {
174 profile()->setBaseUrl(baseUrl);
175 baseUrl_ = baseUrl;
176 }
177
activateWindow(QString name)178 void WebPage::activateWindow(QString name)
179 {
180 BrowserWindow* pWindow = s_windowTracker.getWindow(name);
181 if (pWindow)
182 desktop::raiseAndActivateWindow(pWindow);
183 }
184
closeWindow(QString name)185 void WebPage::closeWindow(QString name)
186 {
187 BrowserWindow* pWindow = s_windowTracker.getWindow(name);
188 if (pWindow)
189 desktop::closeWindow(pWindow);
190 }
191
prepareForWindow(const PendingWindow & pendingWindow)192 void WebPage::prepareForWindow(const PendingWindow& pendingWindow)
193 {
194 std::lock_guard<std::mutex> lock(s_mutex);
195
196 pendingWindows_.push(pendingWindow);
197 }
198
createWindow(QWebEnginePage::WebWindowType type)199 QWebEnginePage* WebPage::createWindow(QWebEnginePage::WebWindowType type)
200 {
201 std::lock_guard<std::mutex> lock(s_mutex);
202
203 QString name;
204 bool isSatellite = false;
205 bool showToolbar = true;
206 bool allowExternalNavigate = false;
207 int width = 0;
208 int height = 0;
209 int x = -1;
210 int y = -1;
211 MainWindow* pMainWindow = nullptr;
212 BrowserWindow* pWindow = nullptr;
213 bool show = true;
214
215 // check if this is target="_blank" from an IDE window
216 if (!baseUrl_.isEmpty() && type == QWebEnginePage::WebWindowType::WebBrowserTab)
217 {
218 // QtWebEngine behavior is to open a new browser window and send it the
219 // acceptNavigationRequest we use to redirect to system browser; don't want
220 // that to be visible
221 show = false;
222 name = tr("_blank_redirector");
223
224 // check for an existing hidden window
225 pWindow = s_windowTracker.getWindow(name);
226 if (pWindow)
227 {
228 return pWindow->webView()->webPage();
229 }
230 }
231
232 // check if we have a satellite window waiting to come up
233 if (!pendingWindows_.empty())
234 {
235 // retrieve the window
236 PendingWindow pendingWindow = pendingWindows_.front();
237 pendingWindows_.pop();
238
239 // capture pending window params then clear them (one time only)
240 name = pendingWindow.name;
241 isSatellite = pendingWindow.isSatellite;
242 showToolbar = pendingWindow.showToolbar;
243 pMainWindow = pendingWindow.pMainWindow;
244 allowExternalNavigate = pendingWindow.allowExternalNavigate;
245
246 // get width and height, and adjust for high DPI
247 double dpiZoomScaling = getDpiZoomScaling();
248 width = pendingWindow.width * dpiZoomScaling;
249 height = pendingWindow.height * dpiZoomScaling;
250 x = pendingWindow.x;
251 y = pendingWindow.y;
252
253 // check for an existing window of this name
254 pWindow = s_windowTracker.getWindow(name);
255 if (pWindow)
256 {
257 // activate the browser then return NULL to indicate
258 // we didn't create a new WebView
259 desktop::raiseAndActivateWindow(pWindow);
260 return nullptr;
261 }
262 }
263
264 if (isSatellite)
265 {
266 // create and size
267 pWindow = new SatelliteWindow(pMainWindow, name, this);
268 pWindow->resize(width, height);
269
270 if (x >= 0 && y >= 0)
271 {
272 // if the window specified its location, use it
273 pWindow->move(x, y);
274 }
275 else if (name != QString::fromUtf8("pdf"))
276 {
277 // window location was left for us to determine; try to tile the window
278 // (but leave pdf window alone since it is so large)
279
280 // calculate location to move to
281
282 // y always attempts to be 25 pixels above then faults back
283 // to 25 pixels below if that would be offscreen
284 const int OVERLAP = 25;
285 int moveY = pMainWindow->y() - OVERLAP;
286 if (moveY < 0)
287 moveY = pMainWindow->y() + OVERLAP;
288
289 // x is based on centering over main window
290 int moveX = pMainWindow->x() +
291 (pMainWindow->width() / 2) -
292 (width / 2);
293
294 // perform move
295 pWindow->move(moveX, moveY);
296 }
297 }
298 else
299 {
300 pWindow = new SecondaryWindow(showToolbar, name, baseUrl_, nullptr, this,
301 allowExternalNavigate);
302
303 // allow for Ctrl + W to close window (NOTE: Ctrl means Meta on macOS)
304 QAction* action = new QAction(pWindow);
305 action->setShortcut(Qt::CTRL + Qt::Key_W);
306 pWindow->addAction(action);
307 QObject::connect(
308 action, &QAction::triggered,
309 pWindow, &BrowserWindow::close);
310 }
311
312 // if we have a name set, start tracking this window
313 if (!name.isEmpty())
314 {
315 s_windowTracker.addWindow(name, pWindow);
316 }
317
318 // show and return the browser
319 if (show)
320 pWindow->show();
321 return pWindow->webView()->webPage();
322 }
323
closeRequested()324 void WebPage::closeRequested()
325 {
326 // invoked when close is requested via script (i.e. window.close()); honor
327 // this request by closing the window in which the view is hosted
328 view()->window()->close();
329 }
330
onUrlIntercepted(const QUrl & url,int type)331 void WebPage::onUrlIntercepted(const QUrl& url, int type)
332 {
333 if (type == QWebEngineUrlRequestInfo::ResourceTypeSubFrame &&
334 url != s_hoveredUrl)
335 {
336 s_subframes[url] = type;
337 }
338 }
339
shouldInterruptJavaScript()340 bool WebPage::shouldInterruptJavaScript()
341 {
342 return false;
343 }
344
javaScriptConsoleMessage(JavaScriptConsoleMessageLevel,const QString & message,int,const QString &)345 void WebPage::javaScriptConsoleMessage(JavaScriptConsoleMessageLevel /*level*/, const QString& message,
346 int /*lineNumber*/, const QString& /*sourceID*/)
347 {
348 qDebug() << message;
349 }
350
351 namespace {
352
353 boost::once_flag s_once = BOOST_ONCE_INIT;
354 std::vector<std::string> safeHosts_;
355
initSafeHosts()356 void initSafeHosts()
357 {
358 safeHosts_ = {
359 ".youtube.com",
360 ".vimeo.com",
361 ".c9.ms",
362 ".google.com"
363 };
364
365 for (const SessionServer& server : sessionServerSettings().servers())
366 {
367 http::URL url(server.url());
368 safeHosts_.push_back(url.hostname());
369 }
370 }
371
isSafeHost(const std::string & host)372 bool isSafeHost(const std::string& host)
373 {
374 boost::call_once(s_once,
375 initSafeHosts);
376
377 for (auto entry : safeHosts_)
378 if (boost::algorithm::ends_with(host, entry))
379 return true;
380
381 return false;
382 }
383
384 } // end anonymous namespace
385
386 // NOTE: NavigationType is unreliable, as per:
387 //
388 // https://bugreports.qt.io/browse/QTBUG-56805
389 //
390 // so we avoid querying it here.
acceptNavigationRequest(const QUrl & url,NavigationType,bool isMainFrame)391 bool WebPage::acceptNavigationRequest(const QUrl &url,
392 NavigationType /* navType*/,
393 bool isMainFrame)
394 {
395 if (url.toString() == QStringLiteral("about:blank"))
396 return true;
397
398 if (url.toString() == QStringLiteral("chrome://gpu/"))
399 return true;
400
401 if (url.scheme() == QStringLiteral("data"))
402 return true;
403
404 if (url.scheme() != QStringLiteral("http") &&
405 url.scheme() != QStringLiteral("https") &&
406 url.scheme() != QStringLiteral("mailto"))
407 {
408 qDebug() << url.toString();
409 return false;
410 }
411
412 // determine if this is a local request (handle internally only if local)
413 std::string host = url.host().toStdString();
414 bool isLocal = host == "localhost" || host == "127.0.0.1" || host == "::1";
415
416 // accept Chrome Developer Tools navigation attempts
417 #ifndef NDEBUG
418 if (isLocal && url.port() == desktopInfo().getChromiumDevtoolsPort())
419 return true;
420 #endif
421
422 // if this appears to be an attempt to load an external page within
423 // an iframe, check it's from a safe host
424 if (!isLocal && s_subframes.count(url))
425 {
426 s_subframes.erase(url);
427 return isSafeHost(host);
428 }
429
430 if ((baseUrl_.isEmpty() && isLocal) ||
431 (url.scheme() == baseUrl_.scheme() &&
432 url.host() == baseUrl_.host() &&
433 url.port() == baseUrl_.port()))
434 {
435 return true;
436 }
437 // allow viewer urls to be handled internally by Qt. note that the client is responsible for
438 // ensuring that non-local viewer urls are appropriately sandboxed.
439 else if (!viewerUrl().isEmpty() &&
440 url.toString().startsWith(viewerUrl()))
441 {
442 return true;
443 }
444 // allow tutorial urls to be handled internally by Qt. note that the client is responsible for
445 // ensuring that non-local tutorial urls are appropriately sandboxed.
446 else if (!tutorialUrl().isEmpty() &&
447 url.toString().startsWith(tutorialUrl()))
448 {
449 return true;
450 }
451 // allow shiny dialog urls to be handled internally by Qt
452 else if (isLocal && !shinyDialogUrl_.isEmpty() &&
453 url.toString().startsWith(shinyDialogUrl_))
454 {
455 return true;
456 }
457 else
458 {
459 bool navigated = false;
460
461 if (allowExternalNav_)
462 {
463 // if allowing external navigation, follow this (even if a link click)
464 return true;
465 }
466 else if (isSafeHost(host))
467 {
468 return true;
469 }
470 else
471 {
472 // when not allowing external navigation, open an external browser
473 // to view the URL
474 desktop::openUrl(url);
475 navigated = true;
476 }
477
478 if (!navigated)
479 this->view()->window()->deleteLater();
480
481 return false;
482 }
483 }
484
485 namespace {
486
setPaneUrl(const QString & requestedUrl,QString * pUrl)487 void setPaneUrl(const QString& requestedUrl, QString* pUrl)
488 {
489 // record about:blank literally
490 if (requestedUrl == QStringLiteral("about:blank"))
491 {
492 *pUrl = requestedUrl;
493 return;
494 }
495
496 // extract the authority (domain and port) from the URL; we'll agree to
497 // serve requests for the pane that match this prefix.
498 QUrl url(requestedUrl);
499 *pUrl =
500 url.scheme() + QStringLiteral("://") +
501 url.authority() + QStringLiteral("/");
502
503 }
504
505 } // end anonymous namespace
506
setTutorialUrl(const QString & tutorialUrl)507 void WebPage::setTutorialUrl(const QString& tutorialUrl)
508 {
509 setPaneUrl(tutorialUrl, &tutorialUrl_);
510 }
511
setViewerUrl(const QString & viewerUrl)512 void WebPage::setViewerUrl(const QString& viewerUrl)
513 {
514 setPaneUrl(viewerUrl, &viewerUrl_);
515 }
516
tutorialUrl()517 QString WebPage::tutorialUrl()
518 {
519 if (tutorialUrl_.isEmpty())
520 {
521 // if we don't know the tutorial URL ourselves but we're a child window, ask our parent
522 BrowserWindow *parent = dynamic_cast<BrowserWindow*>(view()->window());
523 if (parent != nullptr && parent->opener() != nullptr)
524 {
525 return parent->opener()->tutorialUrl();
526 }
527 }
528
529 // return our own tutorial URL
530 return tutorialUrl_;
531 }
532
viewerUrl()533 QString WebPage::viewerUrl()
534 {
535 if (viewerUrl_.isEmpty())
536 {
537 // if we don't know the viewer URL ourselves but we're a child window, ask our parent
538 BrowserWindow *parent = dynamic_cast<BrowserWindow*>(view()->window());
539 if (parent != nullptr && parent->opener() != nullptr)
540 {
541 return parent->opener()->viewerUrl();
542 }
543 }
544
545 // return our own viewer URL
546 return viewerUrl_;
547 }
548
setShinyDialogUrl(const QString & shinyDialogUrl)549 void WebPage::setShinyDialogUrl(const QString &shinyDialogUrl)
550 {
551 shinyDialogUrl_ = shinyDialogUrl;
552 }
553
triggerAction(WebAction action,bool checked)554 void WebPage::triggerAction(WebAction action, bool checked)
555 {
556 QWebEnginePage::triggerAction(action, checked);
557 }
558
PendingWindow(QString name,MainWindow * pMainWindow,int screenX,int screenY,int width,int height)559 PendingWindow::PendingWindow(QString name,
560 MainWindow* pMainWindow,
561 int screenX,
562 int screenY,
563 int width,
564 int height)
565 : name(name), pMainWindow(pMainWindow), x(screenX), y(screenY),
566 width(width), height(height), isSatellite(true),
567 allowExternalNavigate(pMainWindow->isRemoteDesktop()), showToolbar(false)
568 {
569 }
570
571 } // namespace desktop
572 } // namespace rstudio
573