1/*
2 kshorturifilter.h
3
4 This file is part of the KDE project
5 SPDX-FileCopyrightText: 2000 Dawit Alemayehu <adawit@kde.org>
6 SPDX-FileCopyrightText: 2000 Malte Starostik <starosti@zedat.fu-berlin.de>
7
8 SPDX-License-Identifier: GPL-2.0-or-later
9*/
10
11#include "kshorturifilter.h"
12#include "../utils_p.h"
13
14#include <QDBusConnection>
15#include <QDir>
16#include <QLoggingCategory>
17#include <qplatformdefs.h>
18
19#include <KApplicationTrader>
20#include <KConfig>
21#include <KConfigGroup>
22#include <KLocalizedString>
23#include <KPluginFactory>
24#include <KService>
25#include <KUser>
26#include <kprotocolinfo.h>
27#include <kurlauthorized.h>
28
29namespace
30{
31Q_LOGGING_CATEGORY(category, "kf.kio.urifilters.shorturi", QtWarningMsg)
32}
33
34static bool isPotentialShortURL(const QString &cmd)
35{
36 // Host names and IPv4 address...
37 // Exclude ".." and paths starting with "../", these are used to go up in a filesystem
38 // dir hierarchy
39 if (cmd != QLatin1String("..") && !cmd.startsWith(s: QLatin1String("../")) && cmd.contains(c: QLatin1Char('.'))) {
40 return true;
41 }
42
43 // IPv6 Address...
44 if (cmd.startsWith(c: QLatin1Char('[')) && cmd.contains(c: QLatin1Char(':'))) {
45 return true;
46 }
47
48 return false;
49}
50
51static QString removeArgs(const QString &_cmd)
52{
53 QString cmd(_cmd);
54
55 if (cmd.isEmpty()) {
56 return cmd;
57 }
58
59 if (cmd[0] != QLatin1Char('\'') && cmd[0] != QLatin1Char('"')) {
60 // Remove command-line options (look for first non-escaped space)
61 int spacePos = 0;
62
63 do {
64 spacePos = cmd.indexOf(c: QLatin1Char(' '), from: spacePos + 1);
65 } while (spacePos > 1 && cmd[spacePos - 1] == QLatin1Char('\\'));
66
67 if (spacePos > 0) {
68 cmd.truncate(pos: spacePos);
69 qCDebug(category) << "spacePos=" << spacePos << " returning " << cmd;
70 }
71 }
72
73 return cmd;
74}
75
76static bool isKnownProtocol(const QString &protocol)
77{
78 if (KProtocolInfo::isKnownProtocol(protocol, updateCacheIfNotfound: false) || protocol == QLatin1String("mailto")) {
79 return true;
80 }
81 const KService::Ptr service = KApplicationTrader::preferredService(mimeType: QLatin1String("x-scheme-handler/") + protocol);
82 return service;
83}
84
85KShortUriFilter::KShortUriFilter(QObject *parent, const KPluginMetaData &data)
86 : KUriFilterPlugin(parent, data)
87{
88 QDBusConnection::sessionBus()
89 .connect(service: QString(), QStringLiteral("/"), QStringLiteral("org.kde.KUriFilterPlugin"), QStringLiteral("configure"), receiver: this, SLOT(configure()));
90 configure();
91}
92
93bool KShortUriFilter::filterUri(KUriFilterData &data) const
94{
95 /*
96 * Here is a description of how the shortURI deals with the supplied
97 * data. First it expands any environment variable settings and then
98 * deals with special shortURI cases. These special cases are the "smb:"
99 * URL scheme which is very specific to KDE, "#" and "##" which are
100 * shortcuts for man:/ and info:/ protocols respectively. It then handles
101 * local files. Then it checks to see if the URL is valid and one that is
102 * supported by KDE's IO system. If all the above checks fails, it simply
103 * lookups the URL in the user-defined list and returns without filtering
104 * if it is not found. TODO: the user-defined table is currently only manually
105 * hackable and is missing a config dialog.
106 */
107
108 // QUrl url = data.uri();
109 QString cmd = data.typedString();
110
111 int firstNonSlash = 0;
112 while (firstNonSlash < cmd.length() && (cmd.at(i: firstNonSlash) == QLatin1Char('/'))) {
113 firstNonSlash++;
114 }
115 if (firstNonSlash > 1) {
116 cmd.remove(i: 0, len: firstNonSlash - 1);
117 }
118
119 // Replicate what KUrl(cmd) did in KDE4. This could later be folded into the checks further down...
120 QUrl url;
121 if (Utils::isAbsoluteLocalPath(path: cmd)) {
122 url = QUrl::fromLocalFile(localfile: cmd);
123 } else {
124 url.setUrl(url: cmd);
125 }
126
127 // WORKAROUND: Allow the use of '@' in the username component of a URL since
128 // other browsers such as firefox in their infinite wisdom allow such blatant
129 // violations of RFC 3986. BR# 69326/118413.
130 if (cmd.count(c: QLatin1Char('@')) > 1) {
131 const int lastIndex = cmd.lastIndexOf(c: QLatin1Char('@'));
132 // Percent encode all but the last '@'.
133 const auto suffix = QStringView(cmd).mid(pos: lastIndex);
134 cmd = QString::fromUtf8(ba: QUrl::toPercentEncoding(cmd.left(n: lastIndex), QByteArrayLiteral(":/"))) + suffix;
135 url.setUrl(url: cmd);
136 }
137
138 const bool isMalformed = !url.isValid();
139 QString protocol = url.scheme();
140
141 qCDebug(category) << cmd;
142
143 // Fix misparsing of "foo:80", QUrl thinks "foo" is the protocol and "80" is the path.
144 // However, be careful not to do that for valid hostless URLs, e.g. file:///foo!
145 if (!protocol.isEmpty() && url.host().isEmpty() && !url.path().isEmpty() && cmd.contains(c: QLatin1Char(':')) && !isKnownProtocol(protocol)) {
146 protocol.clear();
147 }
148
149 qCDebug(category) << "url=" << url << "cmd=" << cmd << "isMalformed=" << isMalformed;
150
151 // TODO: Make this a bit more intelligent for Minicli! There
152 // is no need to make comparisons if the supplied data is a local
153 // executable and only the argument part, if any, changed! (Dawit)
154 // You mean caching the last filtering, to try and reuse it, to save stat()s? (David)
155
156 const QLatin1String starthere_proto("start-here:");
157 if (cmd.startsWith(s: starthere_proto)) {
158 setFilteredUri(data, uri: QUrl(QStringLiteral("system:/")));
159 setUriType(data, type: KUriFilterData::LocalDir);
160 return true;
161 }
162
163 // Handle MAN & INFO pages shortcuts...
164 const QLatin1String man_proto("man:");
165 const QLatin1String info_proto("info:");
166 if (cmd.startsWith(c: QLatin1Char('#')) || cmd.startsWith(s: man_proto) || cmd.startsWith(s: info_proto)) {
167 QStringView sview(cmd);
168 if (cmd.startsWith(s: QLatin1String("##"))) {
169 cmd = QLatin1String("info:/") + sview.mid(pos: 2);
170 } else if (cmd.startsWith(c: QLatin1Char('#'))) {
171 cmd = QLatin1String("man:/") + sview.mid(pos: 1);
172 } else if (cmd == info_proto || cmd == man_proto) {
173 cmd += QLatin1Char('/');
174 }
175
176 setFilteredUri(data, uri: QUrl(cmd));
177 setUriType(data, type: KUriFilterData::Help);
178 return true;
179 }
180
181 // Detect UNC style (aka windows SMB) URLs
182 if (cmd.startsWith(s: QLatin1String("\\\\"))) {
183 // make sure path is unix style
184 cmd.replace(before: QLatin1Char('\\'), after: QLatin1Char('/'));
185 cmd.prepend(s: QLatin1String("smb:"));
186 setFilteredUri(data, uri: QUrl(cmd));
187 setUriType(data, type: KUriFilterData::NetProtocol);
188 return true;
189 }
190
191 bool expanded = false;
192
193 // Expanding shortcut to HOME URL...
194 QString path;
195 QString ref;
196 QString query;
197 QString nameFilter;
198
199 if (!Utils::isAbsoluteLocalPath(path: cmd) && QUrl(cmd).isRelative()) {
200 path = cmd;
201 qCDebug(category) << "path=cmd=" << path;
202 } else {
203 if (url.isLocalFile()) {
204 qCDebug(category) << "hasRef=" << url.hasFragment();
205 // Split path from ref/query
206 // but not for "/tmp/a#b", if "a#b" is an existing file,
207 // or for "/tmp/a?b" (#58990)
208 if ((url.hasFragment() || !url.query().isEmpty()) && !url.path().endsWith(c: QLatin1Char('/'))) { // /tmp/?foo is a namefilter, not a query
209 path = url.path();
210 ref = url.fragment();
211 qCDebug(category) << "isLocalFile set path to" << path << "and ref to" << ref;
212 query = url.query();
213 if (path.isEmpty() && !url.host().isEmpty()) {
214 path = QStringLiteral("/");
215 }
216 } else {
217 if (cmd.startsWith(s: QLatin1String("file://"))) {
218 path = cmd.mid(position: strlen(s: "file://"));
219 } else {
220 path = cmd;
221 }
222 qCDebug(category) << "(2) path=cmd=" << path;
223 }
224 }
225 }
226
227 if (path.startsWith(c: QLatin1Char('~'))) {
228 int slashPos = path.indexOf(c: QLatin1Char('/'));
229 if (slashPos == -1) {
230 slashPos = path.length();
231 }
232 if (slashPos == 1) { // ~/
233 path.replace(i: 0, len: 1, after: QDir::homePath());
234 } else { // ~username/
235 const QString userName(path.mid(position: 1, n: slashPos - 1));
236 KUser user(userName);
237 if (user.isValid() && !user.homeDir().isEmpty()) {
238 path.replace(i: 0, len: slashPos, after: user.homeDir());
239 } else {
240 if (user.isValid()) {
241 setErrorMsg(data, i18n("<qt><b>%1</b> does not have a home folder.</qt>", userName));
242 } else {
243 setErrorMsg(data, i18n("<qt>There is no user called <b>%1</b>.</qt>", userName));
244 }
245 setUriType(data, type: KUriFilterData::Error);
246 // Always return true for error conditions so
247 // that other filters will not be invoked !!
248 return true;
249 }
250 }
251 expanded = true;
252 } else if (path.startsWith(c: QLatin1Char('$'))) {
253 // Environment variable expansion.
254 static const QRegularExpression envVarExp(QStringLiteral("\\$[a-zA-Z_][a-zA-Z0-9_]*"));
255 const auto match = envVarExp.match(subject: path);
256 if (match.hasMatch()) {
257 const QByteArray exp = qgetenv(varName: QStringView(path).mid(pos: 1, n: match.capturedLength(nth: 0) - 1).toLocal8Bit().constData());
258 if (!exp.isEmpty()) {
259 path.replace(i: 0, len: match.capturedLength(nth: 0), after: QFile::decodeName(localFileName: exp));
260 expanded = true;
261 }
262 }
263 }
264
265 if (expanded || cmd.startsWith(c: QLatin1Char('/'))) {
266 // Look for #ref again, after $ and ~ expansion (testcase: $QTDIR/doc/html/functions.html#s)
267 // Can't use QUrl here, setPath would escape it...
268 const int pos = path.indexOf(c: QLatin1Char('#'));
269 if (pos > -1) {
270 const QString newPath = path.left(n: pos);
271 if (QFile::exists(fileName: newPath)) {
272 ref = path.mid(position: pos + 1);
273 path = newPath;
274 qCDebug(category) << "Extracted ref: path=" << path << " ref=" << ref;
275 }
276 }
277 }
278
279 bool isLocalFullPath = Utils::isAbsoluteLocalPath(path);
280
281 // Checking for local resource match...
282 // Determine if "uri" is an absolute path to a local resource OR
283 // A local resource with a supplied absolute path in KUriFilterData
284 const QString abs_path = data.absolutePath();
285
286 const bool canBeAbsolute = (protocol.isEmpty() && !abs_path.isEmpty());
287 const bool canBeLocalAbsolute = (canBeAbsolute && abs_path.startsWith(c: QLatin1Char('/')) && !isMalformed);
288 bool exists = false;
289
290 /*qCDebug(category) << "abs_path=" << abs_path
291 << "protocol=" << protocol
292 << "canBeAbsolute=" << canBeAbsolute
293 << "canBeLocalAbsolute=" << canBeLocalAbsolute
294 << "isLocalFullPath=" << isLocalFullPath;*/
295
296 QT_STATBUF buff;
297 if (canBeLocalAbsolute) {
298 QString abs = QDir::cleanPath(path: abs_path);
299 // combine absolute path (abs_path) and relative path (cmd) into abs_path
300 int len = path.length();
301 if ((len == 1 && path[0] == QLatin1Char('.')) || (len == 2 && path[0] == QLatin1Char('.') && path[1] == QLatin1Char('.'))) {
302 path += QLatin1Char('/');
303 }
304 qCDebug(category) << "adding " << abs << " and " << path;
305 abs = QDir::cleanPath(path: abs + QLatin1Char('/') + path);
306 qCDebug(category) << "checking whether " << abs << " exists.";
307 // Check if it exists
308 if (QT_STAT(file: QFile::encodeName(fileName: abs).constData(), buf: &buff) == 0) {
309 path = abs; // yes -> store as the new cmd
310 exists = true;
311 isLocalFullPath = true;
312 }
313 }
314
315 if (isLocalFullPath && !exists && !isMalformed) {
316 exists = QT_STAT(file: QFile::encodeName(fileName: path).constData(), buf: &buff) == 0;
317
318 if (!exists) {
319 // Support for name filter (/foo/*.txt), see also KonqMainWindow::detectNameFilter
320 // If the app using this filter doesn't support it, well, it'll simply error out itself
321 int lastSlash = path.lastIndexOf(c: QLatin1Char('/'));
322 if (lastSlash > -1
323 && path.indexOf(c: QLatin1Char(' '), from: lastSlash) == -1) { // no space after last slash, otherwise it's more likely command-line arguments
324 QString fileName = path.mid(position: lastSlash + 1);
325 QString testPath = path.left(n: lastSlash);
326 if ((fileName.indexOf(c: QLatin1Char('*')) != -1 || fileName.indexOf(c: QLatin1Char('[')) != -1 || fileName.indexOf(c: QLatin1Char('?')) != -1)
327 && QT_STAT(file: QFile::encodeName(fileName: testPath).constData(), buf: &buff) == 0) {
328 nameFilter = fileName;
329 qCDebug(category) << "Setting nameFilter to" << nameFilter << "and path to" << testPath;
330 path = testPath;
331 exists = true;
332 }
333 }
334 }
335 }
336
337 qCDebug(category) << "path =" << path << " isLocalFullPath=" << isLocalFullPath << " exists=" << exists << " url=" << url;
338 if (exists) {
339 QUrl u = QUrl::fromLocalFile(localfile: path);
340 qCDebug(category) << "ref=" << ref << "query=" << query;
341 u.setFragment(fragment: ref);
342 u.setQuery(query);
343
344 if (!KUrlAuthorized::authorizeUrlAction(QStringLiteral("open"), baseUrl: QUrl(), destUrl: u)) {
345 // No authorization, we pretend it's a file will get
346 // an access denied error later on.
347 setFilteredUri(data, uri: u);
348 setUriType(data, type: KUriFilterData::LocalFile);
349 return true;
350 }
351
352 // Can be abs path to file or directory, or to executable with args
353 bool isDir = Utils::isDirMask(mode: buff.st_mode);
354 if (!isDir && access(name: QFile::encodeName(fileName: path).data(), X_OK) == 0) {
355 qCDebug(category) << "Abs path to EXECUTABLE";
356 setFilteredUri(data, uri: u);
357 setUriType(data, type: KUriFilterData::Executable);
358 return true;
359 }
360
361 // Open "uri" as file:/xxx if it is a non-executable local resource.
362 if (isDir || Utils::isRegFileMask(mode: buff.st_mode)) {
363 qCDebug(category) << "Abs path as local file or directory";
364 if (!nameFilter.isEmpty()) {
365 u.setPath(path: Utils::concatPaths(path1: u.path(), path2: nameFilter));
366 }
367 setFilteredUri(data, uri: u);
368 setUriType(data, type: (isDir) ? KUriFilterData::LocalDir : KUriFilterData::LocalFile);
369 return true;
370 }
371
372 // Should we return LOCAL_FILE for non-regular files too?
373 qCDebug(category) << "File found, but not a regular file nor dir... socket?";
374 }
375
376 if (data.checkForExecutables()) {
377 // Let us deal with possible relative URLs to see
378 // if it is executable under the user's $PATH variable.
379 // We try hard to avoid parsing any possible command
380 // line arguments or options that might have been supplied.
381 QString exe = removeArgs(cmd: cmd);
382 qCDebug(category) << "findExe with" << exe;
383
384 if (!QStandardPaths::findExecutable(executableName: exe).isNull()) {
385 qCDebug(category) << "EXECUTABLE exe=" << exe;
386 setFilteredUri(data, uri: QUrl::fromLocalFile(localfile: exe));
387 // check if we have command line arguments
388 if (exe != cmd) {
389 setArguments(data, args: cmd.right(n: cmd.length() - exe.length()));
390 }
391 setUriType(data, type: KUriFilterData::Executable);
392 return true;
393 }
394 }
395
396 // Process URLs of known and supported protocols so we don't have
397 // to resort to the pattern matching scheme below which can possibly
398 // slow things down...
399 if (!isMalformed && !isLocalFullPath && !protocol.isEmpty()) {
400 qCDebug(category) << "looking for protocol" << protocol;
401 if (isKnownProtocol(protocol)) {
402 setFilteredUri(data, uri: url);
403 if (protocol == QLatin1String("man") || protocol == QLatin1String("help")) {
404 setUriType(data, type: KUriFilterData::Help);
405 } else {
406 setUriType(data, type: KUriFilterData::NetProtocol);
407 }
408 return true;
409 }
410 }
411
412 // Short url matches
413 if (!cmd.contains(c: QLatin1Char(' '))) {
414 // Okay this is the code that allows users to supply custom matches for specific
415 // URLs using Qt's QRegularExpression class.
416 for (const URLHint &hint : std::as_const(t: m_urlHints)) {
417 qCDebug(category) << "testing regexp for" << hint.prepend;
418 if (hint.hintRe.match(subject: cmd).capturedStart() == 0) {
419 const QString cmdStr = hint.prepend + cmd;
420 QUrl cmdUrl(cmdStr);
421 qCDebug(category) << "match - prepending" << hint.prepend << "->" << cmdStr << "->" << cmdUrl;
422 setFilteredUri(data, uri: cmdUrl);
423 setUriType(data, type: hint.type);
424 return true;
425 }
426 }
427
428 // No protocol and not malformed means a valid short URL such as kde.org or
429 // user@192.168.0.1. However, it might also be valid only because it lacks
430 // the scheme component, e.g. www.kde,org (illegal ',' before 'org'). The
431 // check below properly deciphers the difference between the two and sends
432 // back the proper result.
433 if (protocol.isEmpty() && isPotentialShortURL(cmd)) {
434 QString urlStr = data.defaultUrlScheme();
435 if (urlStr.isEmpty()) {
436 urlStr = m_strDefaultUrlScheme;
437 }
438
439 const int index = urlStr.indexOf(c: QLatin1Char(':'));
440 if (index == -1 || !isKnownProtocol(protocol: urlStr.left(n: index))) {
441 urlStr += QStringLiteral("://");
442 }
443 urlStr += cmd;
444
445 QUrl fixedUrl(urlStr);
446 if (fixedUrl.isValid()) {
447 setFilteredUri(data, uri: fixedUrl);
448 setUriType(data, type: KUriFilterData::NetProtocol);
449 } else if (isKnownProtocol(protocol: fixedUrl.scheme())) {
450 setFilteredUri(data, uri: data.uri());
451 setUriType(data, type: KUriFilterData::Error);
452 }
453 return true;
454 }
455 }
456
457 // If we previously determined that the URL might be a file,
458 // and if it doesn't exist... we'll pretend it exists.
459 // This allows to use it for completion purposes.
460 // (If you change this logic again, look at the commit that was testing
461 // for KUrlAuthorized::authorizeUrlAction("open"))
462 if (isLocalFullPath && !exists) {
463 QUrl u = QUrl::fromLocalFile(localfile: path);
464 u.setFragment(fragment: ref);
465 setFilteredUri(data, uri: u);
466 setUriType(data, type: KUriFilterData::LocalFile);
467 return true;
468 }
469
470 // If we reach this point, we cannot filter this thing so simply return false
471 // so that other filters, if present, can take a crack at it.
472 return false;
473}
474
475void KShortUriFilter::configure()
476{
477 KConfig config(objectName() + QStringLiteral("rc"), KConfig::NoGlobals);
478 KConfigGroup cg(config.group(group: QString()));
479
480 m_strDefaultUrlScheme = cg.readEntry(key: "DefaultProtocol", QStringLiteral("https://"));
481 const QMap<QString, QString> patterns = config.entryMap(QStringLiteral("Pattern"));
482 const QMap<QString, QString> protocols = config.entryMap(QStringLiteral("Protocol"));
483 KConfigGroup typeGroup(&config, QStringLiteral("Type"));
484
485 for (auto it = patterns.begin(); it != patterns.end(); ++it) {
486 QString protocol = protocols[it.key()];
487 if (!protocol.isEmpty()) {
488 int type = typeGroup.readEntry(key: it.key(), aDefault: -1);
489 if (type > -1 && type <= KUriFilterData::Unknown) {
490 m_urlHints.append(t: URLHint(it.value(), protocol, static_cast<KUriFilterData::UriTypes>(type)));
491 } else {
492 m_urlHints.append(t: URLHint(it.value(), protocol));
493 }
494 }
495 }
496}
497
498K_PLUGIN_CLASS_WITH_JSON(KShortUriFilter, "kshorturifilter.json")
499
500#include "kshorturifilter.moc"
501
502#include "moc_kshorturifilter.cpp"
503

source code of kio/src/urifilters/shorturi/kshorturifilter.cpp