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