From d07e3eb0dc34b5162449e113de1318260f4337a7 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Wed, 26 Aug 2026 00:28:35 +0200 Subject: [PATCH] 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. --- desktop/.gitignore | 3 + desktop/CMakeLists.txt | 23 ++++ desktop/flake.lock | 61 ++++++++++ desktop/flake.nix | 38 ++++++ desktop/imageprep.cpp | 237 ++++++++++++++++++++++++++++++++++++ desktop/imageprep.h | 37 ++++++ desktop/main.cpp | 15 +++ desktop/mainwindow.cpp | 257 +++++++++++++++++++++++++++++++++++++++ desktop/mainwindow.h | 53 ++++++++ desktop/settings.cpp | 113 +++++++++++++++++ desktop/settings.h | 52 ++++++++ desktop/uploadclient.cpp | 126 +++++++++++++++++++ desktop/uploadclient.h | 51 ++++++++ 13 files changed, 1066 insertions(+) create mode 100644 desktop/.gitignore create mode 100644 desktop/CMakeLists.txt create mode 100644 desktop/flake.lock create mode 100644 desktop/flake.nix create mode 100644 desktop/imageprep.cpp create mode 100644 desktop/imageprep.h create mode 100644 desktop/main.cpp create mode 100644 desktop/mainwindow.cpp create mode 100644 desktop/mainwindow.h create mode 100644 desktop/settings.cpp create mode 100644 desktop/settings.h create mode 100644 desktop/uploadclient.cpp create mode 100644 desktop/uploadclient.h diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..658cd88 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,3 @@ +/build/ +/.direnv/ +.envrc diff --git a/desktop/CMakeLists.txt b/desktop/CMakeLists.txt new file mode 100644 index 0000000..7913910 --- /dev/null +++ b/desktop/CMakeLists.txt @@ -0,0 +1,23 @@ +cmake_minimum_required(VERSION 3.20) +project(photogallery_uploader LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Widgets Network) +find_package(Qt6Keychain REQUIRED) + +add_executable(uploader + main.cpp + mainwindow.cpp + settings.cpp + uploadclient.cpp + imageprep.cpp +) + +target_link_libraries(uploader PRIVATE + Qt6::Widgets + Qt6::Network + Qt6Keychain::Qt6Keychain +) diff --git a/desktop/flake.lock b/desktop/flake.lock new file mode 100644 index 0000000..effd00b --- /dev/null +++ b/desktop/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1787498568, + "narHash": "sha256-9i/VTdusq/+NM/tz+J1Re+ojkMB8MBf0QshnYfzHz30=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "56c02bc00adcf003215cc4bd996d6efaf4cff188", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/desktop/flake.nix b/desktop/flake.nix new file mode 100644 index 0000000..2a27e5f --- /dev/null +++ b/desktop/flake.nix @@ -0,0 +1,38 @@ +{ + description = "photogallery uploader — Qt6 C++ desktop app"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + qtPlugins = pkgs.lib.makeSearchPathOutput "out" "lib/qt-6/plugins" [ + pkgs.qt6.qtbase + pkgs.qt6.qtwayland + pkgs.qt6.qtimageformats + ]; + in { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + cmake + ninja + pkg-config + qt6.qtbase + qt6.qtwayland # native Wayland QPA plugin + qt6.qtimageformats # HEIF/HEIC + others, read by QImage + qt6Packages.qtkeychain # password → libsecret / kwallet + ]; + + shellHook = '' + # Let a binary run straight from the build dir find Qt's platform + # (xcb + wayland) and image-format plugins without wrapQtAppsHook. + export QT_PLUGIN_PATH="${qtPlugins}''${QT_PLUGIN_PATH:+:$QT_PLUGIN_PATH}" + ''; + }; + }); +} diff --git a/desktop/imageprep.cpp b/desktop/imageprep.cpp new file mode 100644 index 0000000..3cfdeff --- /dev/null +++ b/desktop/imageprep.cpp @@ -0,0 +1,237 @@ +#include "imageprep.h" + +#include +#include +#include +#include +#include +#include + +namespace { +// nginx is configured with client_max_body_size 20M; leave headroom. +constexpr qint64 kMaxUploadBytes = 18LL * 1024 * 1024; +constexpr int kJpegQuality = 92; +const int kQualityLadder[] = {92, 85, 75}; + +// Encodes to JPEG in memory at the given quality. +QByteArray encodeJpeg(const QImage &image, int quality) +{ + QByteArray out; + QBuffer buffer(&out); + buffer.open(QIODevice::WriteOnly); + QImageWriter writer(&buffer, "jpeg"); + writer.setQuality(quality); + writer.write(image); + return out; +} + +// Decodes with EXIF orientation baked into the pixels. Works for HEIC too, via +// the qtimageformats plugin. +QImage decodeOriented(const QString &path) +{ + QImageReader reader(path); + reader.setAutoTransform(true); + return reader.read(); +} +} // namespace + +ImagePrep::Prepared ImagePrep::prepare(const QString &sourcePath, const QString &cacheDir, + const QString &baseName) +{ + Prepared result; + + QFile in(sourcePath); + if (!in.open(QIODevice::ReadOnly)) { + result.error = QStringLiteral("Could not read the selected image"); + return result; + } + const QByteArray source = in.readAll(); + in.close(); + + QStringList notes; + + // A rotation flag cannot survive metadata stripping, so bake it into the + // pixels instead, which costs a re-encode. + QImageReader orientationReader(sourcePath); + const bool mustRotate = + orientationReader.transformation() != QImageIOHandler::TransformationNone; + + QByteArray bytes; + QString extension; + + if (!mustRotate && isJpeg(source)) { + bytes = stripJpegSegments(source); + extension = QStringLiteral("jpg"); + } else if (!mustRotate && isPng(source)) { + bytes = stripPngChunks(source); + extension = QStringLiteral("png"); + } + + if (bytes.isEmpty()) { + // Re-encode path: rotated originals, HEIC, or a JPEG/PNG whose marker + // structure did not parse. + const QImage image = decodeOriented(sourcePath); + if (image.isNull()) { + result.error = QStringLiteral("Could not decode the selected image"); + return result; + } + if (mustRotate) + notes << QStringLiteral("rotated to match its EXIF orientation"); + else if (!isJpeg(source) && !isPng(source)) + notes << QStringLiteral("converted to JPEG"); + bytes = encodeJpeg(image, kJpegQuality); + extension = QStringLiteral("jpg"); + } + + // Size valve. Only trips on genuinely huge originals; quality is stepped + // before touching resolution. + if (bytes.size() > kMaxUploadBytes) { + QImage image = decodeOriented(sourcePath); + if (image.isNull()) { + result.error = QStringLiteral("Could not decode the selected image"); + return result; + } + QByteArray shrunk; + for (int quality : kQualityLadder) { + const QByteArray attempt = encodeJpeg(image, quality); + if (attempt.size() <= kMaxUploadBytes) { + shrunk = attempt; + notes << QStringLiteral("re-encoded at quality %1 to fit the 20M upload limit").arg(quality); + break; + } + } + if (shrunk.isEmpty()) { + double factor = 1.0; + while (factor > 0.2) { + factor *= 0.75; + const QImage scaled = image.scaledToWidth( + qMax(1, int(image.width() * factor)), Qt::SmoothTransformation); + const QByteArray attempt = encodeJpeg(scaled, kQualityLadder[2]); + if (attempt.size() <= kMaxUploadBytes) { + shrunk = attempt; + notes << QStringLiteral("scaled to %1×%2 to fit the 20M upload limit") + .arg(scaled.width()).arg(scaled.height()); + break; + } + } + } + if (shrunk.isEmpty()) { + result.error = QStringLiteral("Image is too large to upload even after downscaling"); + return result; + } + bytes = shrunk; + extension = QStringLiteral("jpg"); + } + + const QString outPath = QDir(cacheDir).filePath(baseName + QLatin1Char('.') + extension); + QFile out(outPath); + if (!out.open(QIODevice::WriteOnly) || out.write(bytes) != bytes.size()) { + result.error = QStringLiteral("Could not write the prepared image"); + return result; + } + out.close(); + + result.filePath = outPath; + result.note = notes.join(QStringLiteral("; ")); + return result; +} + +bool ImagePrep::isJpeg(const QByteArray &b) +{ + return b.size() > 3 && quint8(b[0]) == 0xFF && quint8(b[1]) == 0xD8 && quint8(b[2]) == 0xFF; +} + +bool ImagePrep::isPng(const QByteArray &b) +{ + return b.size() > 8 && quint8(b[0]) == 0x89 && quint8(b[1]) == 0x50 && quint8(b[2]) == 0x4E + && quint8(b[3]) == 0x47; +} + +// Drops APP1 (EXIF and XMP) and COM segments, keeping the compressed scan data +// byte-for-byte. APP0/JFIF and APP2/ICC stay: removing the colour profile would +// shift the rendered colours. Returns an empty array if the marker structure +// does not parse, so the caller falls back to a re-encode. +QByteArray ImagePrep::stripJpegSegments(const QByteArray &src) +{ + const auto u8 = [&](int i) { return quint8(src[i]); }; + QByteArray out; + out.append(char(0xFF)); + out.append(char(0xD8)); + + int i = 2; + while (i + 1 < src.size()) { + if (u8(i) != 0xFF) + return {}; + const int marker = u8(i + 1); + + // Standalone markers: no length field, no payload. + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { + out.append(char(0xFF)); + out.append(char(marker)); + i += 2; + continue; + } + + // Start of scan: entropy-coded data runs to the end of the file. + if (marker == 0xDA) { + out.append(src.mid(i)); + return out; + } + + if (i + 3 >= src.size()) + return {}; + const int length = (u8(i + 2) << 8) | u8(i + 3); + if (length < 2 || i + 2 + length > src.size()) + return {}; + + const bool isExifOrXmp = marker == 0xE1; + const bool isComment = marker == 0xFE; + if (!isExifOrXmp && !isComment) + out.append(src.mid(i, 2 + length)); + + i += 2 + length; + } + return {}; +} + +// Drops the metadata chunks, keeps everything else including the critical +// IHDR/PLTE/IDAT/IEND run and colour chunks (iCCP, sRGB). Whole chunks go, so no +// CRC needs recomputing. +QByteArray ImagePrep::stripPngChunks(const QByteArray &src) +{ + static const QByteArray drop[] = {"eXIf", "tEXt", "zTXt", "iTXt", "tIME"}; + const auto u8 = [&](int i) { return quint8(src[i]); }; + if (src.size() < 8) + return {}; + + QByteArray out; + out.append(src.mid(0, 8)); + + int i = 8; + while (i + 8 <= src.size()) { + const qint64 length = + (qint64(u8(i)) << 24) | (qint64(u8(i + 1)) << 16) | (qint64(u8(i + 2)) << 8) | qint64(u8(i + 3)); + if (length < 0) + return {}; + + const QByteArray type = src.mid(i + 4, 4); + const qint64 total = 12 + length; + if (i + total > src.size()) + return {}; + + bool dropped = false; + for (const QByteArray &d : drop) { + if (type == d) { + dropped = true; + break; + } + } + if (!dropped) + out.append(src.mid(i, int(total))); + i += int(total); + + if (type == "IEND") + return out; + } + return {}; +} diff --git a/desktop/imageprep.h b/desktop/imageprep.h new file mode 100644 index 0000000..c70ed38 --- /dev/null +++ b/desktop/imageprep.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +// Turns a picked image into a file the gallery's upload API accepts. +// +// Three things must be true of the result: no EXIF (the gallery publishes the +// uploaded file verbatim as its full-size download, so embedded GPS would be +// public), a .jpg or .png extension (server.go rejects anything else, HEIC +// included), and under nginx's 20M body cap. +// +// JPEG and PNG take a lossless route — whole metadata segments are dropped +// without touching the compressed image data — so the published file keeps the +// original sensor pixels. Anything else, or a file whose EXIF orientation must +// be baked in, is decoded and re-encoded as JPEG (HEIC via Qt's qtimageformats +// plugin). The strippers are a direct port of the Android app's. +class ImagePrep +{ +public: + struct Prepared { + QString filePath; + QString note; // non-empty when the app departed from a byte-exact copy + QString error; + bool ok() const { return error.isEmpty(); } + }; + + // Writes the upload-ready file into cacheDir as baseName.(jpg|png). + static Prepared prepare(const QString &sourcePath, const QString &cacheDir, + const QString &baseName); + +private: + static QByteArray stripJpegSegments(const QByteArray &src); // empty on parse failure + static QByteArray stripPngChunks(const QByteArray &src); + static bool isJpeg(const QByteArray &b); + static bool isPng(const QByteArray &b); +}; diff --git a/desktop/main.cpp b/desktop/main.cpp new file mode 100644 index 0000000..dd2280c --- /dev/null +++ b/desktop/main.cpp @@ -0,0 +1,15 @@ +#include + +#include "mainwindow.h" + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + app.setApplicationName(QStringLiteral("photogallery-uploader")); + app.setOrganizationName(QStringLiteral("inflo.ws")); + + MainWindow window; + window.resize(440, 680); + window.show(); + return app.exec(); +} diff --git a/desktop/mainwindow.cpp b/desktop/mainwindow.cpp new file mode 100644 index 0000000..c842d45 --- /dev/null +++ b/desktop/mainwindow.cpp @@ -0,0 +1,257 @@ +#include "mainwindow.h" + +#include "imageprep.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr int kPreviewSize = 360; +} + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) +{ + setWindowTitle(QStringLiteral("Photo Gallery Uploader")); + + m_stack = new QStackedWidget(this); + m_stack->addWidget(buildSettingsPage()); // index 0 + m_stack->addWidget(buildPostPage()); // index 1 + setCentralWidget(m_stack); + + // Land on settings until there is somewhere to post to. + if (m_settings.isConfigured()) + showPost(); + else + showSettings(); +} + +QWidget *MainWindow::buildSettingsPage() +{ + auto *page = new QWidget; + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(24, 24, 24, 24); + + auto *form = new QFormLayout; + m_baseUrl = new QLineEdit; + m_baseUrl->setPlaceholderText(QStringLiteral("https://photos.example.com")); + m_username = new QLineEdit; + m_password = new QLineEdit; + m_password->setEchoMode(QLineEdit::Password); + form->addRow(QStringLiteral("Base URL"), m_baseUrl); + form->addRow(QStringLiteral("Username"), m_username); + form->addRow(QStringLiteral("Password"), m_password); + layout->addLayout(form); + + m_saveButton = new QPushButton(QStringLiteral("Save && test connection")); + connect(m_saveButton, &QPushButton::clicked, this, &MainWindow::saveAndTest); + layout->addWidget(m_saveButton); + + m_settingsStatus = new QLabel; + m_settingsStatus->setWordWrap(true); + layout->addWidget(m_settingsStatus); + + m_settingsBack = new QPushButton(QStringLiteral("Back")); + connect(m_settingsBack, &QPushButton::clicked, this, &MainWindow::showPost); + layout->addWidget(m_settingsBack); + + layout->addWidget(new QLabel(QStringLiteral( + "The password is stored in your system keychain, never in plain text. " + "https is required — Basic auth would otherwise send it in the clear."))); + layout->addStretch(); + return page; +} + +QWidget *MainWindow::buildPostPage() +{ + auto *page = new QWidget; + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(24, 24, 24, 24); + + m_preview = new QLabel(QStringLiteral("No photo selected")); + m_preview->setAlignment(Qt::AlignCenter); + m_preview->setFixedSize(kPreviewSize, kPreviewSize); + m_preview->setFrameShape(QFrame::Box); + layout->addWidget(m_preview, 0, Qt::AlignHCenter); + + m_chooseButton = new QPushButton(QStringLiteral("Choose a photo…")); + connect(m_chooseButton, &QPushButton::clicked, this, &MainWindow::chooseImage); + layout->addWidget(m_chooseButton); + + auto *form = new QFormLayout; + m_caption = new QLineEdit; + m_date = new QDateEdit(QDate::currentDate()); + m_date->setCalendarPopup(true); + m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd")); + form->addRow(QStringLiteral("Caption"), m_caption); + form->addRow(QStringLiteral("Date"), m_date); + layout->addLayout(form); + + m_postButton = new QPushButton(QStringLiteral("Post")); + m_postButton->setEnabled(false); + connect(m_postButton, &QPushButton::clicked, this, &MainWindow::submit); + layout->addWidget(m_postButton); + + m_postStatus = new QLabel; + m_postStatus->setWordWrap(true); + layout->addWidget(m_postStatus); + + m_lastPost = new QLabel; + m_lastPost->setOpenExternalLinks(true); + m_lastPost->setWordWrap(true); + layout->addWidget(m_lastPost); + + auto *settingsButton = new QPushButton(QStringLiteral("Settings")); + connect(settingsButton, &QPushButton::clicked, this, &MainWindow::showSettings); + layout->addWidget(settingsButton); + + layout->addStretch(); + return page; +} + +void MainWindow::showSettings() +{ + m_baseUrl->setText(m_settings.baseUrl()); + m_username->setText(m_settings.username()); + m_password->setText(m_settings.readPassword()); + m_settingsStatus->clear(); + m_settingsBack->setVisible(m_settings.isConfigured()); + m_stack->setCurrentIndex(0); +} + +void MainWindow::showPost() +{ + const Settings::LastPost last = m_settings.lastPost(); + if (last.valid()) { + m_lastPost->setText(QStringLiteral("Last post: %2") + .arg(last.url, last.caption.isEmpty() ? last.slug : last.caption)); + } else { + m_lastPost->clear(); + } + m_stack->setCurrentIndex(1); +} + +void MainWindow::saveAndTest() +{ + const QString urlError = Settings::validateBaseUrl(m_baseUrl->text()); + if (!urlError.isEmpty()) { + m_settingsStatus->setText(urlError); + return; + } + if (m_username->text().trimmed().isEmpty() || m_password->text().isEmpty()) { + m_settingsStatus->setText(QStringLiteral("Username and password are required")); + return; + } + + m_settings.setBaseUrl(m_baseUrl->text()); + m_settings.setUsername(m_username->text()); + m_settings.writePassword(m_password->text()); + m_baseUrl->setText(m_settings.baseUrl()); // show normalised value + + m_saveButton->setEnabled(false); + m_settingsStatus->setText(QStringLiteral("Testing…")); + + auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(), + m_settings.readPassword(), this); + connect(client, &UploadClient::testFinished, this, [this, client](const QString &error) { + m_saveButton->setEnabled(true); + m_settingsBack->setVisible(m_settings.isConfigured()); + m_settingsStatus->setText(error.isEmpty() + ? QStringLiteral("Saved — server reachable and credentials accepted") + : error); + client->deleteLater(); + }); + client->testConnection(); +} + +void MainWindow::chooseImage() +{ + const QString path = QFileDialog::getOpenFileName( + this, QStringLiteral("Choose a photo"), QString(), + QStringLiteral("Images (*.jpg *.jpeg *.png *.heic *.heif *.webp *.tif *.tiff *.bmp);;All files (*)")); + if (path.isEmpty()) + return; + + m_imagePath = path; + + QImageReader reader(path); + reader.setAutoTransform(true); + const QImage image = reader.read(); + if (image.isNull()) { + m_preview->setText(QStringLiteral("Could not preview this image")); + } else { + m_preview->setPixmap(QPixmap::fromImage(image).scaled( + kPreviewSize, kPreviewSize, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + m_postStatus->clear(); + m_postButton->setEnabled(true); +} + +void MainWindow::submit() +{ + if (m_imagePath.isEmpty()) + return; + if (!m_settings.isConfigured()) { + m_postStatus->setText(QStringLiteral("Set the server URL and credentials first")); + return; + } + + m_postButton->setEnabled(false); + m_postStatus->setText(QStringLiteral("Preparing…")); + + // Slug from wall-clock time: posts are made one at a time, so this cannot + // collide, and re-sending the same filename overwrites the same post. The + // real capture date still travels in the date field. + const QString slug = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd-HHmmss")); + const QString cacheDir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + + const ImagePrep::Prepared prepared = ImagePrep::prepare(m_imagePath, cacheDir, slug); + if (!prepared.ok()) { + m_postStatus->setText(prepared.error); + m_postButton->setEnabled(true); + return; + } + + m_postStatus->setText(QStringLiteral("Uploading…")); + + const QString caption = m_caption->text(); + const QString date = m_date->date().toString(QStringLiteral("yyyy-MM-dd")); + const QString fileName = QFileInfo(prepared.filePath).fileName(); + + auto *client = new UploadClient(m_settings.baseUrl(), m_settings.username(), + m_settings.readPassword(), this); + connect(client, &UploadClient::uploadFinished, this, + [this, client, prepared, caption](UploadClient::Result result, const QString &slug, + const QString &url, const QString &message) { + QFile::remove(prepared.filePath); // no retry queue in this MVP + m_postButton->setEnabled(true); + + if (result == UploadClient::Result::Success) { + Settings::LastPost last{slug, url, caption, QDateTime::currentDateTime()}; + m_settings.setLastPost(last); + QString msg = QStringLiteral("Posted. View").arg(url); + if (!prepared.note.isEmpty()) + msg += QStringLiteral("
%1").arg(prepared.note); + m_postStatus->setText(msg); + m_postStatus->setOpenExternalLinks(true); + } else { + m_postStatus->setText(message); + } + client->deleteLater(); + }); + client->upload(prepared.filePath, fileName, caption, date); +} diff --git a/desktop/mainwindow.h b/desktop/mainwindow.h new file mode 100644 index 0000000..2cd4a89 --- /dev/null +++ b/desktop/mainwindow.h @@ -0,0 +1,53 @@ +#pragma once + +#include + +#include "settings.h" +#include "uploadclient.h" + +class QDateEdit; +class QLabel; +class QLineEdit; +class QPushButton; +class QStackedWidget; + +class MainWindow : public QMainWindow +{ + Q_OBJECT +public: + explicit MainWindow(QWidget *parent = nullptr); + +private: + QWidget *buildSettingsPage(); + QWidget *buildPostPage(); + + void showSettings(); + void showPost(); + + void saveAndTest(); + void chooseImage(); + void submit(); + + Settings m_settings; + + QStackedWidget *m_stack = nullptr; + + // Settings page + QLineEdit *m_baseUrl = nullptr; + QLineEdit *m_username = nullptr; + QLineEdit *m_password = nullptr; + QPushButton *m_saveButton = nullptr; + QPushButton *m_settingsBack = nullptr; + QLabel *m_settingsStatus = nullptr; + + // Post page + QLabel *m_preview = nullptr; + QLineEdit *m_caption = nullptr; + QDateEdit *m_date = nullptr; + QPushButton *m_chooseButton = nullptr; + QPushButton *m_postButton = nullptr; + QLabel *m_postStatus = nullptr; + QLabel *m_lastPost = nullptr; + + QString m_imagePath; // currently selected source image +}; diff --git a/desktop/settings.cpp b/desktop/settings.cpp new file mode 100644 index 0000000..e9906bd --- /dev/null +++ b/desktop/settings.cpp @@ -0,0 +1,113 @@ +#include "settings.h" + +#include +#include + +#include + +namespace { +const QLatin1String kService("photogallery-uploader"); +const QLatin1String kPasswordKey("password"); +} // namespace + +Settings::Settings() + : m_settings(QStringLiteral("inflo.ws"), QStringLiteral("photogallery-uploader")) +{ +} + +QString Settings::baseUrl() const +{ + return m_settings.value(QStringLiteral("baseUrl")).toString(); +} + +void Settings::setBaseUrl(const QString &value) +{ + m_settings.setValue(QStringLiteral("baseUrl"), normalizeBaseUrl(value)); +} + +QString Settings::username() const +{ + return m_settings.value(QStringLiteral("username")).toString(); +} + +void Settings::setUsername(const QString &value) +{ + m_settings.setValue(QStringLiteral("username"), value.trimmed()); +} + +QString Settings::readPassword() const +{ + QKeychain::ReadPasswordJob job(kService); + job.setAutoDelete(false); + job.setKey(kPasswordKey); + + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); + + if (job.error() != QKeychain::NoError) + return QString(); + return job.textData(); +} + +void Settings::writePassword(const QString &value) const +{ + QKeychain::WritePasswordJob job(kService); + job.setAutoDelete(false); + job.setKey(kPasswordKey); + job.setTextData(value); + + QEventLoop loop; + QObject::connect(&job, &QKeychain::Job::finished, &loop, &QEventLoop::quit); + job.start(); + loop.exec(); +} + +bool Settings::isConfigured() const +{ + return !baseUrl().isEmpty() && !username().isEmpty() && !readPassword().isEmpty(); +} + +Settings::LastPost Settings::lastPost() const +{ + LastPost p; + p.slug = m_settings.value(QStringLiteral("last/slug")).toString(); + p.url = m_settings.value(QStringLiteral("last/url")).toString(); + p.caption = m_settings.value(QStringLiteral("last/caption")).toString(); + p.postedAt = m_settings.value(QStringLiteral("last/postedAt")).toDateTime(); + return p; +} + +void Settings::setLastPost(const LastPost &value) +{ + m_settings.setValue(QStringLiteral("last/slug"), value.slug); + m_settings.setValue(QStringLiteral("last/url"), value.url); + m_settings.setValue(QStringLiteral("last/caption"), value.caption); + m_settings.setValue(QStringLiteral("last/postedAt"), value.postedAt); +} + +QString Settings::validateBaseUrl(const QString &raw) +{ + const QString trimmed = raw.trimmed(); + if (trimmed.isEmpty()) + return QStringLiteral("Base URL is required"); + const QUrl url(trimmed); + if (!url.isValid() || url.host().isEmpty()) + return QStringLiteral("Not a valid URL"); + if (url.scheme() != QStringLiteral("https")) + return QStringLiteral("Must be https — plain http sends your password in the clear"); + return QString(); +} + +QString Settings::normalizeBaseUrl(const QString &raw) +{ + QString v = raw.trimmed(); + while (v.endsWith(QLatin1Char('/'))) + v.chop(1); + if (v.endsWith(QStringLiteral("/upload"), Qt::CaseInsensitive)) + v.chop(QStringLiteral("/upload").size()); + while (v.endsWith(QLatin1Char('/'))) + v.chop(1); + return v; +} diff --git a/desktop/settings.h b/desktop/settings.h new file mode 100644 index 0000000..86f644a --- /dev/null +++ b/desktop/settings.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +// Persists the non-secret settings (base URL, username, last post) in a +// QSettings INI under ~/.config, and the password in the system keychain +// (libsecret / kwallet via QtKeychain). The password never lands on disk in the +// clear, mirroring the Android app's Keystore sealing. +class Settings +{ +public: + struct LastPost { + QString slug; + QString url; + QString caption; + QDateTime postedAt; + bool valid() const { return !slug.isEmpty(); } + }; + + Settings(); + + QString baseUrl() const; + void setBaseUrl(const QString &value); + + QString username() const; + void setUsername(const QString &value); + + // Keychain access is synchronous here (a local event loop): the app does + // nothing until the credential is in hand, and there is no background retry + // worker as on Android that would need it off the UI thread. + QString readPassword() const; + void writePassword(const QString &value) const; + + bool isConfigured() const; + + LastPost lastPost() const; + void setLastPost(const LastPost &value); + + // Returns an error message, or an empty string when the URL is usable. + // https is required: Basic auth would otherwise send the password in the + // clear. + static QString validateBaseUrl(const QString &raw); + + // Trims trailing slashes and a trailing "/upload" (the endpoint path a user + // pasting the upload URL would leave on — the client appends it itself). + static QString normalizeBaseUrl(const QString &raw); + +private: + mutable QSettings m_settings; +}; diff --git a/desktop/uploadclient.cpp b/desktop/uploadclient.cpp new file mode 100644 index 0000000..9f2d515 --- /dev/null +++ b/desktop/uploadclient.cpp @@ -0,0 +1,126 @@ +#include "uploadclient.h" + +#include +#include +#include +#include +#include +#include +#include + +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)); + }); +} diff --git a/desktop/uploadclient.h b/desktop/uploadclient.h new file mode 100644 index 0000000..79ab7af --- /dev/null +++ b/desktop/uploadclient.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +class QNetworkAccessManager; + +// Talks to the gallery's POST /upload endpoint, which sits behind nginx HTTP +// Basic auth. The credentials go out preemptively in the Authorization header +// on every request (never via QNetworkAccessManager's authenticationRequired +// signal, which only fires on a 401 and would replay a multipart body already +// streamed off disk). +// +// Asynchronous: results arrive via signals so the UI stays responsive during an +// upload over a slow link. +class UploadClient : public QObject +{ + Q_OBJECT +public: + enum class Result { + Success, // uploaded; slug/url populated + AuthFailed, // wrong credentials — a retry will not help + Rejected, // server said no for a reason a retry won't change + Transient, // network trouble or a 5xx — worth retrying + }; + + UploadClient(QString baseUrl, QString username, QString password, QObject *parent = nullptr); + + // The filename matters: server.go derives the slug from it, so re-sending + // the same name overwrites the same post instead of duplicating it. + void upload(const QString &filePath, const QString &fileName, + const QString &caption, const QString &date); + + // Credential check via GET /upload (same Basic-auth gate the POST uses). + // /health is not usable: nginx only proxies location /upload. + void testConnection(); + +signals: + void uploadFinished(UploadClient::Result result, const QString &slug, + const QString &url, const QString &message); + // errorMessage is empty on success. + void testFinished(const QString &errorMessage); + +private: + QByteArray authHeader() const; + + QString m_base; + QString m_username; + QString m_password; + QNetworkAccessManager *m_net; +};