mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2026-08-07 04:05:17 +00:00
335 lines
8.7 KiB
C++
335 lines
8.7 KiB
C++
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#include "iv/markdown/iv_markdown_prepare_links.h"
|
|
#include "ui/basic_click_handlers.h"
|
|
|
|
#include <QtCore/QDir>
|
|
#include <QtCore/QFileInfo>
|
|
#include <QtCore/QUrl>
|
|
|
|
#include <algorithm>
|
|
|
|
namespace Iv::Markdown {
|
|
namespace {
|
|
|
|
[[nodiscard]] bool HasUrlScheme(const QString &target) {
|
|
if (target.isEmpty()) {
|
|
return false;
|
|
}
|
|
const auto colon = target.indexOf(QChar(':'));
|
|
if (colon <= 0) {
|
|
return false;
|
|
}
|
|
const auto slash = target.indexOf(QChar('/'));
|
|
const auto question = target.indexOf(QChar('?'));
|
|
const auto hash = target.indexOf(QChar('#'));
|
|
auto limit = target.size();
|
|
for (const auto value : { slash, question, hash }) {
|
|
if (value >= 0) {
|
|
limit = std::min(limit, value);
|
|
}
|
|
}
|
|
if (colon >= limit) {
|
|
return false;
|
|
}
|
|
if (!target[0].isLetter()) {
|
|
return false;
|
|
}
|
|
for (auto i = 1; i != colon; ++i) {
|
|
const auto ch = target[i];
|
|
if (!ch.isLetterOrNumber() && ch != QChar('+') && ch != QChar('-')
|
|
&& ch != QChar('.')) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
[[nodiscard]] bool LooksLikeWindowsDrivePath(const QString &target) {
|
|
return target.size() >= 2
|
|
&& target[0].isLetter()
|
|
&& target[1] == QChar(':');
|
|
}
|
|
|
|
[[nodiscard]] bool LooksLikeFileUrl(const QString &target) {
|
|
return target.size() >= 5
|
|
&& target.left(5).compare(u"file:"_q, Qt::CaseInsensitive) == 0;
|
|
}
|
|
|
|
[[nodiscard]] bool LooksLikeFilesystemTarget(const QString &target) {
|
|
return target.startsWith(u"/"_q)
|
|
|| target.startsWith(u"\\"_q)
|
|
|| target.startsWith(u"//"_q)
|
|
|| target.startsWith(u"\\\\"_q)
|
|
|| LooksLikeWindowsDrivePath(target)
|
|
|| LooksLikeFileUrl(target);
|
|
}
|
|
|
|
[[nodiscard]] QString ComparablePath(QString path) {
|
|
path = QDir::fromNativeSeparators(QDir::cleanPath(path));
|
|
return path.toLower();
|
|
}
|
|
|
|
[[nodiscard]] bool IsContainedPath(
|
|
const QString &baseDirectory,
|
|
const QString &resolvedPath) {
|
|
const auto base = ComparablePath(baseDirectory);
|
|
const auto resolved = ComparablePath(resolvedPath);
|
|
return (resolved == base) || resolved.startsWith(base + u"/"_q);
|
|
}
|
|
|
|
[[nodiscard]] bool IsEscapableAscii(char ch) {
|
|
const auto value = uchar(ch);
|
|
return (value >= 0x21 && value <= 0x2F)
|
|
|| (value >= 0x3A && value <= 0x40)
|
|
|| (value >= 0x5B && value <= 0x60)
|
|
|| (value >= 0x7B && value <= 0x7E);
|
|
}
|
|
|
|
[[nodiscard]] bool AppendHtmlEntityText(
|
|
const QByteArray &entity,
|
|
QString *result) {
|
|
if (entity == "amp") {
|
|
result->append(QChar('&'));
|
|
} else if (entity == "lt") {
|
|
result->append(QChar('<'));
|
|
} else if (entity == "gt") {
|
|
result->append(QChar('>'));
|
|
} else if (entity == "quot") {
|
|
result->append(QChar('"'));
|
|
} else if (entity == "apos") {
|
|
result->append(QChar('\''));
|
|
} else if (entity.startsWith("#x") || entity.startsWith("#X")) {
|
|
auto ok = false;
|
|
const auto value = entity.mid(2).toUInt(&ok, 16);
|
|
if (!ok || value > 0xFFFF) {
|
|
return false;
|
|
}
|
|
result->append(QChar(ushort(value)));
|
|
} else if (entity.startsWith("#")) {
|
|
auto ok = false;
|
|
const auto value = entity.mid(1).toUInt(&ok, 10);
|
|
if (!ok || value > 0xFFFF) {
|
|
return false;
|
|
}
|
|
result->append(QChar(ushort(value)));
|
|
} else {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
[[nodiscard]] QString NormalizeMailtoTarget(const QString &target) {
|
|
const auto parsed = QUrl(target);
|
|
auto result = parsed.path(QUrl::FullyDecoded);
|
|
if (result.isEmpty()) {
|
|
result = target.mid(target.indexOf(QChar(':')) + 1);
|
|
const auto question = result.indexOf(QChar('?'));
|
|
if (question >= 0) {
|
|
result = result.left(question);
|
|
}
|
|
result = QString::fromUtf8(
|
|
QByteArray::fromPercentEncoding(result.toUtf8()));
|
|
}
|
|
while (result.startsWith(u"/"_q)) {
|
|
result.remove(0, 1);
|
|
}
|
|
return result.trimmed();
|
|
}
|
|
|
|
[[nodiscard]] QString ExternalLinkDisplayText(const PreparedLink &link) {
|
|
if (link.entityType == EntityType::Email) {
|
|
return link.target;
|
|
}
|
|
const auto original = QUrl(link.target);
|
|
const auto good = QUrl(original.isValid()
|
|
? original.toEncoded()
|
|
: QString());
|
|
return good.isValid() ? good.toDisplayString() : link.target;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void NormalizePreparedUrlLink(PreparedLink *result, const QString &target) {
|
|
if (!result) {
|
|
return;
|
|
}
|
|
result->fragment = QString();
|
|
if (target.startsWith(u"mailto:"_q, Qt::CaseInsensitive)) {
|
|
result->target = NormalizeMailtoTarget(target);
|
|
result->copyText = result->target;
|
|
result->entityType = EntityType::Email;
|
|
result->shown = EntityLinkShown::Full;
|
|
return;
|
|
}
|
|
result->target = UrlClickHandler::EncodeForOpening(target);
|
|
result->copyText = result->target;
|
|
result->entityType = EntityType::Url;
|
|
result->shown = EntityLinkShown::Full;
|
|
}
|
|
|
|
void FinalizePreparedUrlLink(
|
|
PreparedLink *link,
|
|
QStringView renderedText) {
|
|
if (!link || link->entityType != EntityType::Url) {
|
|
return;
|
|
}
|
|
if (renderedText == QStringView(ExternalLinkDisplayText(*link))) {
|
|
return;
|
|
}
|
|
if (UrlClickHandler::EncodeForOpening(renderedText.toString())
|
|
== link->target) {
|
|
link->shown = EntityLinkShown::Partial;
|
|
return;
|
|
}
|
|
link->entityType = EntityType::CustomUrl;
|
|
}
|
|
|
|
QString InternalLinkData(uint16 index) {
|
|
return u"internal:index"_q + QChar(index);
|
|
}
|
|
|
|
QString NormalizeFragmentId(QString fragment) {
|
|
fragment = QString::fromUtf8(
|
|
QByteArray::fromPercentEncoding(fragment.toUtf8()));
|
|
fragment = fragment.trimmed().toLower();
|
|
while (fragment.startsWith(u"#"_q)) {
|
|
fragment.remove(0, 1);
|
|
}
|
|
return fragment;
|
|
}
|
|
|
|
PreparedLink ClassifiedLink(
|
|
uint16 index,
|
|
QString target,
|
|
const PrepareState *state) {
|
|
auto result = PreparedLink();
|
|
result.index = index;
|
|
if (target.startsWith(QChar('#'))) {
|
|
result.kind = PreparedLinkKind::Anchor;
|
|
result.target = NormalizeFragmentId(target.mid(1));
|
|
return result;
|
|
}
|
|
if (LooksLikeFilesystemTarget(target)) {
|
|
result.kind = PreparedLinkKind::RejectedRelative;
|
|
return result;
|
|
}
|
|
if (HasUrlScheme(target)) {
|
|
result.kind = PreparedLinkKind::External;
|
|
NormalizePreparedUrlLink(&result, target);
|
|
return result;
|
|
}
|
|
|
|
auto fragmentIndex = target.indexOf(QChar('#'));
|
|
if (fragmentIndex >= 0) {
|
|
result.fragment = NormalizeFragmentId(target.mid(fragmentIndex + 1));
|
|
target = target.left(fragmentIndex);
|
|
}
|
|
result.target = target;
|
|
|
|
if (target.isEmpty()) {
|
|
result.kind = PreparedLinkKind::Anchor;
|
|
result.target = result.fragment;
|
|
result.fragment = QString();
|
|
return result;
|
|
}
|
|
if (!state
|
|
|| !state->request
|
|
|| state->request->sourcePath.isEmpty()) {
|
|
result.kind = PreparedLinkKind::RejectedRelative;
|
|
return result;
|
|
}
|
|
if (target.contains(QChar('?'))) {
|
|
result.kind = PreparedLinkKind::RejectedRelative;
|
|
return result;
|
|
}
|
|
const auto baseDirectory = QFileInfo(state->request->sourcePath).absolutePath();
|
|
if (baseDirectory.isEmpty()) {
|
|
result.kind = PreparedLinkKind::RejectedRelative;
|
|
return result;
|
|
}
|
|
const auto resolved = QDir(baseDirectory).absoluteFilePath(target);
|
|
const auto cleanedResolved = QDir::cleanPath(resolved);
|
|
if (!IsContainedPath(baseDirectory, cleanedResolved)
|
|
|| !LooksLikeMarkdownFile(cleanedResolved)) {
|
|
result.kind = PreparedLinkKind::RejectedRelative;
|
|
return result;
|
|
}
|
|
result.kind = PreparedLinkKind::LocalFile;
|
|
result.target = cleanedResolved;
|
|
return result;
|
|
}
|
|
|
|
QString FirstInfoToken(const QString &info) {
|
|
const auto trimmed = info.trimmed();
|
|
for (auto i = 0; i != trimmed.size(); ++i) {
|
|
if (trimmed[i].isSpace()) {
|
|
return trimmed.left(i);
|
|
}
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
void SortEntities(TextWithEntities *text) {
|
|
auto &entities = text->entities;
|
|
std::sort(
|
|
entities.begin(),
|
|
entities.end(),
|
|
[](const EntityInText &left, const EntityInText &right) {
|
|
if (left.offset() != right.offset()) {
|
|
return left.offset() < right.offset();
|
|
} else if (left.length() != right.length()) {
|
|
return left.length() > right.length();
|
|
}
|
|
return int(left.type()) < int(right.type());
|
|
});
|
|
}
|
|
|
|
QString DecodeMarkdownTextPrefix(QByteArray bytes) {
|
|
auto result = QString();
|
|
auto plainFrom = 0;
|
|
const auto flushPlain = [&](int till) {
|
|
if (till > plainFrom) {
|
|
result.append(QString::fromUtf8(
|
|
bytes.constData() + plainFrom,
|
|
till - plainFrom));
|
|
}
|
|
};
|
|
for (auto i = 0; i != bytes.size();) {
|
|
if (bytes[i] == '\\'
|
|
&& (i + 1) < bytes.size()
|
|
&& IsEscapableAscii(bytes[i + 1])) {
|
|
flushPlain(i);
|
|
result.append(QChar(ushort(uchar(bytes[i + 1]))));
|
|
i += 2;
|
|
plainFrom = i;
|
|
} else if (bytes[i] == '&') {
|
|
const auto semicolon = bytes.indexOf(';', i + 1);
|
|
if (semicolon > i && semicolon - i <= 32) {
|
|
auto entityText = QString();
|
|
if (AppendHtmlEntityText(
|
|
bytes.mid(i + 1, semicolon - i - 1),
|
|
&entityText)) {
|
|
flushPlain(i);
|
|
result.append(entityText);
|
|
i = semicolon + 1;
|
|
plainFrom = i;
|
|
continue;
|
|
}
|
|
}
|
|
++i;
|
|
} else {
|
|
++i;
|
|
}
|
|
}
|
|
flushPlain(bytes.size());
|
|
return result;
|
|
}
|
|
|
|
} // namespace Iv::Markdown
|