1/*
2 This file is part of the KDE libraries
3
4 SPDX-FileCopyrightText: 2005-2012 David Faure <faure@kde.org>
5 SPDX-FileCopyrightText: 2022-2023 Harald Sitter <sitter@kde.org>
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
33static QString kdeUriListMime()
34{
35 return QStringLiteral("application/x-kde4-urilist");
36} // keep this name "kde4" for compat.
37
38static 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
49void 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
58void 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
70QStringList KUrlMimeData::mimeDataTypes()
71{
72 return QStringList{kdeUriListMime(), QStringLiteral("text/uri-list")};
73}
74
75static QList<QUrl> extractKdeUriList(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
92static QString kioFuseServiceName()
93{
94 return QStringLiteral("org.kde.KIOFuse");
95}
96
97static QString portalServiceName()
98{
99 return QStringLiteral("org.freedesktop.portal.Documents");
100}
101
102static bool isKIOFuseAvailable()
103{
104 static bool available = QDBusConnection::sessionBus().interface()
105 && QDBusConnection::sessionBus().interface()->activatableServiceNames().value().contains(str: kioFuseServiceName());
106 return available;
107}
108
109bool KUrlMimeData::isDocumentsPortalAvailable()
110{
111 static bool available =
112 QDBusConnection::sessionBus().interface() && QDBusConnection::sessionBus().interface()->activatableServiceNames().value().contains(str: portalServiceName());
113 return available;
114}
115
116static QString portalFormat()
117{
118 return QStringLiteral("application/vnd.portal.filetransfer");
119}
120
121static QList<QUrl> extractPortalUriList(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
150static QString sourceIdMime()
151{
152 return QStringLiteral("application/x-kde-source-id");
153}
154
155static QString sourceId()
156{
157 return QDBusConnection::sessionBus().baseService();
158}
159
160void KUrlMimeData::setSourceId(QMimeData *mimeData)
161{
162 mimeData->setData(mimetype: sourceIdMime(), data: sourceId().toUtf8());
163}
164
165static bool hasSameSourceId(const QMimeData *mimeData)
166{
167 return mimeData->hasFormat(mimetype: sourceIdMime()) && mimeData->data(mimetype: sourceIdMime()) == sourceId().toUtf8();
168}
169
170#endif
171
172QList<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
226static 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
235static 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
282bool 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

source code of kcoreaddons/src/lib/io/kurlmimedata.cpp