Add Qt6 desktop uploader
A native Qt6/C++ desktop client for the gallery's POST /upload API, in desktop/ with its own flake (kept separate from the Go server module the same way android/ is). Single-photo parity with the Android app: - multipart upload with preemptive HTTP Basic auth and retry-aware outcome classification (uploadclient) - JPEG/PNG metadata stripped byte-for-byte to drop embedded GPS, EXIF orientation baked in, and a quality/scale valve for the 20M nginx cap; HEIC decodes via the qtimageformats plugin (imageprep) - base URL / username in QSettings, password in the system keychain via qtkeychain; https required and a trailing /upload stripped (settings) - settings and post screens in a QStackedWidget (mainwindow) Native Wayland or xcb is selected at runtime. Bulk upload, EXIF capture-date prefill, and moving image prep off the UI thread are left as follow-ups.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
#include "uploadclient.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QHttpMultiPart>
|
||||
#include <QHttpPart>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
|
||||
UploadClient::UploadClient(QString baseUrl, QString username, QString password, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_base(std::move(baseUrl))
|
||||
, m_username(std::move(username))
|
||||
, m_password(std::move(password))
|
||||
, m_net(new QNetworkAccessManager(this))
|
||||
{
|
||||
while (m_base.endsWith(QLatin1Char('/')))
|
||||
m_base.chop(1);
|
||||
}
|
||||
|
||||
QByteArray UploadClient::authHeader() const
|
||||
{
|
||||
const QByteArray raw = (m_username + QLatin1Char(':') + m_password).toUtf8();
|
||||
return "Basic " + raw.toBase64();
|
||||
}
|
||||
|
||||
void UploadClient::upload(const QString &filePath, const QString &fileName,
|
||||
const QString &caption, const QString &date)
|
||||
{
|
||||
auto *file = new QFile(filePath);
|
||||
if (!file->open(QIODevice::ReadOnly)) {
|
||||
delete file;
|
||||
emit uploadFinished(Result::Rejected, {}, {}, QStringLiteral("Could not read the prepared image"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
|
||||
const QByteArray contentType =
|
||||
fileName.endsWith(QStringLiteral(".png"), Qt::CaseInsensitive) ? "image/png" : "image/jpeg";
|
||||
|
||||
QHttpPart imagePart;
|
||||
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant(contentType));
|
||||
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QVariant(QStringLiteral("form-data; name=\"image\"; filename=\"%1\"").arg(fileName)));
|
||||
file->setParent(multiPart);
|
||||
imagePart.setBodyDevice(file);
|
||||
multiPart->append(imagePart);
|
||||
|
||||
const auto textPart = [](const QString &name, const QString &value) {
|
||||
QHttpPart part;
|
||||
part.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QVariant(QStringLiteral("form-data; name=\"%1\"").arg(name)));
|
||||
part.setBody(value.toUtf8());
|
||||
return part;
|
||||
};
|
||||
multiPart->append(textPart(QStringLiteral("caption"), caption));
|
||||
multiPart->append(textPart(QStringLiteral("date"), date));
|
||||
|
||||
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
|
||||
request.setRawHeader("Authorization", authHeader());
|
||||
|
||||
QNetworkReply *reply = m_net->post(request, multiPart);
|
||||
multiPart->setParent(reply);
|
||||
|
||||
const QString fallbackSlug = QFileInfo(fileName).completeBaseName();
|
||||
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply, fallbackSlug]() {
|
||||
reply->deleteLater();
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
|
||||
|
||||
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
|
||||
// Server replies "uploaded slug.ext"; recover the slug.
|
||||
QString name = body;
|
||||
if (name.startsWith(QStringLiteral("uploaded ")))
|
||||
name = name.mid(QStringLiteral("uploaded ").size()).trimmed();
|
||||
const int dot = name.lastIndexOf(QLatin1Char('.'));
|
||||
QString slug = dot > 0 ? name.left(dot) : name;
|
||||
if (slug.isEmpty())
|
||||
slug = fallbackSlug;
|
||||
emit uploadFinished(Result::Success, slug, m_base + QStringLiteral("/photo/") + slug + QLatin1Char('/'), {});
|
||||
return;
|
||||
}
|
||||
if (status == 401 || status == 403) {
|
||||
emit uploadFinished(Result::AuthFailed, {}, {}, QStringLiteral("Wrong username or password"));
|
||||
return;
|
||||
}
|
||||
if (status >= 500 && status < 600) {
|
||||
emit uploadFinished(Result::Transient, {}, {}, QStringLiteral("Server error %1").arg(status));
|
||||
return;
|
||||
}
|
||||
if (status == 0) {
|
||||
// No HTTP response — a socket/TLS failure. Worth retrying.
|
||||
emit uploadFinished(Result::Transient, {}, {}, reply->errorString());
|
||||
return;
|
||||
}
|
||||
emit uploadFinished(Result::Rejected, {}, {}, body.isEmpty() ? QStringLiteral("Upload rejected") : body);
|
||||
});
|
||||
}
|
||||
|
||||
void UploadClient::testConnection()
|
||||
{
|
||||
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
|
||||
request.setRawHeader("Authorization", authHeader());
|
||||
|
||||
QNetworkReply *reply = m_net->get(request);
|
||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||
reply->deleteLater();
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300) {
|
||||
emit testFinished(QString());
|
||||
return;
|
||||
}
|
||||
if (status == 401 || status == 403) {
|
||||
emit testFinished(QStringLiteral("Wrong username or password"));
|
||||
return;
|
||||
}
|
||||
if (status == 0) {
|
||||
emit testFinished(reply->errorString());
|
||||
return;
|
||||
}
|
||||
emit testFinished(QStringLiteral("Server returned %1").arg(status));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user