From 59744211970f455e8455b008784b1b8f36421ca2 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 27 May 2026 23:44:42 -0400 Subject: [PATCH 1/4] feat(scan): add --target alias for platform profiles (#363) Add `qtmesh scan --target ` as a CI-friendly alias for selecting a bundled platform profile. Active profile is emitted into JSON, SARIF, and text reports. Co-authored-by: Cursor --- src/CLIPipeline.cpp | 30 +++++++++++++++++++++++++----- src/CLIPipeline_test.cpp | 11 ++++++++++- src/ScanEngine.cpp | 13 +++++++++++-- src/ScanEngine.h | 5 +++-- 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/CLIPipeline.cpp b/src/CLIPipeline.cpp index b80b7319d..549a67d85 100644 --- a/src/CLIPipeline.cpp +++ b/src/CLIPipeline.cpp @@ -579,6 +579,7 @@ void CLIPipeline::printUsage() " material --list-presets List the built-in preset names\n" "\n" "Scan options:\n" + " --target Alias for --profile (CI-friendly). Built-in targets include: example-minimal, example-base\n" " --profile Built-in platform profile (e.g. example-minimal) or path to .json\n" " --list-profiles List built-in platform profile ids and exit\n" " --config Config file (default: qtmesh.yml, qtmesh.json)\n" @@ -3774,6 +3775,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) QString scanRoot; QString configPath; QString profileIdArg; + QString targetIdArg; bool listProfiles = false; QString tokenArg; bool strictUpload = false; @@ -3877,7 +3879,10 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) if (arg == "--no-upload") { noUpload = true; continue; } if (arg == "--list-profiles") { listProfiles = true; continue; } QString value; - ParseValueResult parseResult = parseValueArg(arg, "--profile", i, value); + ParseValueResult parseResult = parseValueArg(arg, "--target", i, value); + if (parseResult == ParseValueResult::Error) return 2; + if (parseResult == ParseValueResult::Matched) { targetIdArg = value; continue; } + parseResult = parseValueArg(arg, "--profile", i, value); if (parseResult == ParseValueResult::Error) return 2; if (parseResult == ParseValueResult::Matched) { profileIdArg = value; continue; } parseResult = parseValueArg(arg, "--config", i, value); @@ -4054,6 +4059,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) // 4) CLI flags (below) ScanConfig config = ScanConfig::defaults(); QVariantMap projectRoot; + QString activeProfileId; if (!configPath.isEmpty()) { if (!QFileInfo::exists(configPath)) { @@ -4109,6 +4115,15 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) } QString profileId = profileIdArg.trimmed(); + const QString targetId = targetIdArg.trimmed(); + if (!targetId.isEmpty()) { + if (!profileId.isEmpty() && profileId != targetId) { + err() << "Error: --target and --profile both provided with different values (" + << targetId << " vs " << profileId << ")" << Qt::endl; + return 2; + } + profileId = targetId; + } if (profileId.isEmpty()) profileId = projectRoot.value(QStringLiteral("profile")).toString().trimmed(); @@ -4123,6 +4138,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) for (const QString& w : loaded.warnings) err() << "Warning: " << w << Qt::endl; applyPlatformProfile(config, loaded.profile); + activeProfileId = loaded.profile.id; err() << "Note: Using platform profile '" << loaded.profile.id << "'." << Qt::endl; SentryReporter::addBreadcrumb(QStringLiteral("cli.scan"), QStringLiteral("platform profile=%1").arg(loaded.profile.id)); @@ -4292,12 +4308,16 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) } } - const QJsonObject reportJson = ScanEngine::scanReportToJsonObject(result); + QJsonObject reportJson = ScanEngine::scanReportToJsonObject(result); + if (!activeProfileId.isEmpty()) + reportJson.insert(QStringLiteral("profile"), activeProfileId); // Output to terminal if (jsonOutput) { cliWrite(QString::fromUtf8(QJsonDocument(reportJson).toJson(QJsonDocument::Indented)) + "\n"); } else { + if (!activeProfileId.isEmpty()) + cliWrite(QStringLiteral("Profile: %1\n").arg(activeProfileId)); cliWrite(formatScanSummary(result, colorizeTextOutput)); } @@ -4315,7 +4335,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) QFile f(sarifPath); QDir().mkpath(QFileInfo(sarifPath).path()); if (f.open(QIODevice::WriteOnly | QIODevice::Text)) - f.write(ScanEngine::formatSarif(result).toUtf8()); + f.write(ScanEngine::formatSarif(result, activeProfileId).toUtf8()); else err() << "Warning: Could not write SARIF report to " << sarifPath << Qt::endl; } @@ -4326,7 +4346,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) QDir().mkpath(QFileInfo(config.reportOutput).path()); if (f.open(QIODevice::WriteOnly | QIODevice::Text)) { if (config.reportFormat == "text") - f.write(ScanEngine::formatText(result, config, false).toUtf8()); + f.write(ScanEngine::formatText(result, config, false, activeProfileId).toUtf8()); else f.write(QJsonDocument(reportJson).toJson(QJsonDocument::Indented)); } @@ -4335,7 +4355,7 @@ int CLIPipeline::cmdScan(int argc, char* argv[]) QFile f(config.sarifOutput); QDir().mkpath(QFileInfo(config.sarifOutput).path()); if (f.open(QIODevice::WriteOnly | QIODevice::Text)) - f.write(ScanEngine::formatSarif(result).toUtf8()); + f.write(ScanEngine::formatSarif(result, activeProfileId).toUtf8()); } bool uploadOk = true; diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 1af743cf3..0d9408a6d 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -2606,7 +2606,8 @@ TEST(CLIPipelineCmdScan, WritesJsonAndSarifReports) QByteArray configBa = configPath.toUtf8(); QByteArray reportBa = reportPath.toUtf8(); QByteArray sarifBa = sarifPath.toUtf8(); - TestArgv args({"qtmesh", "scan", rootBa.constData(), "--config", configBa.constData(), + TestArgv args({"qtmesh", "scan", rootBa.constData(), "--target", "example-minimal", + "--config", configBa.constData(), "--json", "--report", reportBa.constData(), "--sarif", sarifBa.constData(), "--fail-on", "never"}); @@ -2621,6 +2622,7 @@ TEST(CLIPipelineCmdScan, WritesJsonAndSarifReports) EXPECT_TRUE(reportContent.contains("\"assets\"")); EXPECT_TRUE(reportContent.contains("\"scanStartedUtc\"")); EXPECT_TRUE(reportContent.contains("\"scanCompletedUtc\"")); + EXPECT_TRUE(reportContent.contains("\"profile\": \"example-minimal\"")); QFile sarifFile(sarifPath); ASSERT_TRUE(sarifFile.open(QIODevice::ReadOnly | QIODevice::Text)); @@ -2629,6 +2631,13 @@ TEST(CLIPipelineCmdScan, WritesJsonAndSarifReports) EXPECT_TRUE(sarifContent.contains("qtmesh scan")); EXPECT_TRUE(sarifContent.contains("\"startTimeUtc\"")); EXPECT_TRUE(sarifContent.contains("\"endTimeUtc\"")); + EXPECT_TRUE(sarifContent.contains("\"profile\": \"example-minimal\"")); +} + +TEST(CLIPipelineCmdScanError, UnknownTargetReturns2) +{ + TestArgv args({"qtmesh", "scan", "--target", "no-such-target"}); + EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 2); } TEST(CLIPipelineCmdScan, ReportAndSarifAreWrittenWithFailOnNever) diff --git a/src/ScanEngine.cpp b/src/ScanEngine.cpp index d7e7ac2fd..762449797 100644 --- a/src/ScanEngine.cpp +++ b/src/ScanEngine.cpp @@ -1917,12 +1917,16 @@ static QString colorizeToken(const QString& text, const char* ansiColor, bool en return QStringLiteral("\x1b[%1m%2\x1b[0m").arg(QString::fromLatin1(ansiColor), text); } -QString ScanEngine::formatText(const ScanResult& result, const ScanConfig& config, bool colorize) +QString ScanEngine::formatText(const ScanResult& result, const ScanConfig& config, bool colorize, + const QString& activeProfileId) { Q_UNUSED(config); QString out; QTextStream s(&out); + if (!activeProfileId.isEmpty()) + s << "Profile: " << activeProfileId << "\n\n"; + // Per-asset output for (const auto& asset : result.assets) { // Collect this asset's findings @@ -2174,7 +2178,7 @@ QString ScanEngine::formatJson(const ScanResult& result) // SARIF formatter (Static Analysis Results Interchange Format 2.1.0) // --------------------------------------------------------------------------- -QString ScanEngine::formatSarif(const ScanResult& result) +QString ScanEngine::formatSarif(const ScanResult& result, const QString& activeProfileId) { // Build rule definitions from unique rule IDs QMap ruleDescriptions; @@ -2275,6 +2279,11 @@ QString ScanEngine::formatSarif(const ScanResult& result) QJsonObject run; run["tool"] = tool; + if (!activeProfileId.isEmpty()) { + QJsonObject props; + props["profile"] = activeProfileId; + run["properties"] = props; + } run["results"] = resultsArr; QString sarifStart, sarifEnd; scanReportUtcTimes(result, &sarifStart, &sarifEnd); diff --git a/src/ScanEngine.h b/src/ScanEngine.h index 190c46ac0..2b93628fe 100644 --- a/src/ScanEngine.h +++ b/src/ScanEngine.h @@ -149,7 +149,8 @@ class ScanEngine { // --- Formatters --- - static QString formatText(const ScanResult& result, const ScanConfig& config, bool colorize = false); + static QString formatText(const ScanResult& result, const ScanConfig& config, bool colorize = false, + const QString& activeProfileId = QString()); /// Canonical JSON object for `--json`, report files, and QtMesh Cloud upload (identical schema). static QJsonObject scanReportToJsonObject(const ScanResult& result); @@ -159,7 +160,7 @@ class ScanEngine { /// UTC ISO-8601 timestamps for reports (`scanStartedUtc` / `scanCompletedUtc`); missing values use `scanCompletedUtc` or current UTC. static void scanReportUtcTimes(const ScanResult& result, QString* scanStartedUtc, QString* scanCompletedUtc); static QString formatJson(const ScanResult& result); - static QString formatSarif(const ScanResult& result); + static QString formatSarif(const ScanResult& result, const QString& activeProfileId = QString()); // --- Helpers (public for testing) --- From 6f53e9c81e3efb51724e0dd8430eb483c6924917 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 28 May 2026 09:30:59 -0400 Subject: [PATCH 2/4] fix(tests): avoid scan JSON stdout in report test Co-authored-by: Cursor --- src/CLIPipeline_test.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CLIPipeline_test.cpp b/src/CLIPipeline_test.cpp index 0d9408a6d..f319b03a7 100644 --- a/src/CLIPipeline_test.cpp +++ b/src/CLIPipeline_test.cpp @@ -2608,7 +2608,7 @@ TEST(CLIPipelineCmdScan, WritesJsonAndSarifReports) QByteArray sarifBa = sarifPath.toUtf8(); TestArgv args({"qtmesh", "scan", rootBa.constData(), "--target", "example-minimal", "--config", configBa.constData(), - "--json", "--report", reportBa.constData(), "--sarif", sarifBa.constData(), + "--report", reportBa.constData(), "--sarif", sarifBa.constData(), "--fail-on", "never"}); EXPECT_EQ(CLIPipeline::cmdScan(args.argc(), args.argv()), 0); From 7a4004acb35e0e7fa2377fe652c2c96e87297ee8 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 28 May 2026 17:06:05 -0400 Subject: [PATCH 3/4] Add QtMesh Cloud asset upload client --- src/QtMeshCloudClient.cpp | 447 +++++++++++++++++++++++++++++++++ src/QtMeshCloudClient.h | 101 ++++++++ src/QtMeshCloudClient_test.cpp | 63 +++++ 3 files changed, 611 insertions(+) diff --git a/src/QtMeshCloudClient.cpp b/src/QtMeshCloudClient.cpp index 3f614deeb..858761f5f 100644 --- a/src/QtMeshCloudClient.cpp +++ b/src/QtMeshCloudClient.cpp @@ -2,6 +2,9 @@ #include "SentryReporter.h" #include +#include +#include +#include #include #include #include @@ -26,6 +29,42 @@ bool httpStatusRetryable(int code) return code == 408 || code == 429 || code == 500 || code == 502 || code == 503 || code == 504; } +QNetworkRequest authorizedJsonRequest(const QUrl& url, const QString& bearerToken, int timeoutMs) +{ + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json")); + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesheditor")); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8()); + req.setTransferTimeout(timeoutMs); + return req; +} + +QString ownerProjectPath(const QString& ownerSlug, const QString& projectSlug, const QString& suffix) +{ + return QStringLiteral("/v1/u/%1/p/%2/%3") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(ownerSlug)), + QString::fromUtf8(QUrl::toPercentEncoding(projectSlug)), + suffix); +} + +bool parseJsonObjectBody(const QByteArray& body, QJsonObject& out, QString& error) +{ + QJsonParseError perr{}; + const QJsonDocument doc = QJsonDocument::fromJson(body, &perr); + if (perr.error != QJsonParseError::NoError || !doc.isObject()) { + error = QStringLiteral("invalid JSON: %1").arg(perr.errorString()); + return false; + } + out = doc.object(); + return true; +} + +QString pathLeaf(const QString& path) +{ + const QString name = QFileInfo(path).fileName(); + return name.isEmpty() ? path : name; +} + } // namespace QString QtMeshCloudClient::apiBaseUrl() @@ -240,3 +279,411 @@ QtMeshCloudClient::UploadResult QtMeshCloudClient::uploadScanReport(const QStrin QStringLiteral("QtMesh Cloud uploadScan: exhausted retries"), QStringLiteral("warning")); return out; } + +QtMeshCloudClient::ProjectResult QtMeshCloudClient::createProject(const QString& bearerToken, + const QString& name, + const QString& slug, + const QString& description, + int timeoutMs) +{ + ProjectResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + if (name.trimmed().isEmpty() || slug.trimmed().isEmpty()) { + out.errorString = QStringLiteral("project name and slug are required"); + return out; + } + + const QUrl url(apiBaseUrl() + QStringLiteral("/v1/projects")); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QJsonObject body; + body.insert(QStringLiteral("name"), name.trimmed()); + body.insert(QStringLiteral("slug"), slug.trimmed().toLower()); + if (!description.trimmed().isEmpty()) + body.insert(QStringLiteral("description"), description.trimmed()); + + QNetworkAccessManager nam; + QNetworkRequest req = authorizedJsonRequest(url, bearerToken, timeoutMs); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + + SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"), + QStringLiteral("QtMesh Cloud createProject: start slug=%1").arg(slug.trimmed().toLower())); + + QNetworkReply* reply = nam.post(req, payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) { + out.responseBodySnippet = trimSnippet(responseBody); + out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus); + if (!out.responseBodySnippet.isEmpty()) + out.errorString += QStringLiteral(" — ") + out.responseBodySnippet; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"), + QStringLiteral("QtMesh Cloud createProject: failure HTTP %1").arg(out.httpStatus), + QStringLiteral("warning")); + return out; + } + + QJsonObject root; + if (!parseJsonObjectBody(responseBody, root, out.errorString)) + return out; + const QJsonObject project = root.value(QStringLiteral("project")).toObject(); + out.projectId = project.value(QStringLiteral("id")).toString(); + out.ownerSlug = project.value(QStringLiteral("ownerSlug")).toString(); + out.projectSlug = project.value(QStringLiteral("slug")).toString(slug.trimmed().toLower()); + if (!out.ownerSlug.isEmpty() && !out.projectSlug.isEmpty()) { + out.projectUrl = QStringLiteral("https://qtmesh.dev/%1/%2") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(out.ownerSlug)), + QString::fromUtf8(QUrl::toPercentEncoding(out.projectSlug))); + } + out.ok = !out.projectId.isEmpty() && !out.ownerSlug.isEmpty() && !out.projectSlug.isEmpty(); + if (!out.ok) + out.errorString = QStringLiteral("response missing project id, ownerSlug, or slug"); + else + SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"), + QStringLiteral("QtMesh Cloud createProject: ok")); + return out; +} + +QtMeshCloudClient::UploadUrlsResult QtMeshCloudClient::requestUploadUrls( + const QString& bearerToken, + const QString& ownerSlug, + const QString& projectSlug, + const QList& files, + int timeoutMs) +{ + UploadUrlsResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + if (ownerSlug.isEmpty() || projectSlug.isEmpty()) { + out.errorString = QStringLiteral("owner and project slugs are required"); + return out; + } + if (files.isEmpty()) { + out.errorString = QStringLiteral("at least one file is required"); + return out; + } + + QJsonArray fileArray; + for (const AssetFileDescriptor& file : files) { + const QFileInfo info(file.path); + const qint64 size = file.sizeBytes >= 0 ? file.sizeBytes : info.size(); + if (size <= 0) { + out.errorString = QStringLiteral("file size must be greater than zero: %1").arg(pathLeaf(file.path)); + return out; + } + + QJsonObject f; + f.insert(QStringLiteral("name"), file.uploadName.isEmpty() ? pathLeaf(file.path) : file.uploadName); + f.insert(QStringLiteral("sizeBytes"), size); + if (!file.role.isEmpty()) + f.insert(QStringLiteral("role"), file.role); + if (!file.mimeType.isEmpty()) + f.insert(QStringLiteral("mimeType"), file.mimeType); + fileArray.append(f); + } + + QJsonObject body; + body.insert(QStringLiteral("files"), fileArray); + + const QString path = ownerProjectPath(ownerSlug, projectSlug, QStringLiteral("files/upload-urls")); + const QUrl url(apiBaseUrl() + path); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QNetworkAccessManager nam; + QNetworkRequest req = authorizedJsonRequest(url, bearerToken, timeoutMs); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud requestUploadUrls: start files=%1").arg(files.size())); + + QNetworkReply* reply = nam.post(req, payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) { + out.responseBodySnippet = trimSnippet(responseBody); + out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus); + if (!out.responseBodySnippet.isEmpty()) + out.errorString += QStringLiteral(" — ") + out.responseBodySnippet; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud requestUploadUrls: failure HTTP %1").arg(out.httpStatus), + QStringLiteral("warning")); + return out; + } + + QJsonObject root; + if (!parseJsonObjectBody(responseBody, root, out.errorString)) + return out; + out.uploadMethod = root.value(QStringLiteral("uploadMethod")).toString(QStringLiteral("PUT")); + out.expiresAt = static_cast(root.value(QStringLiteral("expiresAt")).toDouble(0)); + const QJsonArray uploads = root.value(QStringLiteral("uploads")).toArray(); + for (const QJsonValue& value : uploads) { + const QJsonObject u = value.toObject(); + UploadTarget target; + target.fileId = u.value(QStringLiteral("id")).toString(); + target.uploadUrl = u.value(QStringLiteral("uploadUrl")).toString(); + target.sanitizedName = u.value(QStringLiteral("sanitizedName")).toString(); + target.role = u.value(QStringLiteral("role")).toString(); + target.extension = u.value(QStringLiteral("extension")).toString(); + target.mimeType = u.value(QStringLiteral("mimeType")).toString(); + target.sizeBytes = static_cast(u.value(QStringLiteral("sizeBytes")).toDouble(0)); + target.expiresAt = static_cast(u.value(QStringLiteral("expiresAt")).toDouble(out.expiresAt)); + if (!target.fileId.isEmpty() && !target.uploadUrl.isEmpty()) + out.uploads.append(target); + } + + out.ok = out.uploads.size() == files.size(); + if (!out.ok) + out.errorString = QStringLiteral("response upload count did not match request"); + else + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud requestUploadUrls: ok files=%1").arg(out.uploads.size())); + return out; +} + +QtMeshCloudClient::FileUploadResult QtMeshCloudClient::uploadFileContent( + const QString& bearerToken, + const UploadTarget& target, + const QString& localPath, + int timeoutMs) +{ + FileUploadResult out; + out.fileId = target.fileId; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + if (target.uploadUrl.isEmpty() || target.fileId.isEmpty()) { + out.errorString = QStringLiteral("upload target is incomplete"); + return out; + } + + QFile file(localPath); + if (!file.open(QIODevice::ReadOnly)) { + out.errorString = QStringLiteral("could not open file: %1").arg(pathLeaf(localPath)); + return out; + } + const QByteArray payload = file.readAll(); + file.close(); + if (target.sizeBytes > 0 && payload.size() != target.sizeBytes) { + out.errorString = QStringLiteral("file size changed before upload: %1").arg(pathLeaf(localPath)); + return out; + } + + const QUrl url(target.uploadUrl); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid upload URL"); + return out; + } + + QNetworkAccessManager nam; + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesheditor")); + req.setHeader(QNetworkRequest::ContentLengthHeader, payload.size()); + if (!target.mimeType.isEmpty()) + req.setHeader(QNetworkRequest::ContentTypeHeader, target.mimeType); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8()); + req.setTransferTimeout(timeoutMs); + + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud uploadFileContent: start fileId=%1 bytes=%2") + .arg(target.fileId, QString::number(payload.size()))); + + QNetworkReply* reply = nam.put(req, payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) { + out.responseBodySnippet = trimSnippet(responseBody); + out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus); + if (!out.responseBodySnippet.isEmpty()) + out.errorString += QStringLiteral(" — ") + out.responseBodySnippet; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud uploadFileContent: failure HTTP %1").arg(out.httpStatus), + QStringLiteral("warning")); + return out; + } + + QJsonObject root; + QString parseError; + if (parseJsonObjectBody(responseBody, root, parseError)) + out.sizeBytes = static_cast(root.value(QStringLiteral("sizeBytes")).toDouble(payload.size())); + else + out.sizeBytes = payload.size(); + out.ok = true; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud uploadFileContent: ok fileId=%1").arg(target.fileId)); + return out; +} + +QtMeshCloudClient::CompleteUploadResult QtMeshCloudClient::completeUpload( + const QString& bearerToken, + const QString& ownerSlug, + const QString& projectSlug, + const QStringList& fileIds, + const QString& mainFileId, + int timeoutMs) +{ + CompleteUploadResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + if (ownerSlug.isEmpty() || projectSlug.isEmpty() || fileIds.isEmpty()) { + out.errorString = QStringLiteral("owner slug, project slug, and fileIds are required"); + return out; + } + + QJsonArray ids; + for (const QString& id : fileIds) + ids.append(id); + QJsonObject body; + body.insert(QStringLiteral("fileIds"), ids); + if (!mainFileId.isEmpty()) + body.insert(QStringLiteral("mainFileId"), mainFileId); + + const QString path = ownerProjectPath(ownerSlug, projectSlug, QStringLiteral("files/complete")); + const QUrl url(apiBaseUrl() + path); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QNetworkAccessManager nam; + QNetworkRequest req = authorizedJsonRequest(url, bearerToken, timeoutMs); + const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact); + + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud completeUpload: start files=%1").arg(fileIds.size())); + + QNetworkReply* reply = nam.post(req, payload); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) { + out.responseBodySnippet = trimSnippet(responseBody); + out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus); + if (!out.responseBodySnippet.isEmpty()) + out.errorString += QStringLiteral(" — ") + out.responseBodySnippet; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud completeUpload: failure HTTP %1").arg(out.httpStatus), + QStringLiteral("warning")); + return out; + } + + QJsonObject root; + if (!parseJsonObjectBody(responseBody, root, out.errorString)) + return out; + out.scanStatus = root.value(QStringLiteral("scanStatus")).toString(); + const QJsonArray files = root.value(QStringLiteral("files")).toArray(); + for (const QJsonValue& value : files) { + const QString id = value.toObject().value(QStringLiteral("id")).toString(); + if (!id.isEmpty()) + out.fileIds.append(id); + } + out.ok = true; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), + QStringLiteral("QtMesh Cloud completeUpload: ok files=%1").arg(out.fileIds.size())); + return out; +} + +QtMeshCloudClient::ManifestResult QtMeshCloudClient::fetchProjectManifest(const QString& bearerToken, + const QString& ownerSlug, + const QString& projectSlug, + int timeoutMs) +{ + ManifestResult out; + if (bearerToken.isEmpty()) { + out.errorString = QStringLiteral("missing bearer token"); + return out; + } + if (ownerSlug.isEmpty() || projectSlug.isEmpty()) { + out.errorString = QStringLiteral("owner and project slugs are required"); + return out; + } + + const QString path = ownerProjectPath(ownerSlug, projectSlug, QStringLiteral("manifest")); + const QUrl url(apiBaseUrl() + path); + if (!url.isValid()) { + out.errorString = QStringLiteral("invalid API base URL"); + return out; + } + + QNetworkAccessManager nam; + QNetworkRequest req(url); + req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesheditor")); + req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8()); + req.setTransferTimeout(timeoutMs); + + SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"), + QStringLiteral("QtMesh Cloud fetchProjectManifest: start")); + + QNetworkReply* reply = nam.get(req); + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + out.httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray responseBody = reply->readAll(); + const auto nerr = reply->error(); + const QString transportErr = reply->errorString(); + reply->deleteLater(); + + if (nerr != QNetworkReply::NoError || out.httpStatus < 200 || out.httpStatus >= 300) { + out.responseBodySnippet = trimSnippet(responseBody); + out.errorString = nerr != QNetworkReply::NoError ? transportErr : QStringLiteral("HTTP %1").arg(out.httpStatus); + if (!out.responseBodySnippet.isEmpty()) + out.errorString += QStringLiteral(" — ") + out.responseBodySnippet; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"), + QStringLiteral("QtMesh Cloud fetchProjectManifest: failure HTTP %1").arg(out.httpStatus), + QStringLiteral("warning")); + return out; + } + + if (!parseJsonObjectBody(responseBody, out.manifest, out.errorString)) + return out; + out.ok = true; + SentryReporter::addBreadcrumb(QStringLiteral("cloud.project"), + QStringLiteral("QtMesh Cloud fetchProjectManifest: ok")); + return out; +} diff --git a/src/QtMeshCloudClient.h b/src/QtMeshCloudClient.h index 4f167f495..5d7a7e935 100644 --- a/src/QtMeshCloudClient.h +++ b/src/QtMeshCloudClient.h @@ -2,6 +2,7 @@ #define QTMESH_CLOUD_CLIENT_H #include +#include #include /// HTTP client for QtMesh Cloud ingest API (remote rules + scan upload). @@ -35,6 +36,106 @@ class QtMeshCloudClient { /// POST /v1/ingest/scan with compact JSON body (same schema as `qtmesh scan --json`). static UploadResult uploadScanReport(const QString& bearerToken, const QJsonObject& reportJson, int timeoutMs = 120000); + + struct ProjectResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + QString ownerSlug; + QString projectSlug; + QString projectId; + QString projectUrl; + }; + + /// POST /v1/projects — creates a private cloud project owned by the authenticated user. + static ProjectResult createProject(const QString& bearerToken, + const QString& name, + const QString& slug, + const QString& description = QString(), + int timeoutMs = 30000); + + struct AssetFileDescriptor { + QString path; + QString uploadName; + QString role; + QString mimeType; + qint64 sizeBytes = -1; + }; + + struct UploadTarget { + QString fileId; + QString uploadUrl; + QString sanitizedName; + QString role; + QString extension; + QString mimeType; + qint64 sizeBytes = 0; + qint64 expiresAt = 0; + }; + + struct UploadUrlsResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + QString uploadMethod; + qint64 expiresAt = 0; + QList uploads; + }; + + /// POST /v1/u/:owner/p/:project/files/upload-urls. + static UploadUrlsResult requestUploadUrls(const QString& bearerToken, + const QString& ownerSlug, + const QString& projectSlug, + const QList& files, + int timeoutMs = 30000); + + struct FileUploadResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + QString fileId; + qint64 sizeBytes = 0; + }; + + /// PUT binary content to an upload URL returned by requestUploadUrls(). + static FileUploadResult uploadFileContent(const QString& bearerToken, + const UploadTarget& target, + const QString& localPath, + int timeoutMs = 120000); + + struct CompleteUploadResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + QString scanStatus; + QStringList fileIds; + }; + + /// POST /v1/u/:owner/p/:project/files/complete. + static CompleteUploadResult completeUpload(const QString& bearerToken, + const QString& ownerSlug, + const QString& projectSlug, + const QStringList& fileIds, + const QString& mainFileId = QString(), + int timeoutMs = 30000); + + struct ManifestResult { + bool ok = false; + int httpStatus = 0; + QString errorString; + QString responseBodySnippet; + QJsonObject manifest; + }; + + /// GET /v1/u/:owner/p/:project/manifest — listing/download handoff foundation. + static ManifestResult fetchProjectManifest(const QString& bearerToken, + const QString& ownerSlug, + const QString& projectSlug, + int timeoutMs = 30000); }; #endif diff --git a/src/QtMeshCloudClient_test.cpp b/src/QtMeshCloudClient_test.cpp index f3527c70d..1c954e184 100644 --- a/src/QtMeshCloudClient_test.cpp +++ b/src/QtMeshCloudClient_test.cpp @@ -135,3 +135,66 @@ TEST(QtMeshCloudClientUploadScan, MissingTokenReturnsErrorImmediately) EXPECT_FALSE(result.ok); EXPECT_TRUE(result.errorString.contains("missing bearer token", Qt::CaseInsensitive)); } + +TEST(QtMeshCloudClientCreateProject, MissingTokenReturnsErrorImmediately) +{ + auto result = QtMeshCloudClient::createProject(QString(), QStringLiteral("Project"), QStringLiteral("project"), + QString(), /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("missing bearer token", Qt::CaseInsensitive)); +} + +TEST(QtMeshCloudClientCreateProject, MissingNameOrSlugReturnsErrorImmediately) +{ + auto result = QtMeshCloudClient::createProject(QStringLiteral("token"), QString(), QStringLiteral("project"), + QString(), /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("name and slug", Qt::CaseInsensitive)); +} + +TEST(QtMeshCloudClientRequestUploadUrls, MissingTokenReturnsErrorImmediately) +{ + QList files; + QtMeshCloudClient::AssetFileDescriptor file; + file.path = QStringLiteral("model.obj"); + file.sizeBytes = 128; + files.append(file); + + auto result = QtMeshCloudClient::requestUploadUrls(QString(), QStringLiteral("me"), QStringLiteral("project"), + files, /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("missing bearer token", Qt::CaseInsensitive)); +} + +TEST(QtMeshCloudClientRequestUploadUrls, EmptyFileListReturnsErrorImmediately) +{ + auto result = QtMeshCloudClient::requestUploadUrls(QStringLiteral("token"), QStringLiteral("me"), + QStringLiteral("project"), {}, /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("at least one file", Qt::CaseInsensitive)); +} + +TEST(QtMeshCloudClientUploadFileContent, IncompleteTargetReturnsErrorImmediately) +{ + QtMeshCloudClient::UploadTarget target; + auto result = QtMeshCloudClient::uploadFileContent(QStringLiteral("token"), target, + QStringLiteral("model.obj"), /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("upload target", Qt::CaseInsensitive)); +} + +TEST(QtMeshCloudClientCompleteUpload, MissingFileIdsReturnsErrorImmediately) +{ + auto result = QtMeshCloudClient::completeUpload(QStringLiteral("token"), QStringLiteral("me"), + QStringLiteral("project"), {}, QString(), /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("fileIds", Qt::CaseInsensitive)); +} + +TEST(QtMeshCloudClientFetchManifest, MissingOwnerReturnsErrorImmediately) +{ + auto result = QtMeshCloudClient::fetchProjectManifest(QStringLiteral("token"), QString(), + QStringLiteral("project"), /*timeoutMs=*/100); + EXPECT_FALSE(result.ok); + EXPECT_TRUE(result.errorString.contains("owner", Qt::CaseInsensitive)); +} From 4dd6f06fbe6b965216914ec3b528f4b732b19666 Mon Sep 17 00:00:00 2001 From: Fernando Date: Thu, 28 May 2026 17:37:27 -0400 Subject: [PATCH 4/4] Stream QtMesh Cloud file uploads --- src/QtMeshCloudClient.cpp | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/QtMeshCloudClient.cpp b/src/QtMeshCloudClient.cpp index 858761f5f..8409ed490 100644 --- a/src/QtMeshCloudClient.cpp +++ b/src/QtMeshCloudClient.cpp @@ -1,6 +1,7 @@ #include "QtMeshCloudClient.h" #include "SentryReporter.h" +#include #include #include #include @@ -483,15 +484,19 @@ QtMeshCloudClient::FileUploadResult QtMeshCloudClient::uploadFileContent( out.errorString = QStringLiteral("upload target is incomplete"); return out; } + if (QCoreApplication::instance() + && QThread::currentThread() == QCoreApplication::instance()->thread()) { + out.errorString = QStringLiteral("uploadFileContent must run on a worker thread"); + return out; + } QFile file(localPath); if (!file.open(QIODevice::ReadOnly)) { out.errorString = QStringLiteral("could not open file: %1").arg(pathLeaf(localPath)); return out; } - const QByteArray payload = file.readAll(); - file.close(); - if (target.sizeBytes > 0 && payload.size() != target.sizeBytes) { + const qint64 fileSize = file.size(); + if (target.sizeBytes > 0 && fileSize != target.sizeBytes) { out.errorString = QStringLiteral("file size changed before upload: %1").arg(pathLeaf(localPath)); return out; } @@ -505,7 +510,7 @@ QtMeshCloudClient::FileUploadResult QtMeshCloudClient::uploadFileContent( QNetworkAccessManager nam; QNetworkRequest req(url); req.setHeader(QNetworkRequest::UserAgentHeader, QStringLiteral("qtmesheditor")); - req.setHeader(QNetworkRequest::ContentLengthHeader, payload.size()); + req.setHeader(QNetworkRequest::ContentLengthHeader, fileSize); if (!target.mimeType.isEmpty()) req.setHeader(QNetworkRequest::ContentTypeHeader, target.mimeType); req.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + bearerToken.toUtf8()); @@ -513,9 +518,9 @@ QtMeshCloudClient::FileUploadResult QtMeshCloudClient::uploadFileContent( SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), QStringLiteral("QtMesh Cloud uploadFileContent: start fileId=%1 bytes=%2") - .arg(target.fileId, QString::number(payload.size()))); + .arg(target.fileId, QString::number(fileSize))); - QNetworkReply* reply = nam.put(req, payload); + QNetworkReply* reply = nam.put(req, &file); QEventLoop loop; QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); loop.exec(); @@ -540,9 +545,9 @@ QtMeshCloudClient::FileUploadResult QtMeshCloudClient::uploadFileContent( QJsonObject root; QString parseError; if (parseJsonObjectBody(responseBody, root, parseError)) - out.sizeBytes = static_cast(root.value(QStringLiteral("sizeBytes")).toDouble(payload.size())); + out.sizeBytes = static_cast(root.value(QStringLiteral("sizeBytes")).toDouble(fileSize)); else - out.sizeBytes = payload.size(); + out.sizeBytes = fileSize; out.ok = true; SentryReporter::addBreadcrumb(QStringLiteral("cloud.upload"), QStringLiteral("QtMesh Cloud uploadFileContent: ok fileId=%1").arg(target.fileId));