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 | |
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 | |