From edab0faa94e14d3ce828992df6a83f40bcd4d14b Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Sun, 25 Aug 2024 08:17:16 -0400 Subject: [PATCH 1/6] Add Proton Pass importer * Closes #10465 --- COPYING | 1 + .../application/scalable/actions/proton.svg | 1 + share/icons/icons.qrc | 1 + share/translations/keepassxc_en.ts | 24 +- src/CMakeLists.txt | 1 + src/format/ProtonPassReader.cpp | 221 ++++++++++++++++++ src/format/ProtonPassReader.h | 43 ++++ src/gui/DatabaseTabWidget.cpp | 3 + src/gui/wizard/ImportWizard.h | 1 + src/gui/wizard/ImportWizardPageReview.cpp | 41 +++- src/gui/wizard/ImportWizardPageReview.h | 4 +- src/gui/wizard/ImportWizardPageSelect.cpp | 9 +- tests/TestImports.cpp | 56 +++++ tests/TestImports.h | 1 + tests/data/protonpass_export.json | 173 ++++++++++++++ tests/gui/TestGui.cpp | 45 ++-- 16 files changed, 587 insertions(+), 38 deletions(-) create mode 100644 share/icons/application/scalable/actions/proton.svg create mode 100644 src/format/ProtonPassReader.cpp create mode 100644 src/format/ProtonPassReader.h create mode 100644 tests/data/protonpass_export.json diff --git a/COPYING b/COPYING index 9a801fe901..ed03b77f82 100644 --- a/COPYING +++ b/COPYING @@ -137,6 +137,7 @@ Files: share/icons/badges/2_Expired.svg share/icons/database/C46_Help.svg share/icons/database/C53_Apply.svg share/icons/database/C61_Services.svg + share/icons/application/scalable/actions/proton.svg Copyright: 2022 KeePassXC Team License: MIT diff --git a/share/icons/application/scalable/actions/proton.svg b/share/icons/application/scalable/actions/proton.svg new file mode 100644 index 0000000000..89515ddec9 --- /dev/null +++ b/share/icons/application/scalable/actions/proton.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/share/icons/icons.qrc b/share/icons/icons.qrc index 87b8b05b8f..a5f86b28d1 100644 --- a/share/icons/icons.qrc +++ b/share/icons/icons.qrc @@ -72,6 +72,7 @@ application/scalable/actions/password-generator.svg application/scalable/actions/password-show-off.svg application/scalable/actions/password-show-on.svg + application/scalable/actions/proton.svg application/scalable/actions/qrcode.svg application/scalable/actions/refresh.svg application/scalable/actions/remote-sync.svg diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index f0b442bb07..5d52cc5e69 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -4687,6 +4687,14 @@ You can enable the DuckDuckGo website icon service in the security section of th KeePass1 Database + + Proton Pass (.json) + + + + Proton Pass JSON Export + + Temporary Database @@ -4703,10 +4711,6 @@ You can enable the DuckDuckGo website icon service in the security section of th Input: - - Remote Database (.kdbx) - - e.g.: get DatabaseOnRemote.kdbx {TEMP_DATABASE} @@ -4717,6 +4721,10 @@ The command has to exit. In case of `sftp` as last commend `exit` has to be sent + + Remote Database (.kdbx) + + KMessageWidget @@ -9058,6 +9066,14 @@ This option is deprecated, use --set-key-file instead. Cannot generate valid passphrases because the wordlist is too short + + Encrypted files are not supported. + + + + Proton Pass Import + + Delete plugin data? diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ee83fac327..5c7326b5c5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -90,6 +90,7 @@ set(core_SOURCES format/OpVaultReaderAttachments.cpp format/OpVaultReaderBandEntry.cpp format/OpVaultReaderSections.cpp + format/ProtonPassReader.cpp keys/CompositeKey.cpp keys/FileKey.cpp keys/PasswordKey.cpp diff --git a/src/format/ProtonPassReader.cpp b/src/format/ProtonPassReader.cpp new file mode 100644 index 0000000000..105adde79f --- /dev/null +++ b/src/format/ProtonPassReader.cpp @@ -0,0 +1,221 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "ProtonPassReader.h" + +#include "core/Database.h" +#include "core/Entry.h" +#include "core/Group.h" +#include "core/Metadata.h" +#include "core/Tools.h" +#include "core/Totp.h" +#include "crypto/CryptoHash.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + Entry* readItem(const QJsonObject& item) + { + const auto itemMap = item.toVariantMap(); + const auto dataMap = itemMap.value("data").toMap(); + const auto metadataMap = dataMap.value("metadata").toMap(); + + // Create entry and assign basic values + QScopedPointer entry(new Entry()); + entry->setUuid(QUuid::createUuid()); + entry->setTitle(metadataMap.value("name").toString()); + entry->setNotes(metadataMap.value("note").toString()); + + if (itemMap.value("pinned").toBool()) { + entry->addTag(QObject::tr("Favorite", "Tag for favorite entries")); + } + + // Handle specific item types + auto type = dataMap.value("type").toString(); + + // Login + if (type.compare("login", Qt::CaseInsensitive) == 0) { + const auto loginMap = dataMap.value("content").toMap(); + entry->setUsername(loginMap.value("itemUsername").toString()); + entry->setPassword(loginMap.value("password").toString()); + if (loginMap.contains("totpUri")) { + auto totp = loginMap.value("totpUri").toString(); + if (!totp.startsWith("otpauth://")) { + QUrl url(QString("otpauth://totp/%1:%2?secret=%3") + .arg(QString(QUrl::toPercentEncoding(entry->title())), + QString(QUrl::toPercentEncoding(entry->username())), + QString(QUrl::toPercentEncoding(totp)))); + totp = url.toString(QUrl::FullyEncoded); + } + entry->setTotp(Totp::parseSettings(totp)); + } + + if (loginMap.contains("itemEmail")) { + entry->attributes()->set("login_email", loginMap.value("itemEmail").toString()); + } + + // Set the entry url(s) + int i = 1; + for (const auto& urlObj : loginMap.value("urls").toList()) { + const auto url = urlObj.toString(); + if (entry->url().isEmpty()) { + // First url encountered is set as the primary url + entry->setUrl(url); + } else { + // Subsequent urls + entry->attributes()->set( + QString("%1_%2").arg(EntryAttributes::AdditionalUrlAttribute, QString::number(i)), url); + ++i; + } + } + } + // Credit Card + else if (type.compare("creditCard", Qt::CaseInsensitive) == 0) { + const auto cardMap = dataMap.value("content").toMap(); + entry->setUsername(cardMap.value("number").toString()); + entry->setPassword(cardMap.value("verificationNumber").toString()); + const QStringList attrs({"cardholderName", "pin", "expirationDate"}); + const QStringList sensitive({"pin"}); + for (const auto& attr : attrs) { + auto value = cardMap.value(attr).toString(); + if (!value.isEmpty()) { + entry->attributes()->set("card_" + attr, value, sensitive.contains(attr)); + } + } + } + + // Parse extra fields + for (const auto& field : dataMap.value("extraFields").toList()) { + // Derive a prefix for attribute names using the title or uuid if missing + const auto fieldMap = field.toMap(); + auto name = fieldMap.value("fieldName").toString(); + if (entry->attributes()->hasKey(name)) { + name = QString("%1_%2").arg(name, QUuid::createUuid().toString().mid(1, 5)); + } + + QString value; + const auto fieldType = fieldMap.value("type").toString(); + if (fieldType.compare("totp", Qt::CaseInsensitive) == 0) { + value = fieldMap.value("data").toJsonObject().value("totpUri").toString(); + } else { + value = fieldMap.value("data").toJsonObject().value("content").toString(); + } + + entry->attributes()->set(name, value, fieldType.compare("hidden", Qt::CaseInsensitive) == 0); + } + + // Checked expired/deleted state + if (itemMap.value("state").toInt() == 2) { + entry->setExpires(true); + entry->setExpiryTime(QDateTime::currentDateTimeUtc()); + } + + // Collapse any accumulated history + entry->removeHistoryItems(entry->historyItems()); + + // Adjust the created and modified times + auto timeInfo = entry->timeInfo(); + const auto createdTime = QDateTime::fromSecsSinceEpoch(itemMap.value("createTime").toULongLong(), Qt::UTC); + const auto modifiedTime = QDateTime::fromSecsSinceEpoch(itemMap.value("modifyTime").toULongLong(), Qt::UTC); + timeInfo.setCreationTime(createdTime); + timeInfo.setLastModificationTime(modifiedTime); + timeInfo.setLastAccessTime(modifiedTime); + entry->setTimeInfo(timeInfo); + + return entry.take(); + } + + void writeVaultToDatabase(const QJsonObject& vault, QSharedPointer db) + { + // Create groups from vaults and store a temporary map of id -> uuid + const auto vaults = vault.value("vaults").toObject().toVariantMap(); + for (const auto& vaultId : vaults.keys()) { + auto vaultObj = vaults.value(vaultId).toJsonObject(); + auto group = new Group(); + group->setUuid(QUuid::createUuid()); + group->setName(vaultObj.value("name").toString()); + group->setNotes(vaultObj.value("description").toString()); + group->setParent(db->rootGroup()); + + const auto items = vaultObj.value("items").toArray(); + for (const auto& item : items) { + auto entry = readItem(item.toObject()); + if (entry) { + entry->setGroup(group, false); + } + } + } + } +} // namespace + +bool ProtonPassReader::hasError() +{ + return !m_error.isEmpty(); +} + +QString ProtonPassReader::errorString() +{ + return m_error; +} + +QSharedPointer ProtonPassReader::convert(const QString& path) +{ + m_error.clear(); + + QFileInfo fileinfo(path); + if (!fileinfo.exists()) { + m_error = QObject::tr("File does not exist.").arg(path); + return {}; + } + + // Bitwarden uses a json file format + QFile file(fileinfo.absoluteFilePath()); + if (!file.open(QFile::ReadOnly)) { + m_error = QObject::tr("Cannot open file: %1").arg(file.errorString()); + return {}; + } + + QJsonParseError error; + auto json = QJsonDocument::fromJson(file.readAll(), &error).object(); + if (error.error != QJsonParseError::NoError) { + m_error = + QObject::tr("Cannot parse file: %1 at position %2").arg(error.errorString(), QString::number(error.offset)); + return {}; + } + + file.close(); + + if (json.value("encrypted").toBool()) { + m_error = QObject::tr("Encrypted files are not supported."); + return {}; + } + + auto db = QSharedPointer::create(); + db->rootGroup()->setName(QObject::tr("Proton Pass Import")); + + writeVaultToDatabase(json, db); + + return db; +} diff --git a/src/format/ProtonPassReader.h b/src/format/ProtonPassReader.h new file mode 100644 index 0000000000..74764b8900 --- /dev/null +++ b/src/format/ProtonPassReader.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2024 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef PROTONPASS_READER_H +#define PROTONPASS_READER_H + +#include + +class Database; + +/*! + * Imports a Proton Pass vault in JSON format: https://proton.me/support/pass-export + */ +class ProtonPassReader +{ +public: + explicit ProtonPassReader() = default; + ~ProtonPassReader() = default; + + QSharedPointer convert(const QString& path); + + bool hasError(); + QString errorString(); + +private: + QString m_error; +}; + +#endif // PROTONPASS_READER_H diff --git a/src/gui/DatabaseTabWidget.cpp b/src/gui/DatabaseTabWidget.cpp index cf4328e037..37a9a8911b 100644 --- a/src/gui/DatabaseTabWidget.cpp +++ b/src/gui/DatabaseTabWidget.cpp @@ -303,6 +303,9 @@ void DatabaseTabWidget::importFile() Merger merger(db.data(), newDb.data()); merger.setSkipDatabaseCustomData(true); merger.merge(); + // Transfer the root group data + newDb->rootGroup()->setName(db->rootGroup()->name()); + newDb->rootGroup()->setNotes(db->rootGroup()->notes()); // Show the new database auto dbWidget = new DatabaseWidget(newDb, this); addDatabaseTab(dbWidget); diff --git a/src/gui/wizard/ImportWizard.h b/src/gui/wizard/ImportWizard.h index 95bf8b9986..db27e3d5a3 100644 --- a/src/gui/wizard/ImportWizard.h +++ b/src/gui/wizard/ImportWizard.h @@ -48,6 +48,7 @@ class ImportWizard : public QWizard IMPORT_OPVAULT, IMPORT_OPUX, IMPORT_BITWARDEN, + IMPORT_PROTONPASS, IMPORT_KEEPASS1, IMPORT_REMOTE, }; diff --git a/src/gui/wizard/ImportWizardPageReview.cpp b/src/gui/wizard/ImportWizardPageReview.cpp index 55492a67fc..c7ed90fe7f 100644 --- a/src/gui/wizard/ImportWizardPageReview.cpp +++ b/src/gui/wizard/ImportWizardPageReview.cpp @@ -24,6 +24,7 @@ #include "format/KeePass1Reader.h" #include "format/OPUXReader.h" #include "format/OpVaultReader.h" +#include "format/ProtonPassReader.h" #include "gui/csvImport/CsvImportWidget.h" #include "gui/wizard/ImportWizard.h" @@ -75,34 +76,35 @@ void ImportWizardPageReview::initializePage() break; case ImportWizard::IMPORT_OPVAULT: m_db = importOPVault(filename, field("ImportPassword").toString()); - setupDatabasePreview(); break; case ImportWizard::IMPORT_OPUX: m_db = importOPUX(filename); - setupDatabasePreview(); break; case ImportWizard::IMPORT_KEEPASS1: m_db = importKeePass1(filename, field("ImportPassword").toString(), field("ImportKeyFile").toString()); - setupDatabasePreview(); break; case ImportWizard::IMPORT_BITWARDEN: m_db = importBitwarden(filename, field("ImportPassword").toString()); - setupDatabasePreview(); + break; + case ImportWizard::IMPORT_PROTONPASS: + m_db = importProtonPass(filename); break; case ImportWizard::IMPORT_REMOTE: m_db = importRemote(field("DownloadCommand").toString(), field("DownloadInput").toString(), field("ImportPassword").toString(), field("ImportKeyFile").toString()); - setupDatabasePreview(); + break; default: break; } + + setupDatabasePreview(); } bool ImportWizardPageReview::validatePage() { - if (m_csvWidget && field("ImportType").toInt() == ImportWizard::IMPORT_CSV) { + if (isCsvImport()) { m_db = m_csvWidget->buildDatabase(); } return !m_db.isNull(); @@ -124,14 +126,18 @@ void ImportWizardPageReview::setupCsvImport(const QString& filename) }); m_csvWidget->load(filename); - - // Qt does not automatically resize a QScrollWidget in a QWizard... - m_ui->scrollAreaContents->layout()->addWidget(m_csvWidget); - m_ui->scrollArea->setMinimumSize(m_csvWidget->width() + 50, m_csvWidget->height() + 100); } void ImportWizardPageReview::setupDatabasePreview() { + // CSV preview is handled by the import widget + if (isCsvImport()) { + // Qt does not automatically resize a QScrollWidget in a QWizard... + m_ui->scrollAreaContents->layout()->addWidget(m_csvWidget); + m_ui->scrollArea->setMinimumSize(m_csvWidget->width() + 50, m_csvWidget->height() + 100); + return; + } + if (!m_db) { m_ui->scrollArea->setVisible(false); return; @@ -216,6 +222,21 @@ ImportWizardPageReview::importKeePass1(const QString& filename, const QString& p return db; } +QSharedPointer ImportWizardPageReview::importProtonPass(const QString& filename) +{ + ProtonPassReader reader; + auto db = reader.convert(filename); + if (reader.hasError()) { + m_ui->messageWidget->showMessage(reader.errorString(), KMessageWidget::Error, -1); + } + return db; +} + +bool ImportWizardPageReview::isCsvImport() const +{ + return m_csvWidget && field("ImportType").toInt() == ImportWizard::IMPORT_CSV; +} + QSharedPointer ImportWizardPageReview::importRemote(const QString& downloadCommand, const QString& downloadInput, const QString& password, diff --git a/src/gui/wizard/ImportWizardPageReview.h b/src/gui/wizard/ImportWizardPageReview.h index c1df3efff4..17f85a1a5b 100644 --- a/src/gui/wizard/ImportWizardPageReview.h +++ b/src/gui/wizard/ImportWizardPageReview.h @@ -1,4 +1,4 @@ -/* +/* * Copyright (C) 2023 KeePassXC Team * * This program is free software: you can redistribute it and/or modify @@ -49,11 +49,13 @@ class ImportWizardPageReview : public QWizardPage QSharedPointer database(); private: + bool isCsvImport() const; void setupCsvImport(const QString& filename); QSharedPointer importOPUX(const QString& filename); QSharedPointer importBitwarden(const QString& filename, const QString& password); QSharedPointer importOPVault(const QString& filename, const QString& password); QSharedPointer importKeePass1(const QString& filename, const QString& password, const QString& keyfile); + QSharedPointer importProtonPass(const QString& filename); QSharedPointer importRemote(const QString& downloadCommand, const QString& downloadInput, const QString& password, diff --git a/src/gui/wizard/ImportWizardPageSelect.cpp b/src/gui/wizard/ImportWizardPageSelect.cpp index e7bdacc9f1..ba5f285216 100644 --- a/src/gui/wizard/ImportWizardPageSelect.cpp +++ b/src/gui/wizard/ImportWizardPageSelect.cpp @@ -37,15 +37,17 @@ ImportWizardPageSelect::ImportWizardPageSelect(QWidget* parent) new QListWidgetItem(icons()->icon("onepassword"), tr("1Password Export (.1pux)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("onepassword"), tr("1Password Vault (.opvault)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("bitwarden"), tr("Bitwarden (.json)"), m_ui->importTypeList); - new QListWidgetItem(icons()->icon("object-locked"), tr("KeePass 1 Database (.kdb)"), m_ui->importTypeList); + new QListWidgetItem(icons()->icon("proton"), tr("Proton Pass (.json)"), m_ui->importTypeList); new QListWidgetItem(icons()->icon("web"), tr("Remote Database (.kdbx)"), m_ui->importTypeList); + new QListWidgetItem(icons()->icon("object-locked"), tr("KeePass 1 Database (.kdb)"), m_ui->importTypeList); m_ui->importTypeList->item(0)->setData(Qt::UserRole, ImportWizard::IMPORT_CSV); m_ui->importTypeList->item(1)->setData(Qt::UserRole, ImportWizard::IMPORT_OPUX); m_ui->importTypeList->item(2)->setData(Qt::UserRole, ImportWizard::IMPORT_OPVAULT); m_ui->importTypeList->item(3)->setData(Qt::UserRole, ImportWizard::IMPORT_BITWARDEN); - m_ui->importTypeList->item(4)->setData(Qt::UserRole, ImportWizard::IMPORT_KEEPASS1); + m_ui->importTypeList->item(4)->setData(Qt::UserRole, ImportWizard::IMPORT_PROTONPASS); m_ui->importTypeList->item(5)->setData(Qt::UserRole, ImportWizard::IMPORT_REMOTE); + m_ui->importTypeList->item(6)->setData(Qt::UserRole, ImportWizard::IMPORT_KEEPASS1); connect(m_ui->importTypeList, &QListWidget::currentItemChanged, this, &ImportWizardPageSelect::itemSelected); m_ui->importTypeList->setCurrentRow(0); @@ -132,6 +134,7 @@ void ImportWizardPageSelect::itemSelected(QListWidgetItem* current, QListWidgetI // Unencrypted types case ImportWizard::IMPORT_CSV: case ImportWizard::IMPORT_OPUX: + case ImportWizard::IMPORT_PROTONPASS: setCredentialState(false); setDownloadCommand(false); break; @@ -299,6 +302,8 @@ QString ImportWizardPageSelect::importFileFilter() return QString("%1 (*.1pux)").arg(tr("1Password Export")); case ImportWizard::IMPORT_BITWARDEN: return QString("%1 (*.json)").arg(tr("Bitwarden JSON Export")); + case ImportWizard::IMPORT_PROTONPASS: + return QString("%1 (*.json)").arg(tr("Proton Pass JSON Export")); case ImportWizard::IMPORT_OPVAULT: return QString("%1 (*.opvault)").arg(tr("1Password Vault")); case ImportWizard::IMPORT_KEEPASS1: diff --git a/tests/TestImports.cpp b/tests/TestImports.cpp index c34b9190fd..17ec2bef53 100644 --- a/tests/TestImports.cpp +++ b/tests/TestImports.cpp @@ -25,6 +25,7 @@ #include "format/BitwardenReader.h" #include "format/OPUXReader.h" #include "format/OpVaultReader.h" +#include "format/ProtonPassReader.h" #include #include @@ -315,3 +316,58 @@ void TestImports::testBitwardenPasskey() QCOMPARE(attr->value(EntryAttributes::KPEX_PASSKEY_USER_HANDLE), QStringLiteral("aTFtdmFnOHYtS2dxVEJ0by1rSFpLWGg0enlTVC1iUVJReDZ5czJXa3c2aw")); } + +void TestImports::testProtonPass() +{ + auto protonPassPath = + QStringLiteral("%1/%2").arg(KEEPASSX_TEST_DATA_DIR, QStringLiteral("/protonpass_export.json")); + + ProtonPassReader reader; + auto db = reader.convert(protonPassPath); + QVERIFY2(!reader.hasError(), qPrintable(reader.errorString())); + QVERIFY(db); + + // Confirm Login fields + auto entry = db->rootGroup()->findEntryByPath("/Personal/Test Login"); + QVERIFY(entry); + QCOMPARE(entry->title(), QStringLiteral("Test Login")); + QCOMPARE(entry->username(), QStringLiteral("Username")); + QCOMPARE(entry->password(), QStringLiteral("Password")); + QCOMPARE(entry->url(), QStringLiteral("https://example.com/")); + QCOMPARE(entry->notes(), QStringLiteral("My login secure note.")); + // Check extra URL's + QCOMPARE(entry->attribute("KP2A_URL_1"), QStringLiteral("https://example2.com/")); + // Check TOTP + QVERIFY(entry->hasTotp()); + // Check attributes + auto attr = entry->attributes(); + QVERIFY(attr->isProtected("hidden field")); + QCOMPARE(attr->value("second 2fa secret"), QStringLiteral("TOTPCODE")); + // NOTE: Proton Pass does not export attachments + // NOTE: Proton Pass does not export expiration dates + + // Confirm Secure Note + entry = db->rootGroup()->findEntryByPath("/Personal/My Secure Note"); + QVERIFY(entry); + QCOMPARE(entry->notes(), QStringLiteral("Secure note contents.")); + + // Confirm Credit Card + entry = db->rootGroup()->findEntryByPath("/Personal/Test Card"); + QVERIFY(entry); + QCOMPARE(entry->username(), QStringLiteral("1234222233334444")); + QCOMPARE(entry->password(), QStringLiteral("333")); + attr = entry->attributes(); + QCOMPARE(attr->value("card_cardholderName"), QStringLiteral("Test name")); + QCOMPARE(attr->value("card_expirationDate"), QStringLiteral("2025-01")); + QCOMPARE(attr->value("card_pin"), QStringLiteral("1234")); + QVERIFY(attr->isProtected("card_pin")); + + // Confirm Expired (deleted) entry + entry = db->rootGroup()->findEntryByPath("/Personal/My Deleted Note"); + QVERIFY(entry); + QTRY_VERIFY(entry->isExpired()); + + // Confirm second group (vault) + entry = db->rootGroup()->findEntryByPath("/Test/Other vault login"); + QVERIFY(entry); +} diff --git a/tests/TestImports.h b/tests/TestImports.h index ece40d539d..728fa63775 100644 --- a/tests/TestImports.h +++ b/tests/TestImports.h @@ -31,6 +31,7 @@ private slots: void testBitwarden(); void testBitwardenEncrypted(); void testBitwardenPasskey(); + void testProtonPass(); }; #endif /* TEST_IMPORTS_H */ diff --git a/tests/data/protonpass_export.json b/tests/data/protonpass_export.json new file mode 100644 index 0000000000..ef82352180 --- /dev/null +++ b/tests/data/protonpass_export.json @@ -0,0 +1,173 @@ +{ + "version": "1.21.2", + "userId": "USER_ID", + "encrypted": false, + "vaults": { + "VAULT_A": { + "name": "Personal", + "description": "Personal vault", + "display": { + "color": 0, + "icon": 0 + }, + "items": [ + { + "itemId": "yZENmDjtmZGODNy3Q_CZiPAF_IgINq8w-R-qazrOh-Nt9YJeVF3gu07ovzDS4jhYHoMdOebTw5JkYPGgIL1mwQ==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "Test Login", + "note": "My login secure note.", + "itemUuid": "e8ee1a0c" + }, + "extraFields": [ + { + "fieldName": "non-hidden field", + "type": "text", + "data": { + "content": "non-hidden field content" + } + }, + { + "fieldName": "hidden field", + "type": "hidden", + "data": { + "content": "hidden field content" + } + }, + { + "fieldName": "second 2fa secret", + "type": "totp", + "data": { + "totpUri": "TOTPCODE" + } + } + ], + "type": "login", + "content": { + "itemEmail": "Email", + "password": "Password", + "urls": [ + "https://example.com/", + "https://example2.com/" + ], + "totpUri": "otpauth://totp/Test%20Login%20-%20Personal%20Vault:Username?issuer=Test%20Login%20-%20Personal%20Vault&secret=TOTPCODE&algorithm=SHA1&digits=6&period=30", + "passkeys": [], + "itemUsername": "Username" + } + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182868, + "modifyTime": 1689182868, + "pinned": true + }, + { + "itemId": "xqq_Bh8RxNMBerkiMvRdH427yswZznjYwps-f6C5D8tmKiPgMxCSPNz1BOd4nRJ309gciDiPhXcCVWOyfJ66ZA==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "My Secure Note", + "note": "Secure note contents.", + "itemUuid": "ad618070" + }, + "extraFields": [], + "type": "note", + "content": {} + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182908, + "modifyTime": 1689182908, + "pinned": false + }, + { + "itemId": "ZmGzd-HNQYTr6wmfWlSfiStXQLqGic_PYB2Q2T_hmuRM2JIA4pKAPJcmFafxJrDpXxLZ2EPjgD6Noc9a0U6AVQ==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "Test Card", + "note": "Credit Card Note", + "itemUuid": "d8f45370" + }, + "extraFields": [], + "type": "creditCard", + "content": { + "cardholderName": "Test name", + "cardType": 0, + "number": "1234222233334444", + "verificationNumber": "333", + "expirationDate": "2025-01", + "pin": "1234" + } + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1691001643, + "modifyTime": 1691001643, + "pinned": true + }, + { + "itemId": "xqq_Bh8RxNMBerkiMvRdH427yswZznjYwps-f6C5D8tmKiPgMxCSPNz1BOd4nRJ309gciDiPhXcCVWOyfJ66ZA==", + "shareId": "SN5uWo4WZF2uT5wIDqtbdpkjuxCbNTOIdf-JQ_DYZcKYKURHiZB5csS1a1p9lklvju9ni42l08IKzwQG0B2ySg==", + "data": { + "metadata": { + "name": "My Deleted Note", + "note": "Secure note contents.", + "itemUuid": "ad618070" + }, + "extraFields": [], + "type": "note", + "content": {} + }, + "state": 2, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182908, + "modifyTime": 1689182908, + "pinned": false + } + ] + }, + "VAULT_B": { + "name": "Test", + "description": "", + "display": { + "color": 4, + "icon": 2 + }, + "items": [ + { + "itemId": "U_J8-eUR15sC-PjUhjVcixDcayhjGuoerUZCr560RlAi0ZjBNkSaSKAytVzZn4E0hiFX1_y4qZbUetl6jO3aJw==", + "shareId": "OJz-4MnPqAuYnyemhctcGDlSLJrzsTnf2FnFSwxh1QP_oth9xyGDc2ZAqCv5FnqkVgTNHT5aPj62zcekNemfNw==", + "data": { + "metadata": { + "name": "Other vault login", + "note": "", + "itemUuid": "f3429d44" + }, + "extraFields": [], + "type": "login", + "content": { + "itemEmail": "other vault username", + "password": "other vault password", + "urls": [], + "totpUri": "JBSWY3DPEHPK3PXP", + "passkeys": [], + "itemUsername": "" + } + }, + "state": 1, + "aliasEmail": null, + "contentFormatVersion": 1, + "createTime": 1689182949, + "modifyTime": 1689182949, + "pinned": false + } + ] + } + } +} \ No newline at end of file diff --git a/tests/gui/TestGui.cpp b/tests/gui/TestGui.cpp index e3d4189258..4beede3211 100644 --- a/tests/gui/TestGui.cpp +++ b/tests/gui/TestGui.cpp @@ -489,35 +489,38 @@ void TestGui::testOpenRemoteDatabase() QTest::mouseClick(openRemoteButton, Qt::LeftButton); QApplication::processEvents(); - TEST_MODAL_NO_WAIT( - ImportWizard * wizard; QTRY_VERIFY(wizard = m_tabWidget->findChild()); + TEST_MODAL_NO_WAIT(ImportWizard * wizard; QTRY_VERIFY(wizard = m_tabWidget->findChild()); - auto* importTypeList = wizard->currentPage()->findChild("importTypeList"); - QVERIFY(importTypeList); - importTypeList->scrollToBottom(); + auto* importTypeList = wizard->currentPage()->findChild("importTypeList"); + QVERIFY(importTypeList); - QListWidgetItem* remoteOption = importTypeList->item(importTypeList->count() - 1); - QRect remoteOptionRect = importTypeList->visualItemRect(remoteOption); - QTest::mouseClick(importTypeList->viewport(), Qt::LeftButton, nullptr, remoteOptionRect.center()); + for (int i = 0; i < importTypeList->count(); ++i) { + auto item = importTypeList->item(i); + if (item->data(Qt::UserRole) == ImportWizard::IMPORT_REMOTE) { + importTypeList->setCurrentItem(item); + break; + } + } - auto* downloadCommandEdit = wizard->currentPage()->findChild("downloadCommand"); - QVERIFY(downloadCommandEdit); - QTest::keyClicks(downloadCommandEdit, sourceToSync); + auto* downloadCommandEdit = wizard->currentPage()->findChild("downloadCommand"); + QVERIFY(downloadCommandEdit); + QTest::keyClicks(downloadCommandEdit, sourceToSync); - auto* temporaryDatabaseRadio = wizard->currentPage()->findChild("temporaryDatabaseRadio"); - QVERIFY(temporaryDatabaseRadio); - QTest::mouseClick(temporaryDatabaseRadio, Qt::LeftButton); + auto* temporaryDatabaseRadio = + wizard->currentPage()->findChild("temporaryDatabaseRadio"); + QVERIFY(temporaryDatabaseRadio); + QTest::mouseClick(temporaryDatabaseRadio, Qt::LeftButton); - auto* passwordEdit = wizard->currentPage()->findChild("passwordEdit"); - QVERIFY(passwordEdit); - QTest::keyClicks(passwordEdit, "a"); - QTest::keyClick(passwordEdit, Qt::Key_Enter); + auto* passwordEdit = wizard->currentPage()->findChild("passwordEdit"); + QVERIFY(passwordEdit); + QTest::keyClicks(passwordEdit, "a"); + QTest::keyClick(passwordEdit, Qt::Key_Enter); - QApplication::processEvents(); + QApplication::processEvents(); - QVERIFY(wizard->currentPage()->findChildren().count() > 0); + QVERIFY(wizard->currentPage()->findChildren().count() > 0); - QTest::keyClick(passwordEdit, Qt::Key_Enter);); + QTest::keyClick(passwordEdit, Qt::Key_Enter);); // remote database has been opened QTRY_COMPARE(m_tabWidget->tabText(m_tabWidget->currentIndex()), QString("SyncDatabase [Temporary]")); From 620abb96f2c2ce5bc499a5b2a6d30eb6201516dc Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Sat, 4 Jan 2025 09:38:02 -0500 Subject: [PATCH 2/6] Implement Secure Input Field mode on macOS * Fixes #4738 * Also fixes flaky handling of caps lock detection events --- src/gui/PasswordWidget.cpp | 20 +++++++++++++------- src/gui/PasswordWidget.h | 5 ++--- src/gui/osutils/OSUtilsBase.h | 5 +++++ src/gui/osutils/macutils/MacUtils.cpp | 9 +++++++++ src/gui/osutils/macutils/MacUtils.h | 1 + src/gui/osutils/nixutils/NixUtils.cpp | 6 ++++++ src/gui/osutils/nixutils/NixUtils.h | 1 + src/gui/osutils/winutils/WinUtils.cpp | 6 ++++++ src/gui/osutils/winutils/WinUtils.h | 1 + 9 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/gui/PasswordWidget.cpp b/src/gui/PasswordWidget.cpp index 2b2d9057b3..03751c8b11 100644 --- a/src/gui/PasswordWidget.cpp +++ b/src/gui/PasswordWidget.cpp @@ -38,6 +38,7 @@ PasswordWidget::PasswordWidget(QWidget* parent) { m_ui->setupUi(this); setFocusProxy(m_ui->passwordEdit); + m_ui->passwordEdit->installEventFilter(this); const QIcon errorIcon = icons()->icon("dialog-error"); m_errorAction = m_ui->passwordEdit->addAction(errorIcon, QLineEdit::TrailingPosition); @@ -223,14 +224,19 @@ void PasswordWidget::updateRepeatStatus() } } -bool PasswordWidget::event(QEvent* event) +bool PasswordWidget::eventFilter(QObject* watched, QEvent* event) { - if (isVisible() - && (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease - || event->type() == QEvent::FocusIn)) { - checkCapslockState(); + if (watched == m_ui->passwordEdit) { + auto type = event->type(); + if (isVisible() && (type == QEvent::KeyPress || type == QEvent::KeyRelease || type == QEvent::FocusIn)) { + checkCapslockState(); + } + if (type == QEvent::FocusIn || type == QEvent::FocusOut) { + osUtils->setUserInputProtection(type == QEvent::FocusIn); + } } - return QWidget::event(event); + // Continue with normal operations + return false; } void PasswordWidget::checkCapslockState() @@ -306,4 +312,4 @@ void PasswordWidget::updatePasswordStrength(const QString& password) break; } -} \ No newline at end of file +} diff --git a/src/gui/PasswordWidget.h b/src/gui/PasswordWidget.h index 5ad1c4f5c9..6049d2908c 100644 --- a/src/gui/PasswordWidget.h +++ b/src/gui/PasswordWidget.h @@ -44,6 +44,8 @@ class PasswordWidget : public QWidget bool isPasswordVisible() const; QString text(); + bool eventFilter(QObject* watched, QEvent* event) override; + signals: void textChanged(QString text); @@ -57,9 +59,6 @@ public slots: void setEchoMode(QLineEdit::EchoMode mode); void setClearButtonEnabled(bool enabled); -protected: - bool event(QEvent* event) override; - private slots: void popupPasswordGenerator(); void updateRepeatStatus(); diff --git a/src/gui/osutils/OSUtilsBase.h b/src/gui/osutils/OSUtilsBase.h index 5b827b83be..11d739fde6 100644 --- a/src/gui/osutils/OSUtilsBase.h +++ b/src/gui/osutils/OSUtilsBase.h @@ -56,6 +56,11 @@ class OSUtilsBase : public QObject */ virtual bool isCapslockEnabled() = 0; + /** + * @param enable Toggle protection on user input (if available). + */ + virtual void setUserInputProtection(bool enable) = 0; + virtual void registerNativeEventFilter() = 0; virtual bool registerGlobalShortcut(const QString& name, diff --git a/src/gui/osutils/macutils/MacUtils.cpp b/src/gui/osutils/macutils/MacUtils.cpp index 15f55d94c7..893ec8fcc0 100644 --- a/src/gui/osutils/macutils/MacUtils.cpp +++ b/src/gui/osutils/macutils/MacUtils.cpp @@ -150,6 +150,15 @@ bool MacUtils::isCapslockEnabled() #endif } +void MacUtils::setUserInputProtection(bool enable) +{ + if (enable) { + EnableSecureEventInput(); + } else { + DisableSecureEventInput(); + } +} + /** * Toggle application state between foreground app and UIElement app. * Foreground apps have dock icons, UIElement apps do not. diff --git a/src/gui/osutils/macutils/MacUtils.h b/src/gui/osutils/macutils/MacUtils.h index 16cda54e24..5e0e121d5e 100644 --- a/src/gui/osutils/macutils/MacUtils.h +++ b/src/gui/osutils/macutils/MacUtils.h @@ -40,6 +40,7 @@ class MacUtils : public OSUtilsBase bool isLaunchAtStartupEnabled() const override; void setLaunchAtStartup(bool enable) override; bool isCapslockEnabled() override; + void setUserInputProtection(bool enable) override; WId activeWindow(); bool raiseWindow(WId pid); diff --git a/src/gui/osutils/nixutils/NixUtils.cpp b/src/gui/osutils/nixutils/NixUtils.cpp index c2ca1d4daa..30b7e21e4b 100644 --- a/src/gui/osutils/nixutils/NixUtils.cpp +++ b/src/gui/osutils/nixutils/NixUtils.cpp @@ -235,6 +235,12 @@ bool NixUtils::isCapslockEnabled() return false; } +void NixUtils::setUserInputProtection(bool enable) +{ + // Linux does not support this feature + Q_UNUSED(enable) +} + void NixUtils::registerNativeEventFilter() { qApp->installNativeEventFilter(this); diff --git a/src/gui/osutils/nixutils/NixUtils.h b/src/gui/osutils/nixutils/NixUtils.h index c3caca6b7f..9be835ff9f 100644 --- a/src/gui/osutils/nixutils/NixUtils.h +++ b/src/gui/osutils/nixutils/NixUtils.h @@ -35,6 +35,7 @@ class NixUtils : public OSUtilsBase, QAbstractNativeEventFilter bool isLaunchAtStartupEnabled() const override; void setLaunchAtStartup(bool enable) override; bool isCapslockEnabled() override; + void setUserInputProtection(bool enable) override; void registerNativeEventFilter() override; diff --git a/src/gui/osutils/winutils/WinUtils.cpp b/src/gui/osutils/winutils/WinUtils.cpp index 670b357e2e..a159769324 100644 --- a/src/gui/osutils/winutils/WinUtils.cpp +++ b/src/gui/osutils/winutils/WinUtils.cpp @@ -136,6 +136,12 @@ bool WinUtils::isCapslockEnabled() return GetKeyState(VK_CAPITAL) == 1; } +void WinUtils::setUserInputProtection(bool enable) +{ + // Windows does not support this feature + Q_UNUSED(enable) +} + bool WinUtils::isHighContrastMode() const { QSettings settings(R"(HKEY_CURRENT_USER\Control Panel\Accessibility\HighContrast)", QSettings::NativeFormat); diff --git a/src/gui/osutils/winutils/WinUtils.h b/src/gui/osutils/winutils/WinUtils.h index 9e4492a5f5..9278c9d606 100644 --- a/src/gui/osutils/winutils/WinUtils.h +++ b/src/gui/osutils/winutils/WinUtils.h @@ -44,6 +44,7 @@ class WinUtils : public OSUtilsBase, QAbstractNativeEventFilter bool isLaunchAtStartupEnabled() const override; void setLaunchAtStartup(bool enable) override; bool isCapslockEnabled() override; + void setUserInputProtection(bool enable) override; bool isHighContrastMode() const; void registerNativeEventFilter() override; From 17dc022e1593ac4a91ce54d796ca67f81fc2fd36 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Tue, 24 Dec 2024 08:34:11 -0500 Subject: [PATCH 3/6] Fix rendering & and " in preview panel * Set plain text mode on elements that should never have styling * Revert html escaping as a prior fix --- src/gui/EntryPreviewWidget.cpp | 6 ++++-- src/gui/EntryPreviewWidget.ui | 11 ++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/gui/EntryPreviewWidget.cpp b/src/gui/EntryPreviewWidget.cpp index 227e1c9342..69f6b56dce 100644 --- a/src/gui/EntryPreviewWidget.cpp +++ b/src/gui/EntryPreviewWidget.cpp @@ -245,7 +245,7 @@ void EntryPreviewWidget::updateEntryHeaderLine() { Q_ASSERT(m_currentEntry); const QString title = m_currentEntry->resolveMultiplePlaceholders(m_currentEntry->title()); - m_ui->entryTitleLabel->setRawText(hierarchy(m_currentEntry->group(), title.toHtmlEscaped())); + m_ui->entryTitleLabel->setRawText(hierarchy(m_currentEntry->group(), title)); m_ui->entryIcon->setPixmap(Icons::entryIconPixmap(m_currentEntry, IconSize::Large)); } @@ -302,10 +302,12 @@ void EntryPreviewWidget::setPasswordVisible(bool state) html += "" + QString(c).toHtmlEscaped() + ""; } // clang-format on + m_ui->entryPasswordLabel->setTextFormat(Qt::RichText); m_ui->entryPasswordLabel->setText(html); } else { // No color - m_ui->entryPasswordLabel->setText(password.toHtmlEscaped()); + m_ui->entryPasswordLabel->setTextFormat(Qt::PlainText); + m_ui->entryPasswordLabel->setText(password); } } else if (password.isEmpty() && !config()->get(Config::Security_PasswordEmptyPlaceholder).toBool()) { m_ui->entryPasswordLabel->setText(""); diff --git a/src/gui/EntryPreviewWidget.ui b/src/gui/EntryPreviewWidget.ui index 92081ab181..9b4e499605 100644 --- a/src/gui/EntryPreviewWidget.ui +++ b/src/gui/EntryPreviewWidget.ui @@ -100,7 +100,7 @@ Qt::ClickFocus - Qt::AutoText + Qt::PlainText Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse @@ -279,6 +279,9 @@ + + Qt::ClickFocus + TextLabel @@ -316,6 +319,9 @@ https://example.com + + Qt::RichText + Qt::TextBrowserInteraction @@ -504,6 +510,9 @@ expired + + Qt::PlainText + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse From 40ee047ef0cce230df2df8afb3a8f8c76e977ab3 Mon Sep 17 00:00:00 2001 From: Jonathan White Date: Sat, 4 Jan 2025 16:46:18 -0500 Subject: [PATCH 4/6] Fix crash when pressing home key on empty tags field * Fixes #11344 --- src/gui/tag/TagsEdit.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/tag/TagsEdit.cpp b/src/gui/tag/TagsEdit.cpp index 6bd0d39dfa..883c7b1234 100644 --- a/src/gui/tag/TagsEdit.cpp +++ b/src/gui/tag/TagsEdit.cpp @@ -337,7 +337,7 @@ struct TagsEdit::Impl assert(i < tags.size()); auto occurrencesOfCurrentText = std::count_if(tags.cbegin(), tags.cend(), [this](const auto& tag) { return tag.text == currentText(); }); - if (currentText().isEmpty() || occurrencesOfCurrentText > 1) { + if (tags.size() > 1 && (currentText().isEmpty() || occurrencesOfCurrentText > 1)) { tags.erase(std::next(tags.begin(), std::ptrdiff_t(editing_index))); if (editing_index <= i) { // Do we shift positions after `i`? --i; From dce34de875ac2ba8a4e0aeda3078ff0eac28482a Mon Sep 17 00:00:00 2001 From: Kuznetsov Oleg Date: Sun, 12 Jan 2025 07:08:19 +0300 Subject: [PATCH 5/6] Add New/Preview Entry Attachments dialog and functionality (#11637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11506 Closes #3383 * This change adds a new opportunity to add attachments that don’t require a real file in the file system. * Add a new dialog window to add and preview attachments and integrate it into the EntryAttachmentsWidget. * Attachment preview support for images and plain text files. Additional enhancements: * Fix sizing of attachment columns * Add padding to attachment table items * Fix targeting of preview widget styling to not impact unintended children --- share/translations/keepassxc_en.ts | 69 ++++++++-- src/CMakeLists.txt | 2 + src/core/Tools.cpp | 28 ++++ src/core/Tools.h | 9 ++ src/gui/EntryPreviewWidget.ui | 21 +++ src/gui/entry/EntryAttachmentsDialog.ui | 55 ++++++++ src/gui/entry/EntryAttachmentsWidget.cpp | 101 +++++++++++--- src/gui/entry/EntryAttachmentsWidget.h | 5 +- src/gui/entry/EntryAttachmentsWidget.ui | 75 ++++++++--- src/gui/entry/NewEntryAttachmentsDialog.cpp | 92 +++++++++++++ src/gui/entry/NewEntryAttachmentsDialog.h | 48 +++++++ .../entry/PreviewEntryAttachmentsDialog.cpp | 123 ++++++++++++++++++ src/gui/entry/PreviewEntryAttachmentsDialog.h | 59 +++++++++ src/gui/styles/base/basestyle.qss | 7 +- tests/TestTools.cpp | 67 ++++++++++ tests/TestTools.h | 1 + 16 files changed, 716 insertions(+), 46 deletions(-) create mode 100644 src/gui/entry/EntryAttachmentsDialog.ui create mode 100644 src/gui/entry/NewEntryAttachmentsDialog.cpp create mode 100644 src/gui/entry/NewEntryAttachmentsDialog.h create mode 100644 src/gui/entry/PreviewEntryAttachmentsDialog.cpp create mode 100644 src/gui/entry/PreviewEntryAttachmentsDialog.h diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 5d52cc5e69..519a325bec 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -3826,6 +3826,21 @@ This may cause the affected plugins to malfunction. + + EntryAttachmentsDialog + + Form + + + + File name + + + + File contents... + + + EntryAttachmentsModel @@ -3863,14 +3878,6 @@ This may cause the affected plugins to malfunction. Remove - - Rename selected attachment - - - - Rename - - Open selected attachment @@ -3980,6 +3987,18 @@ Error: %1 Would you like to overwrite the existing attachment? + + New + + + + Preview + + + + Failed to preview an attachment: Attachment not found + + EntryAttributesModel @@ -6349,6 +6368,25 @@ Expect some bugs and minor issues, this version is meant for testing purposes. + + NewEntryAttachmentsDialog + + Attachment name cannot be empty + + + + Attachment with the same name already exists + + + + Save attachment + + + + New entry attachment + + + NixUtils @@ -7114,6 +7152,21 @@ Do you want to overwrite it? + + PreviewEntryAttachmentsDialog + + Preview entry attachment + + + + No preview available + + + + Image format not supported + + + QMessageBox diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5c7326b5c5..84c6090ba5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -157,6 +157,8 @@ set(gui_SOURCES gui/entry/EntryAttachmentsModel.cpp gui/entry/EntryAttachmentsWidget.cpp gui/entry/EntryAttributesModel.cpp + gui/entry/NewEntryAttachmentsDialog.cpp + gui/entry/PreviewEntryAttachmentsDialog.cpp gui/entry/EntryHistoryModel.cpp gui/entry/EntryModel.cpp gui/entry/EntryView.cpp diff --git a/src/core/Tools.cpp b/src/core/Tools.cpp index 5ee4c72984..39c7ab8eb1 100644 --- a/src/core/Tools.cpp +++ b/src/core/Tools.cpp @@ -475,4 +475,32 @@ namespace Tools return pattern; } + + MimeType toMimeType(const QString& mimeName) + { + static QStringList textFormats = { + "text/", + "application/json", + "application/xml", + "application/soap+xml", + "application/x-yaml", + "application/protobuf", + }; + static QStringList imageFormats = {"image/"}; + + static auto isCompatible = [](const QString& format, const QStringList& list) { + return std::any_of( + list.cbegin(), list.cend(), [&format](const auto& item) { return format.startsWith(item); }); + }; + + if (isCompatible(mimeName, imageFormats)) { + return MimeType::Image; + } + + if (isCompatible(mimeName, textFormats)) { + return MimeType::PlainText; + } + + return MimeType::Unknown; + } } // namespace Tools diff --git a/src/core/Tools.h b/src/core/Tools.h index d170d76d0d..7265f68bbf 100644 --- a/src/core/Tools.h +++ b/src/core/Tools.h @@ -114,6 +114,15 @@ namespace Tools QVariantMap qo2qvm(const QObject* object, const QStringList& ignoredProperties = {"objectName"}); QString substituteBackupFilePath(QString pattern, const QString& databasePath); + + enum class MimeType : uint8_t + { + Image, + PlainText, + Unknown + }; + + MimeType toMimeType(const QString& mimeName); } // namespace Tools #endif // KEEPASSX_TOOLS_H diff --git a/src/gui/EntryPreviewWidget.ui b/src/gui/EntryPreviewWidget.ui index 9b4e499605..b2cdecbbab 100644 --- a/src/gui/EntryPreviewWidget.ui +++ b/src/gui/EntryPreviewWidget.ui @@ -288,6 +288,9 @@ Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + true + @@ -325,6 +328,9 @@ Qt::TextBrowserInteraction + + true + @@ -409,6 +415,9 @@ true + + true + @@ -482,6 +491,9 @@ true + + true + @@ -494,6 +506,9 @@ Tags list + + true + @@ -516,6 +531,9 @@ Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + true + @@ -1109,6 +1127,9 @@ true + + true + diff --git a/src/gui/entry/EntryAttachmentsDialog.ui b/src/gui/entry/EntryAttachmentsDialog.ui new file mode 100644 index 0000000000..2b13ea0be1 --- /dev/null +++ b/src/gui/entry/EntryAttachmentsDialog.ui @@ -0,0 +1,55 @@ + + + EntryAttachmentsDialog + + + + 0 + 0 + 402 + 300 + + + + Form + + + + + + File name + + + + + + + true + + + color: #FF9696 + + + + + + + + + + File contents... + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + diff --git a/src/gui/entry/EntryAttachmentsWidget.cpp b/src/gui/entry/EntryAttachmentsWidget.cpp index d514804f8e..744a659310 100644 --- a/src/gui/entry/EntryAttachmentsWidget.cpp +++ b/src/gui/entry/EntryAttachmentsWidget.cpp @@ -16,16 +16,19 @@ */ #include "EntryAttachmentsWidget.h" + +#include "EntryAttachmentsModel.h" +#include "NewEntryAttachmentsDialog.h" +#include "PreviewEntryAttachmentsDialog.h" #include "ui_EntryAttachmentsWidget.h" -#include +#include #include #include #include #include #include "EntryAttachmentsModel.h" -#include "core/Config.h" #include "core/EntryAttachments.h" #include "core/Tools.h" #include "gui/FileDialog.h" @@ -46,12 +49,12 @@ EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent) m_ui->attachmentsView->viewport()->installEventFilter(this); m_ui->attachmentsView->setModel(m_attachmentsModel); - m_ui->attachmentsView->verticalHeader()->hide(); - m_ui->attachmentsView->horizontalHeader()->setStretchLastSection(true); - m_ui->attachmentsView->horizontalHeader()->resizeSection(EntryAttachmentsModel::NameColumn, 400); - m_ui->attachmentsView->setSelectionBehavior(QAbstractItemView::SelectRows); - m_ui->attachmentsView->setSelectionMode(QAbstractItemView::ExtendedSelection); - m_ui->attachmentsView->setEditTriggers(QAbstractItemView::SelectedClicked); + m_ui->attachmentsView->horizontalHeader()->setMinimumSectionSize(70); + m_ui->attachmentsView->horizontalHeader()->setSectionResizeMode(EntryAttachmentsModel::NameColumn, + QHeaderView::Stretch); + m_ui->attachmentsView->horizontalHeader()->setSectionResizeMode(EntryAttachmentsModel::SizeColumn, + QHeaderView::ResizeToContents); + m_ui->attachmentsView->verticalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); connect(this, SIGNAL(buttonsVisibleChanged(bool)), this, SLOT(updateButtonsVisible())); connect(this, SIGNAL(readOnlyChanged(bool)), SLOT(updateButtonsEnabled())); @@ -64,12 +67,13 @@ EntryAttachmentsWidget::EntryAttachmentsWidget(QWidget* parent) // clang-format on connect(this, SIGNAL(readOnlyChanged(bool)), m_attachmentsModel, SLOT(setReadOnly(bool))); - connect(m_ui->attachmentsView, SIGNAL(doubleClicked(QModelIndex)), SLOT(openAttachment(QModelIndex))); + connect(m_ui->attachmentsView, SIGNAL(doubleClicked(QModelIndex)), SLOT(previewSelectedAttachment())); connect(m_ui->saveAttachmentButton, SIGNAL(clicked()), SLOT(saveSelectedAttachments())); connect(m_ui->openAttachmentButton, SIGNAL(clicked()), SLOT(openSelectedAttachments())); connect(m_ui->addAttachmentButton, SIGNAL(clicked()), SLOT(insertAttachments())); + connect(m_ui->newAttachmentButton, SIGNAL(clicked()), SLOT(newAttachments())); + connect(m_ui->previewAttachmentButton, SIGNAL(clicked()), SLOT(previewSelectedAttachment())); connect(m_ui->removeAttachmentButton, SIGNAL(clicked()), SLOT(removeSelectedAttachments())); - connect(m_ui->renameAttachmentButton, SIGNAL(clicked()), SLOT(renameSelectedAttachments())); updateButtonsVisible(); updateButtonsEnabled(); @@ -163,6 +167,57 @@ void EntryAttachmentsWidget::insertAttachments() emit widgetUpdated(); } +void EntryAttachmentsWidget::newAttachments() +{ + Q_ASSERT(m_entryAttachments); + Q_ASSERT(!isReadOnly()); + if (isReadOnly()) { + return; + } + + NewEntryAttachmentsDialog newEntryDialog(m_entryAttachments, this); + if (newEntryDialog.exec() == QDialog::Accepted) { + emit widgetUpdated(); + } +} + +void EntryAttachmentsWidget::previewSelectedAttachment() +{ + Q_ASSERT(m_entryAttachments); + + const auto index = m_ui->attachmentsView->selectionModel()->selectedIndexes().first(); + if (!index.isValid()) { + qWarning() << tr("Failed to preview an attachment: Attachment not found"); + return; + } + + // Set selection to the first + m_ui->attachmentsView->setCurrentIndex(index); + + auto name = m_attachmentsModel->keyByIndex(index); + auto data = m_entryAttachments->value(name); + + PreviewEntryAttachmentsDialog previewDialog(this); + previewDialog.setAttachment(name, data); + + connect(&previewDialog, SIGNAL(openAttachment(QString)), SLOT(openSelectedAttachments())); + connect(&previewDialog, SIGNAL(saveAttachment(QString)), SLOT(saveSelectedAttachments())); + // Refresh the preview if the attachment changes + connect(m_entryAttachments, + &EntryAttachments::keyModified, + &previewDialog, + [&previewDialog, &name, this](const QString& key) { + if (key == name) { + previewDialog.setAttachment(name, m_entryAttachments->value(name)); + } + }); + + previewDialog.exec(); + + // Set focus back to the widget to allow keyboard navigation + setFocus(); +} + void EntryAttachmentsWidget::removeSelectedAttachments() { Q_ASSERT(m_entryAttachments); @@ -192,12 +247,6 @@ void EntryAttachmentsWidget::removeSelectedAttachments() } } -void EntryAttachmentsWidget::renameSelectedAttachments() -{ - Q_ASSERT(m_entryAttachments); - m_ui->attachmentsView->edit(m_ui->attachmentsView->selectionModel()->selectedIndexes().first()); -} - void EntryAttachmentsWidget::saveSelectedAttachments() { Q_ASSERT(m_entryAttachments); @@ -287,7 +336,7 @@ void EntryAttachmentsWidget::openSelectedAttachments() if (!m_entryAttachments->openAttachment(m_attachmentsModel->keyByIndex(index), &errorMessage)) { const QString filename = m_attachmentsModel->keyByIndex(index); errors.append(QString("%1 - %2").arg(filename, errorMessage)); - }; + } } if (!errors.isEmpty()) { @@ -300,18 +349,32 @@ void EntryAttachmentsWidget::updateButtonsEnabled() const bool hasSelection = m_ui->attachmentsView->selectionModel()->hasSelection(); m_ui->addAttachmentButton->setEnabled(!m_readOnly); + m_ui->newAttachmentButton->setEnabled(!m_readOnly); m_ui->removeAttachmentButton->setEnabled(hasSelection && !m_readOnly); - m_ui->renameAttachmentButton->setEnabled(hasSelection && !m_readOnly); m_ui->saveAttachmentButton->setEnabled(hasSelection); + m_ui->previewAttachmentButton->setEnabled(hasSelection); m_ui->openAttachmentButton->setEnabled(hasSelection); + + updateSpacers(); +} + +void EntryAttachmentsWidget::updateSpacers() +{ + if (m_buttonsVisible && !m_readOnly) { + m_ui->previewVSpacer->changeSize(20, 40, QSizePolicy::Fixed, QSizePolicy::Expanding); + } else { + m_ui->previewVSpacer->changeSize(0, 0, QSizePolicy::Fixed, QSizePolicy::Fixed); + } } void EntryAttachmentsWidget::updateButtonsVisible() { m_ui->addAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); + m_ui->newAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); m_ui->removeAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); - m_ui->renameAttachmentButton->setVisible(m_buttonsVisible && !m_readOnly); + + updateSpacers(); } bool EntryAttachmentsWidget::insertAttachments(const QStringList& filenames, QString& errorMessage) diff --git a/src/gui/entry/EntryAttachmentsWidget.h b/src/gui/entry/EntryAttachmentsWidget.h index 0f104a82a6..8c15fd68a5 100644 --- a/src/gui/entry/EntryAttachmentsWidget.h +++ b/src/gui/entry/EntryAttachmentsWidget.h @@ -57,8 +57,9 @@ public slots: private slots: void insertAttachments(); + void newAttachments(); + void previewSelectedAttachment(); void removeSelectedAttachments(); - void renameSelectedAttachments(); void saveSelectedAttachments(); void openAttachment(const QModelIndex& index); void openSelectedAttachments(); @@ -67,6 +68,8 @@ private slots: void attachmentModifiedExternally(const QString& key, const QString& filePath); private: + void updateSpacers(); + bool insertAttachments(const QStringList& fileNames, QString& errorMessage); QStringList confirmAttachmentSelection(const QStringList& filenames); diff --git a/src/gui/entry/EntryAttachmentsWidget.ui b/src/gui/entry/EntryAttachmentsWidget.ui index e685813b3d..5b6de67aa2 100644 --- a/src/gui/entry/EntryAttachmentsWidget.ui +++ b/src/gui/entry/EntryAttachmentsWidget.ui @@ -7,7 +7,7 @@ 0 0 337 - 289 + 258 @@ -34,11 +34,20 @@ QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked + + QAbstractItemView::SelectRows + + + false + + + false + - + 0 @@ -52,41 +61,48 @@ 0 - + false - - Add new attachment - - Add + New - + false - Remove selected attachment + Add new attachment - Remove + Add - + + + Qt::Vertical + + + + 20 + 40 + + + + + + false - - Rename selected attachment - - Rename + Preview @@ -129,6 +145,35 @@ + + + + false + + + Remove selected attachment + + + Remove + + + + + + + Qt::Vertical + + + QSizePolicy::Minimum + + + + 20 + 0 + + + + diff --git a/src/gui/entry/NewEntryAttachmentsDialog.cpp b/src/gui/entry/NewEntryAttachmentsDialog.cpp new file mode 100644 index 0000000000..b8da3b791f --- /dev/null +++ b/src/gui/entry/NewEntryAttachmentsDialog.cpp @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "NewEntryAttachmentsDialog.h" +#include "core/EntryAttachments.h" +#include "ui_EntryAttachmentsDialog.h" + +#include +#include + +NewEntryAttachmentsDialog::NewEntryAttachmentsDialog(QPointer attachments, QWidget* parent) + : QDialog(parent) + , m_attachments(std::move(attachments)) + , m_ui(new Ui::EntryAttachmentsDialog) +{ + Q_ASSERT(m_attachments); + + m_ui->setupUi(this); + + setWindowTitle(tr("New entry attachment")); + + m_ui->dialogButtons->clear(); + m_ui->dialogButtons->addButton(QDialogButtonBox::Ok); + m_ui->dialogButtons->addButton(QDialogButtonBox::Cancel); + + connect(m_ui->dialogButtons, SIGNAL(accepted()), this, SLOT(saveAttachment())); + connect(m_ui->dialogButtons, SIGNAL(rejected()), this, SLOT(reject())); + connect(m_ui->titleEdit, SIGNAL(textChanged(const QString&)), this, SLOT(fileNameTextChanged(const QString&))); + + fileNameTextChanged(m_ui->titleEdit->text()); +} + +NewEntryAttachmentsDialog::~NewEntryAttachmentsDialog() = default; + +bool NewEntryAttachmentsDialog::validateFileName(const QString& fileName, QString& error) const +{ + if (fileName.isEmpty()) { + error = tr("Attachment name cannot be empty"); + return false; + } + + if (m_attachments->hasKey(fileName)) { + error = tr("Attachment with the same name already exists"); + return false; + } + + return true; +} + +void NewEntryAttachmentsDialog::saveAttachment() +{ + auto fileName = m_ui->titleEdit->text(); + auto text = m_ui->attachmentTextEdit->toPlainText().toUtf8(); + + QString error; + if (validateFileName(fileName, error)) { + QMessageBox::warning(this, tr("Save attachment"), error); + return; + } + + m_attachments->set(fileName, text); + + accept(); +} + +void NewEntryAttachmentsDialog::fileNameTextChanged(const QString& fileName) +{ + QString error; + bool valid = validateFileName(fileName, error); + + m_ui->errorLabel->setText(error); + m_ui->errorLabel->setVisible(!valid); + + auto okButton = m_ui->dialogButtons->button(QDialogButtonBox::Ok); + if (okButton) { + okButton->setDisabled(!valid); + } +} diff --git a/src/gui/entry/NewEntryAttachmentsDialog.h b/src/gui/entry/NewEntryAttachmentsDialog.h new file mode 100644 index 0000000000..651100f65b --- /dev/null +++ b/src/gui/entry/NewEntryAttachmentsDialog.h @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +namespace Ui +{ + class EntryAttachmentsDialog; +} + +class QByteArray; +class EntryAttachments; + +class NewEntryAttachmentsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit NewEntryAttachmentsDialog(QPointer attachments, QWidget* parent = nullptr); + ~NewEntryAttachmentsDialog() override; + +private slots: + void saveAttachment(); + void fileNameTextChanged(const QString& fileName); + +private: + bool validateFileName(const QString& fileName, QString& error) const; + + QPointer m_attachments; + QScopedPointer m_ui; +}; diff --git a/src/gui/entry/PreviewEntryAttachmentsDialog.cpp b/src/gui/entry/PreviewEntryAttachmentsDialog.cpp new file mode 100644 index 0000000000..6926effbb4 --- /dev/null +++ b/src/gui/entry/PreviewEntryAttachmentsDialog.cpp @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "PreviewEntryAttachmentsDialog.h" +#include "ui_EntryAttachmentsDialog.h" + +#include +#include +#include +#include +#include + +PreviewEntryAttachmentsDialog::PreviewEntryAttachmentsDialog(QWidget* parent) + : QDialog(parent) + , m_ui(new Ui::EntryAttachmentsDialog) +{ + m_ui->setupUi(this); + + setWindowTitle(tr("Preview entry attachment")); + // Disable the help button in the title bar + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + // Set to read-only + m_ui->titleEdit->setReadOnly(true); + m_ui->attachmentTextEdit->setReadOnly(true); + m_ui->errorLabel->setVisible(false); + + // Initialize dialog buttons + m_ui->dialogButtons->setStandardButtons(QDialogButtonBox::Close | QDialogButtonBox::Open | QDialogButtonBox::Save); + auto closeButton = m_ui->dialogButtons->button(QDialogButtonBox::Close); + closeButton->setDefault(true); + + connect(m_ui->dialogButtons, SIGNAL(rejected()), this, SLOT(reject())); + connect(m_ui->dialogButtons, &QDialogButtonBox::clicked, [this](QAbstractButton* button) { + auto pressedButton = m_ui->dialogButtons->standardButton(button); + if (pressedButton == QDialogButtonBox::Open) { + emit openAttachment(m_name); + } else if (pressedButton == QDialogButtonBox::Save) { + emit saveAttachment(m_name); + } + }); +} + +PreviewEntryAttachmentsDialog::~PreviewEntryAttachmentsDialog() = default; + +void PreviewEntryAttachmentsDialog::setAttachment(const QString& name, const QByteArray& data) +{ + m_name = name; + m_ui->titleEdit->setText(m_name); + + m_type = attachmentType(data); + m_data = data; + + update(); +} + +void PreviewEntryAttachmentsDialog::update() +{ + if (m_type == Tools::MimeType::Unknown) { + updateTextAttachment(tr("No preview available").toUtf8()); + } else if (m_type == Tools::MimeType::Image) { + updateImageAttachment(m_data); + } else if (m_type == Tools::MimeType::PlainText) { + updateTextAttachment(m_data); + } +} + +void PreviewEntryAttachmentsDialog::updateTextAttachment(const QByteArray& data) +{ + m_ui->attachmentTextEdit->setPlainText(QString::fromUtf8(data)); +} + +void PreviewEntryAttachmentsDialog::updateImageAttachment(const QByteArray& data) +{ + QImage image{}; + if (!image.loadFromData(data)) { + updateTextAttachment(tr("Image format not supported").toUtf8()); + return; + } + + m_ui->attachmentTextEdit->clear(); + auto cursor = m_ui->attachmentTextEdit->textCursor(); + + // Scale the image to the contents rect minus another set of margins to avoid scrollbars + auto margins = m_ui->attachmentTextEdit->contentsMargins(); + auto size = m_ui->attachmentTextEdit->contentsRect().size(); + size.setWidth(size.width() - margins.left() - margins.right()); + size.setHeight(size.height() - margins.top() - margins.bottom()); + image = image.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + + cursor.insertImage(image); +} + +Tools::MimeType PreviewEntryAttachmentsDialog::attachmentType(const QByteArray& data) const +{ + QMimeDatabase mimeDb{}; + const auto mime = mimeDb.mimeTypeForData(data); + + return Tools::toMimeType(mime.name()); +} + +void PreviewEntryAttachmentsDialog::resizeEvent(QResizeEvent* event) +{ + QDialog::resizeEvent(event); + + if (m_type == Tools::MimeType::Image) { + update(); + } +} diff --git a/src/gui/entry/PreviewEntryAttachmentsDialog.h b/src/gui/entry/PreviewEntryAttachmentsDialog.h new file mode 100644 index 0000000000..b01d1e7ddd --- /dev/null +++ b/src/gui/entry/PreviewEntryAttachmentsDialog.h @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "core/Tools.h" + +#include +#include + +namespace Ui +{ + class EntryAttachmentsDialog; +} + +class PreviewEntryAttachmentsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PreviewEntryAttachmentsDialog(QWidget* parent = nullptr); + ~PreviewEntryAttachmentsDialog() override; + + void setAttachment(const QString& name, const QByteArray& data); + +signals: + void openAttachment(const QString& name); + void saveAttachment(const QString& name); + +protected: + void resizeEvent(QResizeEvent* event) override; + +private: + Tools::MimeType attachmentType(const QByteArray& data) const; + + void update(); + void updateTextAttachment(const QByteArray& data); + void updateImageAttachment(const QByteArray& data); + + QScopedPointer m_ui; + + QString m_name; + QByteArray m_data; + Tools::MimeType m_type{Tools::MimeType::Unknown}; +}; diff --git a/src/gui/styles/base/basestyle.qss b/src/gui/styles/base/basestyle.qss index 8d40281a38..34cc283dd2 100644 --- a/src/gui/styles/base/basestyle.qss +++ b/src/gui/styles/base/basestyle.qss @@ -21,7 +21,9 @@ QCheckBox, QRadioButton { spacing: 10px; } -ReportsDialog QTableView::item { +ReportsDialog QTableView::item, +EntryAttachmentsWidget QTableView::item +{ padding: 4px; } @@ -30,8 +32,7 @@ DatabaseWidget, DatabaseWidget #groupView, DatabaseWidget #tagView { border: none; } -EntryPreviewWidget QLineEdit, EntryPreviewWidget QTextEdit, -EntryPreviewWidget TagsEdit +EntryPreviewWidget *[blendIn="true"] { background-color: palette(window); border: none; diff --git a/tests/TestTools.cpp b/tests/TestTools.cpp index fd15128035..27a468929c 100644 --- a/tests/TestTools.cpp +++ b/tests/TestTools.cpp @@ -272,3 +272,70 @@ void TestTools::testArrayContainsValues() const auto result3 = Tools::getMissingValuesFromList(numberValues, QList({6, 7, 8})); QCOMPARE(result3.length(), 3); } + +void TestTools::testMimeTypes() +{ + const QStringList TextMimeTypes = { + "text/plain", // Plain text + "text/html", // HTML documents + "text/css", // CSS stylesheets + "text/javascript", // JavaScript files + "text/markdown", // Markdown documents + "text/xml", // XML documents + "text/rtf", // Rich Text Format + "text/vcard", // vCard files + "text/tab-separated-values", // Tab-separated values + "application/json", // JSON data + "application/xml", // XML data + "application/soap+xml", // SOAP messages + "application/x-yaml", // YAML data + "application/protobuf", // Protocol Buffers + }; + + const QStringList ImageMimeTypes = { + "image/jpeg", // JPEG images + "image/png", // PNG images + "image/gif", // GIF images + "image/bmp", // BMP images + "image/webp", // WEBP images + "image/svg+xml" // SVG images + }; + + const QStringList UnknownMimeTypes = { + "audio/mpeg", // MPEG audio files + "video/mp4", // MP4 video files + "application/pdf", // PDF documents + "application/zip", // ZIP archives + "application/x-tar", // TAR archives + "application/x-rar-compressed", // RAR archives + "application/x-7z-compressed", // 7z archives + "application/x-shockwave-flash", // Adobe Flash files + "application/vnd.ms-excel", // Microsoft Excel files + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // Microsoft Excel (OpenXML) files + "application/vnd.ms-powerpoint", // Microsoft PowerPoint files + "application/vnd.openxmlformats-officedocument.presentationml.presentation", // Microsoft PowerPoint (OpenXML) + // files + "application/msword", // Microsoft Word files + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // Microsoft Word (OpenXML) files + "application/vnd.oasis.opendocument.text", // OpenDocument Text + "application/vnd.oasis.opendocument.spreadsheet", // OpenDocument Spreadsheet + "application/vnd.oasis.opendocument.presentation", // OpenDocument Presentation + "application/x-httpd-php", // PHP files + "application/x-perl", // Perl scripts + "application/x-python", // Python scripts + "application/x-ruby", // Ruby scripts + "application/x-shellscript", // Shell scripts + }; + + for (const auto& mime : TextMimeTypes) { + QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::PlainText); + } + + for (const auto& mime : ImageMimeTypes) { + QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::Image); + } + + for (const auto& mime : UnknownMimeTypes) { + QCOMPARE(Tools::toMimeType(mime), Tools::MimeType::Unknown); + } +} diff --git a/tests/TestTools.h b/tests/TestTools.h index e8a44b8b3c..5f4b6b6e09 100644 --- a/tests/TestTools.h +++ b/tests/TestTools.h @@ -37,6 +37,7 @@ private slots: void testConvertToRegex(); void testConvertToRegex_data(); void testArrayContainsValues(); + void testMimeTypes(); }; #endif // KEEPASSX_TESTTOOLS_H From 832340e209949be1ef4d057c70dc7a08450f3f36 Mon Sep 17 00:00:00 2001 From: varjolintu Date: Sun, 8 Dec 2024 13:12:56 +0200 Subject: [PATCH 6/6] Fix setting window title as modified --- share/translations/keepassxc_en.ts | 4 ++++ src/gui/MainWindow.cpp | 15 +++++---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts index 519a325bec..bc9e17f226 100644 --- a/share/translations/keepassxc_en.ts +++ b/share/translations/keepassxc_en.ts @@ -6218,6 +6218,10 @@ Expect some bugs and minor issues, this version is meant for testing purposes.Toggle Show Group Panel + + Password Generator + + ManageDatabase diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 20872d82a5..3191b45aa1 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -1033,28 +1033,23 @@ void MainWindow::updateWindowTitle() if (stackedWidgetIndex == DatabaseTabScreen && tabWidgetIndex != -1) { customWindowTitlePart = m_ui->tabWidget->tabName(tabWidgetIndex); - if (isModified) { - // remove asterisk '*' from title + if (isModified && customWindowTitlePart.endsWith("*")) { customWindowTitlePart.remove(customWindowTitlePart.size() - 1, 1); } m_ui->actionDatabaseSave->setEnabled(m_ui->tabWidget->canSave(tabWidgetIndex)); - } else if (stackedWidgetIndex == 1) { + } else if (stackedWidgetIndex == StackedWidgetIndex::SettingsScreen) { customWindowTitlePart = tr("Settings"); + } else if (stackedWidgetIndex == StackedWidgetIndex::PasswordGeneratorScreen) { + customWindowTitlePart = tr("Password Generator"); } QString windowTitle; if (customWindowTitlePart.isEmpty()) { - windowTitle = BaseWindowTitle; + windowTitle = QString("%1[*]").arg(BaseWindowTitle); } else { windowTitle = QString("%1[*] - %2").arg(customWindowTitlePart, BaseWindowTitle); } - if (customWindowTitlePart.isEmpty() || stackedWidgetIndex == 1) { - setWindowFilePath(""); - } else { - setWindowFilePath(m_ui->tabWidget->databaseWidgetFromIndex(tabWidgetIndex)->database()->filePath()); - } - setWindowTitle(windowTitle); setWindowModified(isModified);