Brings the Qt uploader to parity with the web /manage page and the Android
app. Adds a stacked Manage page (reached from the post page) that lists
posts via GET /api/photos with thumbnails from GET /api/thumb/<slug>, edits
caption/date/tags in a QDialog via POST /api/edit, and deletes to trash via
POST /api/delete with a QMessageBox confirm. As a non-browser client it sends
no Origin and so passes the server's same-origin CSRF check.
Tag autocomplete now backs every tags field (post cards and the edit dialog)
via a per-token TagCompleter (QCompleter subclass, MatchContains) fed from
the unguarded GET /api/tags.json.
New files: managepostwidget.{h,cpp}, tagcompleter.{h,cpp}; network methods
and a RemotePhoto struct added to uploadclient. Built and linked clean; not
yet runtime-tested on a real display.
281 lines
11 KiB
C++
281 lines
11 KiB
C++
#include "uploadclient.h"
|
|
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QHttpMultiPart>
|
|
#include <QHttpPart>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QNetworkAccessManager>
|
|
#include <QNetworkReply>
|
|
#include <QNetworkRequest>
|
|
#include <QUrlQuery>
|
|
|
|
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, const QString &tags)
|
|
{
|
|
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));
|
|
multiPart->append(textPart(QStringLiteral("tags"), tags));
|
|
|
|
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));
|
|
});
|
|
}
|
|
|
|
// --- Manage API ------------------------------------------------------------
|
|
|
|
namespace {
|
|
QString httpError(QNetworkReply *reply, int status)
|
|
{
|
|
if (status == 401 || status == 403)
|
|
return QStringLiteral("Wrong username or password");
|
|
if (status == 0)
|
|
return reply->errorString();
|
|
const QString body = QString::fromUtf8(reply->readAll()).trimmed();
|
|
return body.isEmpty() ? QStringLiteral("Server returned %1").arg(status) : body;
|
|
}
|
|
} // namespace
|
|
|
|
void UploadClient::listPhotos()
|
|
{
|
|
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/photos")));
|
|
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 photosListed({}, httpError(reply, status));
|
|
return;
|
|
}
|
|
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
|
if (!doc.isArray()) {
|
|
emit photosListed({}, QStringLiteral("Could not read the server's response"));
|
|
return;
|
|
}
|
|
QList<RemotePhoto> photos;
|
|
const QJsonArray arr = doc.array();
|
|
for (const QJsonValue &v : arr) {
|
|
const QJsonObject o = v.toObject();
|
|
RemotePhoto p;
|
|
p.slug = o.value(QStringLiteral("slug")).toString();
|
|
p.caption = o.value(QStringLiteral("caption")).toString();
|
|
p.date = o.value(QStringLiteral("date")).toString();
|
|
p.thumb = o.value(QStringLiteral("thumb")).toString();
|
|
p.url = o.value(QStringLiteral("url")).toString();
|
|
const QJsonArray tags = o.value(QStringLiteral("tags")).toArray();
|
|
for (const QJsonValue &t : tags)
|
|
p.tags << t.toString();
|
|
photos << p;
|
|
}
|
|
emit photosListed(photos, QString());
|
|
});
|
|
}
|
|
|
|
void UploadClient::fetchTags()
|
|
{
|
|
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/tags.json")));
|
|
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 tagsFetched({}); // best-effort; autocomplete is a convenience
|
|
return;
|
|
}
|
|
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
|
QStringList names;
|
|
for (const QJsonValue &v : doc.array()) {
|
|
const QString name = v.toObject().value(QStringLiteral("tag")).toString();
|
|
if (!name.isEmpty())
|
|
names << name;
|
|
}
|
|
emit tagsFetched(names);
|
|
});
|
|
}
|
|
|
|
void UploadClient::fetchThumbnail(const QString &slug)
|
|
{
|
|
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/thumb/") + slug));
|
|
request.setRawHeader("Authorization", authHeader());
|
|
|
|
QNetworkReply *reply = m_net->get(request);
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
|
|
reply->deleteLater();
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
|
|
emit thumbnailReady(slug, QImage()); // null image on any failure
|
|
return;
|
|
}
|
|
QImage image;
|
|
image.loadFromData(reply->readAll());
|
|
emit thumbnailReady(slug, image);
|
|
});
|
|
}
|
|
|
|
void UploadClient::editPost(const QString &slug, const QString &caption,
|
|
const QString &date, const QString &tags)
|
|
{
|
|
QUrlQuery form;
|
|
form.addQueryItem(QStringLiteral("slug"), slug);
|
|
form.addQueryItem(QStringLiteral("caption"), caption);
|
|
form.addQueryItem(QStringLiteral("date"), date);
|
|
form.addQueryItem(QStringLiteral("tags"), tags);
|
|
|
|
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/edit")));
|
|
request.setRawHeader("Authorization", authHeader());
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader,
|
|
QStringLiteral("application/x-www-form-urlencoded"));
|
|
|
|
QNetworkReply *reply =
|
|
m_net->post(request, form.toString(QUrl::FullyEncoded).toUtf8());
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
|
|
reply->deleteLater();
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
if (reply->error() == QNetworkReply::NoError && status >= 200 && status < 300)
|
|
emit editFinished(slug, QString());
|
|
else
|
|
emit editFinished(slug, httpError(reply, status));
|
|
});
|
|
}
|
|
|
|
void UploadClient::deletePost(const QString &slug)
|
|
{
|
|
QUrlQuery form;
|
|
form.addQueryItem(QStringLiteral("slug"), slug);
|
|
|
|
QNetworkRequest request(QUrl(m_base + QStringLiteral("/api/delete")));
|
|
request.setRawHeader("Authorization", authHeader());
|
|
request.setHeader(QNetworkRequest::ContentTypeHeader,
|
|
QStringLiteral("application/x-www-form-urlencoded"));
|
|
|
|
QNetworkReply *reply =
|
|
m_net->post(request, form.toString(QUrl::FullyEncoded).toUtf8());
|
|
connect(reply, &QNetworkReply::finished, this, [this, reply, slug]() {
|
|
reply->deleteLater();
|
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
|
if (reply->error() != QNetworkReply::NoError || status < 200 || status >= 300) {
|
|
emit deleteFinished(slug, httpError(reply, status));
|
|
return;
|
|
}
|
|
// The server reports how many it moved; 0 means the slug wasn't found.
|
|
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
|
|
const int deleted = doc.object().value(QStringLiteral("deleted")).toInt(0);
|
|
if (deleted > 0)
|
|
emit deleteFinished(slug, QString());
|
|
else
|
|
emit deleteFinished(slug, QStringLiteral("Nothing deleted — not found on the server"));
|
|
});
|
|
}
|