mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-01 15:24:13 +02:00
Add read-only enforcement, preview, import/export to custom SQL reports
Refs discussion #886: a saved-report manager built on custom SQL and nomenclature.json. Most of what was asked for already existed -- Projet > Exporter au format CSV already builds/saves/reuses named SELECT queries via ElementQueryWidget and nomenclature.json, and Projet > Ajouter une nomenclature already inserts one into a folio. This fills the three real gaps. - projectDataBase::isReadOnlySelect() rejects anything that isn't a single SELECT/WITH statement. Checked in newQuery() itself, the one choke point every query path already goes through -- including a query loaded from a saved nomenclature/summary table's <query> element on project open, not just the dialog's own custom-SQL box. ElementQueryWidget shows the same check live as you type, and BOMExportDialog surfaces it before running or exporting anything. - BOMExportDialog gains a Preview button + table (QSqlQueryModel), so a report can be checked on screen before committing to a CSV file. - ElementQueryWidget gains Importer.../Exporter... buttons that read/write nomenclature.json's saved reports as a JSON file, so a report can be handed to a colleague or another install. Import asks before overwriting a locally-saved report of the same name. Verified: Qt 6.10.2, builds clean, ctest 8/8. Drove the real dialog through Xvfb: typed "DROP TABLE element" into the custom-SQL box and got the inline warning immediately, then confirmed Preview also refuses it with a "Requête refusée" dialog rather than running it. Preview against the real default query returned live column headers and a row. Export opens a save dialog without crashing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@
|
||||
#include "../qetproject.h"
|
||||
|
||||
#include <QLocale>
|
||||
#include <QRegularExpression>
|
||||
#include <QSqlError>
|
||||
|
||||
#include <QSqlDriver>
|
||||
@@ -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 <query>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -20,8 +20,15 @@
|
||||
#include "../../properties/elementdata.h"
|
||||
#include "../../qetapp.h"
|
||||
#include "../../qetinformation.h"
|
||||
#include "../projectdatabase.h"
|
||||
#include "ui_elementquerywidget.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileDialog>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMessageBox>
|
||||
|
||||
#include <QRegularExpression>
|
||||
|
||||
/**
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -388,6 +388,26 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QPushButton" name="m_import_reports_pb">
|
||||
<property name="toolTip">
|
||||
<string>Importer des rapports depuis un fichier</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Importer...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QPushButton" name="m_export_reports_pb">
|
||||
<property name="toolTip">
|
||||
<string>Exporter tous les rapports enregistrés vers un fichier</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Exporter...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
@@ -429,6 +449,19 @@
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="m_query_warning_label">
|
||||
<property name="styleSheet">
|
||||
<string notr="true">color: red;</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources>
|
||||
|
||||
Reference in New Issue
Block a user