diff --git a/sources/dataBase/projectdatabase.cpp b/sources/dataBase/projectdatabase.cpp index bd0f0069d..20d2c150d 100644 --- a/sources/dataBase/projectdatabase.cpp +++ b/sources/dataBase/projectdatabase.cpp @@ -28,6 +28,7 @@ #include "../qetproject.h" #include +#include #include #include @@ -132,11 +133,88 @@ QETProject *projectDataBase::project() const } /** - @brief projectDataBase::newQuery - @return a QSqlquery with query as query - and the internal database of this class as database to use. + @brief projectDataBase::isReadOnlySelect + Every query that reaches newQuery() goes through this check first -- + including one loaded from a saved nomenclature/summary table's + element (ProjectDBModel::fromXml()), which makes this a defense against + a crafted project file, not just a careless custom-SQL edit + (qelectrotech-source-mirror#886 asked for read-only enforcement on the + custom SQL reports feature; this covers every path into newQuery(), not + just that one dialog). + + Deliberately simple rather than a real SQL parser: reject more than one + statement (blocks stacking a write after a leading SELECT with `;`), and + require the query to start with SELECT or WITH. A determined attacker + with arbitrary SQL access to a local SQLite connection can still find + tricks a simple prefix check won't catch; this is meant to stop the + ordinary mistake and the obvious payload, not to be a security boundary + against a hostile file assumed to already run in some other trust + context. + @param query the raw SQL text to check + @param error set to a human-readable reason when this returns false + @return true if @p query looks like a single read-only SELECT/WITH */ -QSqlQuery projectDataBase::newQuery(const QString &query) { +bool projectDataBase::isReadOnlySelect(const QString &query, QString *error) +{ + if (error) { + error->clear(); + } + + QString trimmed = query.trimmed(); + if (trimmed.endsWith(QLatin1Char(';'))) { + trimmed.chop(1); + trimmed = trimmed.trimmed(); + } + + if (trimmed.isEmpty()) { + if (error) { + *error = projectDataBase::tr("La requête est vide."); + } + return false; + } + + if (trimmed.contains(QLatin1Char(';'))) { + if (error) { + *error = projectDataBase::tr("Une seule requête SELECT est autorisée" + " (le caractère ';' ne peut apparaître" + " qu'à la toute fin)."); + } + return false; + } + + const int first_space = trimmed.indexOf(QRegularExpression(QStringLiteral("\\s"))); + const QString first_word = (first_space == -1 ? trimmed : trimmed.left(first_space)).toUpper(); + if (first_word != QLatin1String("SELECT") && first_word != QLatin1String("WITH")) { + if (error) { + *error = projectDataBase::tr("Seules les requêtes en lecture seule" + " (SELECT ou WITH ... SELECT) sont" + " autorisées."); + } + return false; + } + + return true; +} + +/** + @brief projectDataBase::newQuery + @param query the SQL text to run -- must be a single read-only + SELECT/WITH statement, see isReadOnlySelect() + @param error set to a human-readable reason when the query was rejected + before ever reaching the database + @return a QSqlQuery with query as query and the internal database of + this class as database to use, or an unexecuted, harmless QSqlQuery if + the query was rejected +*/ +QSqlQuery projectDataBase::newQuery(const QString &query, QString *error) { + QString reason; + if (!isReadOnlySelect(query, &reason)) { + qWarning().noquote() << "projectDataBase::newQuery: rejected query:" << reason << "--" << query; + if (error) { + *error = reason; + } + return QSqlQuery(m_data_base); + } return QSqlQuery(query, m_data_base); } diff --git a/sources/dataBase/projectdatabase.h b/sources/dataBase/projectdatabase.h index 2ce9266bb..abd5e966c 100644 --- a/sources/dataBase/projectdatabase.h +++ b/sources/dataBase/projectdatabase.h @@ -59,7 +59,8 @@ class projectDataBase : public QObject */ void setUpdateBlocked(bool blocked); QETProject *project() const; - QSqlQuery newQuery(const QString &query = QString()); + QSqlQuery newQuery(const QString &query = QString(), QString *error = nullptr); + static bool isReadOnlySelect(const QString &query, QString *error = nullptr); QSqlDatabase database() const {return m_data_base;} int excludedConductorCount() const; diff --git a/sources/dataBase/ui/elementquerywidget.cpp b/sources/dataBase/ui/elementquerywidget.cpp index aafdeb32d..f5a42beb7 100644 --- a/sources/dataBase/ui/elementquerywidget.cpp +++ b/sources/dataBase/ui/elementquerywidget.cpp @@ -20,8 +20,15 @@ #include "../../properties/elementdata.h" #include "../../qetapp.h" #include "../../qetinformation.h" +#include "../projectdatabase.h" #include "ui_elementquerywidget.h" +#include +#include +#include +#include +#include + #include /** @@ -94,6 +101,9 @@ ElementQueryWidget::ElementQueryWidget(QWidget *parent) : setUpItems(); fillSavedQuery(); + + connect(ui->m_sql_query, &QLineEdit::textChanged, this, &ElementQueryWidget::checkQueryValidity); + checkQueryValidity(); } /** @@ -599,6 +609,8 @@ void ElementQueryWidget::on_m_edit_sql_query_cb_clicked() m_custom_query = ui->m_sql_query->text(); updateQueryLine(); } + + checkQueryValidity(); } /** @@ -672,6 +684,10 @@ void ElementQueryWidget::on_m_load_pb_clicked() */ void ElementQueryWidget::on_m_save_current_conf_pb_clicked() { + if (!projectDataBase::isReadOnlySelect(queryStr())) { + return; + } + QFile file_(QETApp::configDir() % "/nomenclature.json"); if (file_.open(QFile::ReadWrite)) @@ -698,7 +714,147 @@ void ElementQueryWidget::on_m_save_current_conf_pb_clicked() } void ElementQueryWidget::on_m_save_name_le_textChanged(const QString &arg1) { - ui->m_save_current_conf_pb->setDisabled(arg1.isEmpty()); + Q_UNUSED(arg1) + checkQueryValidity(); +} + +/** + @brief ElementQueryWidget::checkQueryValidity + Live feedback for the custom-SQL box: only the free-text path can carry + anything other than a SELECT (the column/filter builder always produces + one), so this only actually restricts something once "Requête SQL + personnalisée" is checked. Same rule projectDataBase::isReadOnlySelect() + enforces at execution time -- this just tells the user *before* they hit + Preview/Export/OK instead of after (qelectrotech-source-mirror#886). +*/ +void ElementQueryWidget::checkQueryValidity() +{ + if (!ui->m_edit_sql_query_cb->isChecked()) { + ui->m_query_warning_label->clear(); + ui->m_save_current_conf_pb->setEnabled(!ui->m_save_name_le->text().isEmpty()); + return; + } + + QString reason; + const bool valid = projectDataBase::isReadOnlySelect(ui->m_sql_query->text(), &reason); + ui->m_query_warning_label->setText(valid ? QString() : reason); + ui->m_save_current_conf_pb->setEnabled(valid && !ui->m_save_name_le->text().isEmpty()); +} + +/** + @brief ElementQueryWidget::on_m_export_reports_pb_clicked + Write every saved report in nomenclature.json to a file the user picks, + so it can be handed to a colleague or another install + (qelectrotech-source-mirror#886's "import and export report definitions + for sharing between users or company installations"). +*/ +void ElementQueryWidget::on_m_export_reports_pb_clicked() +{ + QFile source(QETApp::configDir() % "/nomenclature.json"); + if (!source.open(QFile::ReadOnly)) { + QMessageBox::information(this, tr("Exporter"), tr("Aucun rapport enregistré à exporter.")); + return; + } + const auto content = source.readAll(); + source.close(); + + if (QJsonDocument::fromJson(content).object().isEmpty()) { + QMessageBox::information(this, tr("Exporter"), tr("Aucun rapport enregistré à exporter.")); + return; + } + + const QString file_path = QFileDialog::getSaveFileName( + this, tr("Exporter les rapports"), QStringLiteral("rapports_qet.json"), + tr("Fichiers JSON (*.json)")); + if (file_path.isEmpty()) { + return; + } + + QFile dest(file_path); + if (!dest.open(QFile::WriteOnly) || dest.write(content) == -1) { + QMessageBox::critical(this, tr("Erreur"), tr("Impossible d'écrire dans %1.").arg(file_path)); + } +} + +/** + @brief ElementQueryWidget::on_m_import_reports_pb_clicked + Merge a previously-exported reports file into this install's + nomenclature.json. A name already used locally is not overwritten + silently -- the user is asked, per report, whether to replace it. +*/ +void ElementQueryWidget::on_m_import_reports_pb_clicked() +{ + const QString file_path = QFileDialog::getOpenFileName( + this, tr("Importer des rapports"), QString(), tr("Fichiers JSON (*.json)")); + if (file_path.isEmpty()) { + return; + } + + QFile source(file_path); + if (!source.open(QFile::ReadOnly)) { + QMessageBox::critical(this, tr("Erreur"), tr("Impossible de lire %1.").arg(file_path)); + return; + } + + QJsonParseError parse_error; + const auto incoming_doc = QJsonDocument::fromJson(source.readAll(), &parse_error); + source.close(); + + if (parse_error.error != QJsonParseError::NoError || !incoming_doc.isObject()) { + QMessageBox::critical( + this, tr("Erreur"), + tr("%1 ne contient pas des rapports QElectroTech valides.").arg(file_path)); + return; + } + + const auto incoming = incoming_doc.object(); + if (incoming.isEmpty()) { + QMessageBox::information(this, tr("Importer"), tr("Ce fichier ne contient aucun rapport.")); + return; + } + + QFile dest_file(QETApp::configDir() % "/nomenclature.json"); + QJsonObject existing; + if (dest_file.open(QFile::ReadOnly)) { + existing = QJsonDocument::fromJson(dest_file.readAll()).object(); + dest_file.close(); + } + + int imported = 0, skipped = 0; + for (auto it = incoming.begin(); it != incoming.end(); ++it) + { + if (existing.contains(it.key())) + { + const auto answer = QMessageBox::question( + this, tr("Rapport déjà existant"), + tr("Un rapport nommé « %1 » existe déjà. Le remplacer ?").arg(it.key()), + QMessageBox::Yes | QMessageBox::No); + if (answer != QMessageBox::Yes) { + ++skipped; + continue; + } + } + existing[it.key()] = it.value(); + ++imported; + } + + if (dest_file.open(QFile::WriteOnly | QFile::Truncate)) + { + dest_file.write(QJsonDocument(existing).toJson()); + dest_file.close(); + } + else + { + QMessageBox::critical(this, tr("Erreur"), tr("Impossible d'écrire la configuration locale.")); + return; + } + + ui->m_conf_cb->clear(); + fillSavedQuery(); + + QMessageBox::information( + this, tr("Importer"), + tr("%1 rapport(s) importé(s), %2 ignoré(s).").arg(imported).arg(skipped)); } void ElementQueryWidget::on_m_choosen_list_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous) diff --git a/sources/dataBase/ui/elementquerywidget.h b/sources/dataBase/ui/elementquerywidget.h index b44377e12..f89883793 100644 --- a/sources/dataBase/ui/elementquerywidget.h +++ b/sources/dataBase/ui/elementquerywidget.h @@ -59,6 +59,9 @@ class ElementQueryWidget : public QWidget void on_m_load_pb_clicked(); void on_m_save_current_conf_pb_clicked(); void on_m_save_name_le_textChanged(const QString &arg1); + void on_m_import_reports_pb_clicked(); + void on_m_export_reports_pb_clicked(); + void checkQueryValidity(); void on_m_choosen_list_currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous); void on_m_var_list_itemDoubleClicked(QListWidgetItem *item); void on_m_choosen_list_itemDoubleClicked(QListWidgetItem *item); diff --git a/sources/dataBase/ui/elementquerywidget.ui b/sources/dataBase/ui/elementquerywidget.ui index 7f3ae30fb..8de65df47 100644 --- a/sources/dataBase/ui/elementquerywidget.ui +++ b/sources/dataBase/ui/elementquerywidget.ui @@ -388,6 +388,26 @@ + + + + Importer des rapports depuis un fichier + + + Importer... + + + + + + + Exporter tous les rapports enregistrés vers un fichier + + + Exporter... + + + @@ -429,6 +449,19 @@ + + + + color: red; + + + + + + true + + + diff --git a/sources/ui/bomexportdialog.cpp b/sources/ui/bomexportdialog.cpp index e2ea6ce77..da2be53ca 100644 --- a/sources/ui/bomexportdialog.cpp +++ b/sources/ui/bomexportdialog.cpp @@ -26,6 +26,7 @@ #include #include +#include #include /** @@ -44,6 +45,9 @@ BOMExportDialog::BOMExportDialog(QETProject *project, QWidget *parent) : ui->m_main_layout->insertWidget(0, m_query_widget); m_query_widget->setQuery(BomExport::defaultQuery()); on_m_format_as_bom_clicked(false); + + m_preview_model = new QSqlQueryModel(this); + ui->m_preview_table->setModel(m_preview_model); } /** @@ -89,7 +93,15 @@ QByteArray BOMExportDialog::getBom(QString *error) error->clear(); } m_project->dataBase()->updateDB(); - auto query_ = m_project->dataBase()->newQuery(m_query_widget->queryStr()); + QString rejection; + auto query_ = m_project->dataBase()->newQuery(m_query_widget->queryStr(), &rejection); + + if (!rejection.isEmpty()) { + if (error) { + *error = rejection; + } + return {}; + } if (!query_.exec()) { qDebug() << "BOMExportDialog::getBom : query errir : " << query_.lastError(); @@ -130,3 +142,39 @@ void BOMExportDialog::on_m_format_as_bom_clicked(bool checked) { m_query_widget->setGroupBy("designation", checked); m_query_widget->setCount("COUNT(*) AS designation_qty", checked); } + +/** + @brief BOMExportDialog::on_m_preview_pb_clicked + Run the current query and show its result live, without going through + the CSV round-trip -- lets a report be checked and adjusted before + committing to a file (qelectrotech-source-mirror#886). +*/ +void BOMExportDialog::on_m_preview_pb_clicked() +{ + m_project->dataBase()->updateDB(); + QString rejection; + auto query_ = m_project->dataBase()->newQuery(m_query_widget->queryStr(), &rejection); + + if (!rejection.isEmpty()) { + QMessageBox::warning(this, tr("Requête refusée"), rejection); + return; + } + + if (!query_.exec()) { + QMessageBox::warning( + this, tr("Erreur"), + tr("Erreur dans la requête :\n%1").arg(query_.lastError().text())); + return; + } + + m_preview_model->setQuery(std::move(query_)); + for (int i = 0; i < m_preview_model->columnCount(); ++i) + { + const auto field_name = m_preview_model->record().fieldName(i); + const auto translated = QETInformation::translatedInfoKey(field_name); + if (!translated.isEmpty()) { + m_preview_model->setHeaderData(i, Qt::Horizontal, translated); + } + } + ui->m_preview_table->resizeColumnsToContents(); +} diff --git a/sources/ui/bomexportdialog.h b/sources/ui/bomexportdialog.h index 36fa40d15..0c5a0743e 100644 --- a/sources/ui/bomexportdialog.h +++ b/sources/ui/bomexportdialog.h @@ -23,6 +23,7 @@ class QETProject; class ElementQueryWidget; +class QSqlQueryModel; namespace Ui { class BOMExportDialog; @@ -44,11 +45,13 @@ class BOMExportDialog : public QDialog private slots: void on_m_format_as_bom_clicked(bool checked); + void on_m_preview_pb_clicked(); private: Ui::BOMExportDialog *ui; ElementQueryWidget *m_query_widget = nullptr; QETProject *m_project = nullptr; + QSqlQueryModel *m_preview_model = nullptr; }; #endif // BOMEXPORTDIALOG_H diff --git a/sources/ui/bomexportdialog.ui b/sources/ui/bomexportdialog.ui index b9e60b329..04b673f46 100644 --- a/sources/ui/bomexportdialog.ui +++ b/sources/ui/bomexportdialog.ui @@ -7,7 +7,7 @@ 0 0 610 - 232 + 480 @@ -56,6 +56,37 @@ + + + + + + Aperçu + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + QAbstractItemView::NoEditTriggers + + +