| 1 | /* |
| 2 | This file is part of the KDE libraries |
| 3 | |
| 4 | SPDX-FileCopyrightText: 2005-2012 David Faure <[email protected]> |
| 5 | SPDX-FileCopyrightText: 2022-2023 Harald Sitter <[email protected]> |
| 6 | |
| 7 | SPDX-License-Identifier: LGPL-2.0-or-later |
| 8 | */ |
| 9 | |
| 10 | #include "kurlmimedata.h" |
| 11 | #include "config-kdirwatch.h" |
| 12 | |
| 13 | #if HAVE_QTDBUS // not used outside dbus/xdg-portal related code |
| 14 | #include <fcntl.h> |
| 15 | #include <sys/stat.h> |
| 16 | #include <sys/types.h> |
| 17 | #include <unistd.h> |
| 18 | #endif |
| 19 | |
| 20 | #include <optional> |
| 21 | |
| 22 | #include <QMimeData> |
| 23 | #include <QStringList> |
| 24 | |
| 25 | #include "kcoreaddons_debug.h" |
| 26 | #if HAVE_QTDBUS |
| 27 | #include "org.freedesktop.portal.FileTransfer.h" |
| 28 | #include "org.kde.KIOFuse.VFS.h" |
| 29 | #endif |
| 30 | |
| 31 | #include "kurlmimedata_p.h" |
| 32 | |
| 33 | static QString kdeUriListMime() |
| 34 | { |
| 35 | return QStringLiteral("application/x-kde4-urilist" ); |
| 36 | } // keep this name "kde4" for compat. |
| 37 | |
| 38 | static QByteArray uriListData(const QList<QUrl> &urls) |
| 39 | { |
| 40 | // compatible with qmimedata.cpp encoding of QUrls |
| 41 | QByteArray result; |
| 42 | for (int i = 0; i < urls.size(); ++i) { |
| 43 | result += urls.at(i).toEncoded(); |
| 44 | result += "\r\n" ; |
| 45 | } |
| 46 | return result; |
| 47 | } |
| 48 | |
| 49 | void KUrlMimeData::setUrls(const QList<QUrl> &urls, const QList<QUrl> &mostLocalUrls, QMimeData *mimeData) |
| 50 | { |
| 51 | // Export the most local urls as text/uri-list and plain text, for non KDE apps. |
| 52 | mimeData->setUrls(mostLocalUrls); // set text/uri-list and text/plain |
| 53 | |
| 54 | // Export the real KIO urls as a kde-specific mimetype |
| 55 | mimeData->setData(mimetype: kdeUriListMime(), data: uriListData(urls)); |
| 56 | } |
| 57 | |
| 58 | void KUrlMimeData::setMetaData(const MetaDataMap &metaData, QMimeData *mimeData) |
| 59 | { |
| 60 | QByteArray metaDataData; // :) |
| 61 | for (auto it = metaData.cbegin(); it != metaData.cend(); ++it) { |
| 62 | metaDataData += it.key().toUtf8(); |
| 63 | metaDataData += "$@@$" ; |
| 64 | metaDataData += it.value().toUtf8(); |
| 65 | metaDataData += "$@@$" ; |
| 66 | } |
| 67 | mimeData->setData(QStringLiteral("application/x-kio-metadata" ), data: metaDataData); |
| 68 | } |
| 69 | |
| 70 | QStringList KUrlMimeData::mimeDataTypes() |
| 71 | { |
| 72 | return QStringList{kdeUriListMime(), QStringLiteral("text/uri-list" )}; |
| 73 | } |
| 74 | |
| 75 | static QList<QUrl> (const QMimeData *mimeData) |
| 76 | { |
| 77 | QList<QUrl> uris; |
| 78 | const QByteArray ba = mimeData->data(mimetype: kdeUriListMime()); |
| 79 | // Code from qmimedata.cpp |
| 80 | QList<QByteArray> urls = ba.split(sep: '\n'); |
| 81 | uris.reserve(asize: urls.size()); |
| 82 | for (int i = 0; i < urls.size(); ++i) { |
| 83 | QByteArray data = urls.at(i).trimmed(); |
| 84 | if (!data.isEmpty()) { |
| 85 | uris.append(t: QUrl::fromEncoded(input: data)); |
| 86 | } |
| 87 | } |
| 88 | return uris; |
| 89 | } |
| 90 | |
| 91 | #if HAVE_QTDBUS |
| 92 | static QString kioFuseServiceName() |
| 93 | { |
| 94 | return QStringLiteral("org.kde.KIOFuse" ); |
| 95 | } |
| 96 | |
| 97 | static QString portalServiceName() |
| 98 | { |
| 99 | return QStringLiteral("org.freedesktop.portal.Documents" ); |
| 100 | } |
| 101 | |
| 102 | static bool isKIOFuseAvailable() |
| 103 | { |
| 104 | static bool available = QDBusConnection::sessionBus().interface() |
| 105 | && QDBusConnection::sessionBus().interface()->activatableServiceNames().value().contains(str: kioFuseServiceName()); |
| 106 | return available; |
| 107 | } |
| 108 | |
| 109 | bool KUrlMimeData::isDocumentsPortalAvailable() |
| 110 | { |
| 111 | static bool available = |
| 112 | QDBusConnection::sessionBus().interface() && QDBusConnection::sessionBus().interface()->activatableServiceNames().value().contains(str: portalServiceName()); |
| 113 | return available; |
| 114 | } |
| 115 | |
| 116 | static QString portalFormat() |
| 117 | { |
| 118 | return QStringLiteral("application/vnd.portal.filetransfer" ); |
| 119 | } |
| 120 | |
| 121 | static QList<QUrl> (const QMimeData *mimeData) |
| 122 | { |
| 123 | Q_ASSERT(QCoreApplication::instance()->thread() == QThread::currentThread()); |
| 124 | static std::pair<QByteArray, QList<QUrl>> cache; |
| 125 | const auto transferId = mimeData->data(mimetype: portalFormat()); |
| 126 | qCDebug(KCOREADDONS_DEBUG) << "Picking up portal urls from transfer" << transferId; |
| 127 | if (std::get<QByteArray>(p&: cache) == transferId) { |
| 128 | const auto uris = std::get<QList<QUrl>>(p&: cache); |
| 129 | qCDebug(KCOREADDONS_DEBUG) << "Urls from portal cache" << uris; |
| 130 | return uris; |
| 131 | } |
| 132 | auto iface = |
| 133 | new OrgFreedesktopPortalFileTransferInterface(portalServiceName(), QStringLiteral("/org/freedesktop/portal/documents" ), QDBusConnection::sessionBus()); |
| 134 | const QDBusReply<QStringList> reply = iface->RetrieveFiles(key: QString::fromUtf8(ba: transferId), options: {}); |
| 135 | if (!reply.isValid()) { |
| 136 | qCWarning(KCOREADDONS_DEBUG) << "Failed to retrieve files from portal:" << reply.error(); |
| 137 | return {}; |
| 138 | } |
| 139 | const QStringList list = reply.value(); |
| 140 | QList<QUrl> uris; |
| 141 | uris.reserve(asize: list.size()); |
| 142 | for (const auto &path : list) { |
| 143 | uris.append(t: QUrl::fromLocalFile(localfile: path)); |
| 144 | } |
| 145 | qCDebug(KCOREADDONS_DEBUG) << "Urls from portal" << uris; |
| 146 | cache = std::make_pair(x: transferId, y&: uris); |
| 147 | return uris; |
| 148 | } |
| 149 | |
| 150 | static QString sourceIdMime() |
| 151 | { |
| 152 | return QStringLiteral("application/x-kde-source-id" ); |
| 153 | } |
| 154 | |
| 155 | static QString sourceId() |
| 156 | { |
| 157 | return QDBusConnection::sessionBus().baseService(); |
| 158 | } |
| 159 | |
| 160 | void KUrlMimeData::setSourceId(QMimeData *mimeData) |
| 161 | { |
| 162 | mimeData->setData(mimetype: sourceIdMime(), data: sourceId().toUtf8()); |
| 163 | } |
| 164 | |
| 165 | static bool hasSameSourceId(const QMimeData *mimeData) |
| 166 | { |
| 167 | return mimeData->hasFormat(mimetype: sourceIdMime()) && mimeData->data(mimetype: sourceIdMime()) == sourceId().toUtf8(); |
| 168 | } |
| 169 | |
| 170 | #endif |
| 171 | |
| 172 | QList<QUrl> KUrlMimeData::urlsFromMimeData(const QMimeData *mimeData, DecodeOptions decodeOptions, MetaDataMap *metaData) |
| 173 | { |
| 174 | QList<QUrl> uris; |
| 175 | |
| 176 | #if HAVE_QTDBUS |
| 177 | if (!hasSameSourceId(mimeData) && isDocumentsPortalAvailable() && mimeData->hasFormat(mimetype: portalFormat())) { |
| 178 | uris = extractPortalUriList(mimeData); |
| 179 | if (static const auto force = qEnvironmentVariableIntValue(varName: "KCOREADDONS_FORCE_DOCUMENTS_PORTAL" ); force == 1) { |
| 180 | // The environment variable is FOR TESTING ONLY! |
| 181 | // It is used to prevent the fallback logic from running. |
| 182 | return uris; |
| 183 | } |
| 184 | } |
| 185 | #endif |
| 186 | |
| 187 | if (uris.isEmpty()) { |
| 188 | if (decodeOptions.testFlag(flag: PreferLocalUrls)) { |
| 189 | // Extracting uris from text/uri-list, use the much faster QMimeData method urls() |
| 190 | uris = mimeData->urls(); |
| 191 | if (uris.isEmpty()) { |
| 192 | uris = extractKdeUriList(mimeData); |
| 193 | } |
| 194 | } else { |
| 195 | uris = extractKdeUriList(mimeData); |
| 196 | if (uris.isEmpty()) { |
| 197 | uris = mimeData->urls(); |
| 198 | } |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | if (metaData) { |
| 203 | const QByteArray metaDataPayload = mimeData->data(QStringLiteral("application/x-kio-metadata" )); |
| 204 | if (!metaDataPayload.isEmpty()) { |
| 205 | QString str = QString::fromUtf8(utf8: metaDataPayload.constData()); |
| 206 | Q_ASSERT(str.endsWith(QLatin1String("$@@$" ))); |
| 207 | str.chop(n: 4); |
| 208 | const QStringList lst = str.split(QStringLiteral("$@@$" )); |
| 209 | bool readingKey = true; // true, then false, then true, etc. |
| 210 | QString key; |
| 211 | for (const QString &s : lst) { |
| 212 | if (readingKey) { |
| 213 | key = s; |
| 214 | } else { |
| 215 | metaData->insert(key, value: s); |
| 216 | } |
| 217 | readingKey = !readingKey; |
| 218 | } |
| 219 | Q_ASSERT(readingKey); // an odd number of items would be, well, odd ;-) |
| 220 | } |
| 221 | } |
| 222 | return uris; |
| 223 | } |
| 224 | |
| 225 | #if HAVE_QTDBUS |
| 226 | static QStringList urlListToStringList(const QList<QUrl> urls) |
| 227 | { |
| 228 | QStringList list; |
| 229 | for (const auto &url : urls) { |
| 230 | list << url.toLocalFile(); |
| 231 | } |
| 232 | return list; |
| 233 | } |
| 234 | |
| 235 | static std::optional<QStringList> fuseRedirect(QList<QUrl> urls, bool onlyLocalFiles) |
| 236 | { |
| 237 | qCDebug(KCOREADDONS_DEBUG) << "mounting urls with fuse" << urls; |
| 238 | |
| 239 | // Fuse redirection only applies if the list contains non-local files. |
| 240 | if (onlyLocalFiles) { |
| 241 | return urlListToStringList(urls); |
| 242 | } |
| 243 | |
| 244 | OrgKdeKIOFuseVFSInterface kiofuse_iface(kioFuseServiceName(), QStringLiteral("/org/kde/KIOFuse" ), QDBusConnection::sessionBus()); |
| 245 | struct MountRequest { |
| 246 | QDBusPendingReply<QString> reply; |
| 247 | int urlIndex; |
| 248 | QString basename; |
| 249 | }; |
| 250 | QList<MountRequest> requests; |
| 251 | requests.reserve(asize: urls.count()); |
| 252 | for (int i = 0; i < urls.count(); ++i) { |
| 253 | QUrl url = urls.at(i); |
| 254 | if (!url.isLocalFile()) { |
| 255 | const QString path(url.path()); |
| 256 | const int slashes = path.count(c: QLatin1Char('/')); |
| 257 | QString basename; |
| 258 | if (slashes > 1) { |
| 259 | url.setPath(path: path.section(asep: QLatin1Char('/'), astart: 0, aend: slashes - 1)); |
| 260 | basename = path.section(asep: QLatin1Char('/'), astart: slashes, aend: slashes); |
| 261 | } |
| 262 | requests.push_back(t: {.reply: kiofuse_iface.mountUrl(remoteUrl: url.toString()), .urlIndex: i, .basename: basename}); |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | for (auto &request : requests) { |
| 267 | request.reply.waitForFinished(); |
| 268 | if (request.reply.isError()) { |
| 269 | qWarning() << "FUSE request failed:" << request.reply.error(); |
| 270 | return std::nullopt; |
| 271 | } |
| 272 | |
| 273 | urls[request.urlIndex] = QUrl::fromLocalFile(localfile: request.reply.value() + QLatin1Char('/') + request.basename); |
| 274 | }; |
| 275 | |
| 276 | qCDebug(KCOREADDONS_DEBUG) << "mounted urls with fuse, maybe" << urls; |
| 277 | |
| 278 | return urlListToStringList(urls); |
| 279 | } |
| 280 | #endif |
| 281 | |
| 282 | bool KUrlMimeData::exportUrlsToPortal(QMimeData *mimeData) |
| 283 | { |
| 284 | #if HAVE_QTDBUS |
| 285 | if (!isDocumentsPortalAvailable()) { |
| 286 | return false; |
| 287 | } |
| 288 | const QList<QUrl> urls = mimeData->urls(); |
| 289 | bool onlyLocalFiles = true; |
| 290 | for (const auto &url : urls) { |
| 291 | const auto isLocal = url.isLocalFile(); |
| 292 | if (!isLocal) { |
| 293 | onlyLocalFiles = false; |
| 294 | |
| 295 | // For the time being the fuse redirection is opt-in because we later need to open() the files |
| 296 | // and this is an insanely expensive operation involving a stat() for remote URLs that we can't |
| 297 | // really get rid of. We'll need a way to avoid the open(). |
| 298 | // https://bugs.kde.org/show_bug.cgi?id=457529 |
| 299 | // https://github.com/flatpak/xdg-desktop-portal/issues/961 |
| 300 | static const auto fuseRedirect = qEnvironmentVariableIntValue(varName: "KCOREADDONS_FUSE_REDIRECT" ); |
| 301 | if (!fuseRedirect) { |
| 302 | return false; |
| 303 | } |
| 304 | |
| 305 | // some remotes, fusing is enabled, but kio-fuse is unavailable -> cannot run this url list through the portal |
| 306 | if (!isKIOFuseAvailable()) { |
| 307 | qWarning() << "kio-fuse is missing" ; |
| 308 | return false; |
| 309 | } |
| 310 | } else { |
| 311 | const QFileInfo info(url.toLocalFile()); |
| 312 | if (info.isSymbolicLink()) { |
| 313 | // XDG Document Portal also doesn't support symlinks since it doesn't let us open the fd O_NOFOLLOW. |
| 314 | // https://github.com/flatpak/xdg-desktop-portal/issues/961#issuecomment-1573646299 |
| 315 | return false; |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 | |
| 320 | auto iface = |
| 321 | new OrgFreedesktopPortalFileTransferInterface(portalServiceName(), QStringLiteral("/org/freedesktop/portal/documents" ), QDBusConnection::sessionBus()); |
| 322 | |
| 323 | // Do not autostop, we'll stop once our mimedata disappears (i.e. the drag operation has finished); |
| 324 | // Otherwise not-wellbehaved clients that read the urls multiple times will trip the automatic-transfer- |
| 325 | // closing-upon-read inside the portal and have any reads, but the first, not properly resolve anymore. |
| 326 | const QString transferId = iface->StartTransfer(options: {{QStringLiteral("autostop" ), QVariant::fromValue(value: false)}}); |
| 327 | auto cleanup = qScopeGuard(f: [transferId, iface] { |
| 328 | iface->StopTransfer(key: transferId); |
| 329 | iface->deleteLater(); |
| 330 | }); |
| 331 | |
| 332 | const auto optionalPaths = fuseRedirect(urls, onlyLocalFiles); |
| 333 | if (!optionalPaths.has_value()) { |
| 334 | qCWarning(KCOREADDONS_DEBUG) << "Failed to mount with fuse!" ; |
| 335 | return false; |
| 336 | } |
| 337 | |
| 338 | // Prevent running into "too many open files" errors. |
| 339 | // Because submission of calls happens on the qdbus thread we may be feeding |
| 340 | // it QDBusUnixFileDescriptors faster than it can submit them over the wire, this would eventually |
| 341 | // lead to running into the open file cap since the QDBusUnixFileDescriptor hold |
| 342 | // an open FD until their call has been made. |
| 343 | // To prevent this from happening we collect a submission batch, make the call and **wait** for |
| 344 | // the call to succeed. |
| 345 | FDList pendingFds; |
| 346 | static constexpr decltype(pendingFds.size()) maximumBatchSize = 16; |
| 347 | pendingFds.reserve(asize: maximumBatchSize); |
| 348 | |
| 349 | const auto addFilesAndClear = [transferId, &iface, &pendingFds]() { |
| 350 | if (pendingFds.isEmpty()) { |
| 351 | return true; |
| 352 | } |
| 353 | auto reply = iface->AddFiles(key: transferId, fds: pendingFds, options: {}); |
| 354 | reply.waitForFinished(); |
| 355 | if (reply.isError()) { |
| 356 | qCWarning(KCOREADDONS_DEBUG) << "Some files could not be exported. " << reply.error(); |
| 357 | return false; |
| 358 | } |
| 359 | pendingFds.clear(); |
| 360 | return true; |
| 361 | }; |
| 362 | |
| 363 | for (const auto &path : optionalPaths.value()) { |
| 364 | const int fd = open(file: QFile::encodeName(fileName: path).constData(), O_RDONLY | O_CLOEXEC | O_NONBLOCK); |
| 365 | if (fd == -1) { |
| 366 | const int error = errno; |
| 367 | qCWarning(KCOREADDONS_DEBUG) << "Failed to open" << path << strerror(errnum: error); |
| 368 | return false; |
| 369 | } |
| 370 | pendingFds << QDBusUnixFileDescriptor(fd); |
| 371 | close(fd: fd); |
| 372 | |
| 373 | if (pendingFds.size() >= maximumBatchSize) { |
| 374 | if (!addFilesAndClear()) { |
| 375 | return false; |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | if (!addFilesAndClear()) { |
| 381 | return false; |
| 382 | } |
| 383 | |
| 384 | cleanup.dismiss(); |
| 385 | QObject::connect(sender: mimeData, signal: &QObject::destroyed, context: iface, slot: [transferId, iface] { |
| 386 | iface->StopTransfer(key: transferId); |
| 387 | iface->deleteLater(); |
| 388 | }); |
| 389 | QObject::connect(sender: iface, signal: &OrgFreedesktopPortalFileTransferInterface::TransferClosed, context: mimeData, slot: [iface]() { |
| 390 | iface->deleteLater(); |
| 391 | }); |
| 392 | |
| 393 | mimeData->setData(QStringLiteral("application/vnd.portal.filetransfer" ), data: QFile::encodeName(fileName: transferId)); |
| 394 | setSourceId(mimeData); |
| 395 | return true; |
| 396 | #else |
| 397 | Q_UNUSED(mimeData); |
| 398 | return false; |
| 399 | #endif |
| 400 | } |
| 401 | |