feat: add Android app and end-to-end encrypted library sync
Introduce the Kotlin/Compose Android companion app and extend the TMDB proxy into a combo server that synchronizes the whole library as an encrypted snapshot, with all three components (Go server, Android, desktop) sharing one zero-knowledge crypto envelope and JSON schema. - server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic concurrency (revision + 409 on conflict); sync stays disabled unless DATABASE_URL is set. Add docker-compose with Postgres. - desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient; storage-mode and passphrase settings; a "Sync now" toolbar action with conflict resolution. - android: Room-backed library with local/cloud storage modes, encrypted sync client, and settings UI. The proxy server (URL + token) can be configured as a TMDB proxy even in local-only mode. Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash, INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a snapshot encrypted on one client decrypts on the other. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
83a26ef0cf
commit
de353f0218
65 changed files with 4178 additions and 0 deletions
|
|
@ -72,6 +72,28 @@ void AppSettings::setRawgApiKey(const QString &key) {
|
|||
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
|
||||
}
|
||||
|
||||
QString AppSettings::storageMode() const {
|
||||
return m_s.value(QStringLiteral("sync/storageMode"),
|
||||
QStringLiteral("local")).toString();
|
||||
}
|
||||
void AppSettings::setStorageMode(const QString &mode) {
|
||||
m_s.setValue(QStringLiteral("sync/storageMode"), mode);
|
||||
}
|
||||
|
||||
QString AppSettings::syncPassphrase() const {
|
||||
return m_s.value(QStringLiteral("sync/passphrase")).toString();
|
||||
}
|
||||
void AppSettings::setSyncPassphrase(const QString &pass) {
|
||||
m_s.setValue(QStringLiteral("sync/passphrase"), pass);
|
||||
}
|
||||
|
||||
qlonglong AppSettings::syncRevision() const {
|
||||
return m_s.value(QStringLiteral("sync/revision"), 0).toLongLong();
|
||||
}
|
||||
void AppSettings::setSyncRevision(qlonglong revision) {
|
||||
m_s.setValue(QStringLiteral("sync/revision"), revision);
|
||||
}
|
||||
|
||||
bool AppSettings::autoFetchCovers() const {
|
||||
return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,20 @@ public:
|
|||
QString rawgApiKey() const;
|
||||
void setRawgApiKey(const QString &key);
|
||||
|
||||
// --- Library sync ---
|
||||
// Where the library lives: "local" (SQLite only) or "cloud" (E2E sync).
|
||||
QString storageMode() const; // "local" | "cloud"
|
||||
void setStorageMode(const QString &mode);
|
||||
|
||||
// Passphrase used to derive the end-to-end encryption key. Never leaves the
|
||||
// device in plaintext; the server only ever sees ciphertext.
|
||||
QString syncPassphrase() const;
|
||||
void setSyncPassphrase(const QString &pass);
|
||||
|
||||
// Last library revision this device knows about (optimistic concurrency).
|
||||
qlonglong syncRevision() const;
|
||||
void setSyncRevision(qlonglong revision);
|
||||
|
||||
bool autoFetchCovers() const;
|
||||
void setAutoFetchCovers(bool on);
|
||||
|
||||
|
|
|
|||
158
src/sync/CryptoEnvelope.cpp
Normal file
158
src/sync/CryptoEnvelope.cpp
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
#include "sync/CryptoEnvelope.h"
|
||||
|
||||
#include <QtEndian>
|
||||
#include <QtGlobal>
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
namespace umt {
|
||||
namespace CryptoEnvelope {
|
||||
|
||||
namespace {
|
||||
|
||||
const char MAGIC[4] = { 'U', 'M', 'T', 'S' };
|
||||
const quint8 VERSION = 1;
|
||||
|
||||
constexpr int SALT_BYTES = 16; // crypto_pwhash_SALTBYTES
|
||||
constexpr int NONCE_BYTES = 24; // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
||||
constexpr int KEY_BYTES = 32; // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
|
||||
constexpr int ABYTES = 16; // crypto_aead_xchacha20poly1305_ietf_ABYTES
|
||||
|
||||
// libsodium INTERACTIVE limits — a balance of security and CPU cost. Must match
|
||||
// the Android client so snapshots are interchangeable.
|
||||
constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE
|
||||
constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
|
||||
|
||||
constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES;
|
||||
|
||||
void setError(QString *error, const QString &msg) {
|
||||
if (error) *error = msg;
|
||||
}
|
||||
|
||||
bool ensureInit(QString *error) {
|
||||
static const int rc = sodium_init();
|
||||
if (rc < 0) {
|
||||
setError(error, QStringLiteral("Krypto-Bibliothek konnte nicht initialisiert werden"));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
QByteArray deriveKey(const QString &passphrase, const QByteArray &salt,
|
||||
quint64 ops, quint64 mem, QString *error) {
|
||||
const QByteArray pw = passphrase.toUtf8();
|
||||
QByteArray key(KEY_BYTES, Qt::Uninitialized);
|
||||
const int rc = crypto_pwhash(
|
||||
reinterpret_cast<unsigned char *>(key.data()), KEY_BYTES,
|
||||
pw.constData(), static_cast<unsigned long long>(pw.size()),
|
||||
reinterpret_cast<const unsigned char *>(salt.constData()),
|
||||
ops, static_cast<size_t>(mem),
|
||||
crypto_pwhash_ALG_ARGON2ID13);
|
||||
if (rc != 0) {
|
||||
setError(error, QStringLiteral("Schlüsselableitung fehlgeschlagen"));
|
||||
return {};
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
|
||||
QString *error) {
|
||||
if (!ensureInit(error)) return {};
|
||||
|
||||
QByteArray salt(SALT_BYTES, Qt::Uninitialized);
|
||||
randombytes_buf(salt.data(), SALT_BYTES);
|
||||
QByteArray nonce(NONCE_BYTES, Qt::Uninitialized);
|
||||
randombytes_buf(nonce.data(), NONCE_BYTES);
|
||||
|
||||
const QByteArray key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT, error);
|
||||
if (key.isEmpty()) return {};
|
||||
|
||||
QByteArray cipher(plaintext.size() + ABYTES, Qt::Uninitialized);
|
||||
unsigned long long cipherLen = 0;
|
||||
const int rc = crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
reinterpret_cast<unsigned char *>(cipher.data()), &cipherLen,
|
||||
reinterpret_cast<const unsigned char *>(plaintext.constData()),
|
||||
static_cast<unsigned long long>(plaintext.size()),
|
||||
nullptr, 0, // no associated data
|
||||
nullptr, // nsec (unused)
|
||||
reinterpret_cast<const unsigned char *>(nonce.constData()),
|
||||
reinterpret_cast<const unsigned char *>(key.constData()));
|
||||
if (rc != 0) {
|
||||
setError(error, QStringLiteral("Verschlüsselung fehlgeschlagen"));
|
||||
return {};
|
||||
}
|
||||
cipher.resize(static_cast<int>(cipherLen));
|
||||
|
||||
QByteArray out;
|
||||
out.reserve(HEADER_BYTES + cipher.size());
|
||||
out.append(MAGIC, 4);
|
||||
out.append(static_cast<char>(VERSION));
|
||||
|
||||
quint32 opsBE = qToBigEndian<quint32>(static_cast<quint32>(OPSLIMIT));
|
||||
quint32 memBE = qToBigEndian<quint32>(static_cast<quint32>(MEMLIMIT));
|
||||
out.append(reinterpret_cast<const char *>(&opsBE), 4);
|
||||
out.append(reinterpret_cast<const char *>(&memBE), 4);
|
||||
out.append(salt);
|
||||
out.append(nonce);
|
||||
out.append(cipher);
|
||||
return out;
|
||||
}
|
||||
|
||||
QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
|
||||
QString *error) {
|
||||
if (!ensureInit(error)) return {};
|
||||
if (envelope.size() < HEADER_BYTES) {
|
||||
setError(error, QStringLiteral("Daten unvollständig"));
|
||||
return {};
|
||||
}
|
||||
|
||||
const char *p = envelope.constData();
|
||||
if (qstrncmp(p, MAGIC, 4) != 0) {
|
||||
setError(error, QStringLiteral("Unbekanntes Format"));
|
||||
return {};
|
||||
}
|
||||
int off = 4;
|
||||
const quint8 version = static_cast<quint8>(p[off]); off += 1;
|
||||
if (version != VERSION) {
|
||||
setError(error, QStringLiteral("Nicht unterstützte Version: %1").arg(version));
|
||||
return {};
|
||||
}
|
||||
quint32 opsBE, memBE;
|
||||
memcpy(&opsBE, p + off, 4); off += 4;
|
||||
memcpy(&memBE, p + off, 4); off += 4;
|
||||
const quint64 ops = qFromBigEndian<quint32>(opsBE);
|
||||
const quint64 mem = qFromBigEndian<quint32>(memBE);
|
||||
|
||||
const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES;
|
||||
const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES;
|
||||
const QByteArray cipher = envelope.mid(off);
|
||||
if (cipher.size() < ABYTES) {
|
||||
setError(error, QStringLiteral("Daten unvollständig"));
|
||||
return {};
|
||||
}
|
||||
|
||||
const QByteArray key = deriveKey(passphrase, salt, ops, mem, error);
|
||||
if (key.isEmpty()) return {};
|
||||
|
||||
QByteArray plain(cipher.size() - ABYTES, Qt::Uninitialized);
|
||||
unsigned long long plainLen = 0;
|
||||
const int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
reinterpret_cast<unsigned char *>(plain.data()), &plainLen,
|
||||
nullptr, // nsec (unused)
|
||||
reinterpret_cast<const unsigned char *>(cipher.constData()),
|
||||
static_cast<unsigned long long>(cipher.size()),
|
||||
nullptr, 0, // no associated data
|
||||
reinterpret_cast<const unsigned char *>(nonce.constData()),
|
||||
reinterpret_cast<const unsigned char *>(key.constData()));
|
||||
if (rc != 0) {
|
||||
setError(error, QStringLiteral("Entschlüsselung fehlgeschlagen (falsche Passphrase?)"));
|
||||
return {};
|
||||
}
|
||||
plain.resize(static_cast<int>(plainLen));
|
||||
return plain;
|
||||
}
|
||||
|
||||
} // namespace CryptoEnvelope
|
||||
} // namespace umt
|
||||
35
src/sync/CryptoEnvelope.h
Normal file
35
src/sync/CryptoEnvelope.h
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
namespace umt {
|
||||
|
||||
// Versioned, zero-knowledge encryption envelope shared byte-for-byte with the
|
||||
// Android app. A library snapshot is encrypted on the device with a passphrase;
|
||||
// the server only ever stores the resulting opaque ciphertext.
|
||||
//
|
||||
// Byte layout (big-endian integers):
|
||||
//
|
||||
// magic "UMTS" (4) | version u8 = 1 | opslimit u32 | memlimit u32 |
|
||||
// salt[16] | nonce[24] | ciphertext (XChaCha20-Poly1305 AEAD, incl. 16-byte tag)
|
||||
//
|
||||
// KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) -> 32-byte key.
|
||||
// AEAD: XChaCha20-Poly1305-IETF, no associated data.
|
||||
//
|
||||
// Both platforms must agree on this exact layout and the Argon2id algorithm so
|
||||
// either can decrypt the other's data.
|
||||
namespace CryptoEnvelope {
|
||||
|
||||
// Returns the full envelope, or an empty QByteArray on failure (sets *error).
|
||||
QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
|
||||
QString *error = nullptr);
|
||||
|
||||
// Decrypts an envelope produced by encrypt() (on either platform). Returns the
|
||||
// plaintext, or an empty QByteArray on failure (sets *error).
|
||||
QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
|
||||
QString *error = nullptr);
|
||||
|
||||
} // namespace CryptoEnvelope
|
||||
|
||||
} // namespace umt
|
||||
171
src/sync/LibrarySerializer.cpp
Normal file
171
src/sync/LibrarySerializer.cpp
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
#include "sync/LibrarySerializer.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
|
||||
namespace umt {
|
||||
namespace LibrarySerializer {
|
||||
|
||||
namespace {
|
||||
|
||||
QString dtToStr(const QDateTime &dt) {
|
||||
return dt.isValid() ? dt.toString(Qt::ISODate) : QString();
|
||||
}
|
||||
QDateTime strToDt(const QString &s) {
|
||||
return s.isEmpty() ? QDateTime() : QDateTime::fromString(s, Qt::ISODate);
|
||||
}
|
||||
|
||||
QJsonObject unitToJson(const Unit &u) {
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("number")] = u.number;
|
||||
o[QStringLiteral("title")] = u.title;
|
||||
o[QStringLiteral("watched")] = u.watched;
|
||||
o[QStringLiteral("watchedDate")] = dtToStr(u.watchedDate);
|
||||
return o;
|
||||
}
|
||||
|
||||
QJsonObject segmentToJson(const Segment &s) {
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("number")] = s.number;
|
||||
o[QStringLiteral("title")] = s.title;
|
||||
QJsonArray units;
|
||||
for (const Unit &u : s.units) units.append(unitToJson(u));
|
||||
o[QStringLiteral("units")] = units;
|
||||
return o;
|
||||
}
|
||||
|
||||
QJsonObject itemToJson(const MediaItem &m) {
|
||||
QJsonObject o;
|
||||
o[QStringLiteral("type")] = mediaTypeToString(m.type);
|
||||
o[QStringLiteral("title")] = m.title;
|
||||
o[QStringLiteral("originalTitle")] = m.originalTitle;
|
||||
|
||||
QJsonObject loc;
|
||||
for (auto it = m.localizedTitles.constBegin(); it != m.localizedTitles.constEnd(); ++it)
|
||||
loc[it.key()] = it.value();
|
||||
o[QStringLiteral("localizedTitles")] = loc;
|
||||
|
||||
o[QStringLiteral("coverUrl")] = m.coverUrl;
|
||||
|
||||
QJsonArray genres;
|
||||
for (const QString &g : m.genres) genres.append(g);
|
||||
o[QStringLiteral("genres")] = genres;
|
||||
|
||||
QJsonArray tags;
|
||||
for (const QString &t : m.tags) tags.append(t);
|
||||
o[QStringLiteral("tags")] = tags;
|
||||
|
||||
o[QStringLiteral("franchise")] = m.franchise;
|
||||
o[QStringLiteral("status")] = statusToString(m.status);
|
||||
o[QStringLiteral("rating")] = m.rating;
|
||||
o[QStringLiteral("favorite")] = m.favorite;
|
||||
o[QStringLiteral("year")] = m.year;
|
||||
o[QStringLiteral("overview")] = m.overview;
|
||||
o[QStringLiteral("notes")] = m.notes;
|
||||
|
||||
QJsonArray segments;
|
||||
for (const Segment &s : m.segments) segments.append(segmentToJson(s));
|
||||
o[QStringLiteral("segments")] = segments;
|
||||
|
||||
QJsonObject custom;
|
||||
for (auto it = m.customFields.constBegin(); it != m.customFields.constEnd(); ++it)
|
||||
custom[it.key()] = it.value();
|
||||
o[QStringLiteral("customFields")] = custom;
|
||||
|
||||
o[QStringLiteral("dateAdded")] = dtToStr(m.dateAdded);
|
||||
o[QStringLiteral("dateCompleted")] = dtToStr(m.dateCompleted);
|
||||
o[QStringLiteral("externalId")] = m.externalId;
|
||||
o[QStringLiteral("externalSource")] = m.externalSource;
|
||||
return o;
|
||||
}
|
||||
|
||||
Unit unitFromJson(const QJsonObject &o) {
|
||||
Unit u;
|
||||
u.number = o.value(QStringLiteral("number")).toInt();
|
||||
u.title = o.value(QStringLiteral("title")).toString();
|
||||
u.watched = o.value(QStringLiteral("watched")).toBool();
|
||||
u.watchedDate = strToDt(o.value(QStringLiteral("watchedDate")).toString());
|
||||
return u;
|
||||
}
|
||||
|
||||
Segment segmentFromJson(const QJsonObject &o) {
|
||||
Segment s;
|
||||
s.number = o.value(QStringLiteral("number")).toInt();
|
||||
s.title = o.value(QStringLiteral("title")).toString();
|
||||
const QJsonArray units = o.value(QStringLiteral("units")).toArray();
|
||||
for (const QJsonValue &v : units) s.units.append(unitFromJson(v.toObject()));
|
||||
return s;
|
||||
}
|
||||
|
||||
MediaItem itemFromJson(const QJsonObject &o) {
|
||||
MediaItem m;
|
||||
m.type = mediaTypeFromString(o.value(QStringLiteral("type")).toString());
|
||||
m.title = o.value(QStringLiteral("title")).toString();
|
||||
m.originalTitle = o.value(QStringLiteral("originalTitle")).toString();
|
||||
|
||||
const QJsonObject loc = o.value(QStringLiteral("localizedTitles")).toObject();
|
||||
for (auto it = loc.constBegin(); it != loc.constEnd(); ++it)
|
||||
m.localizedTitles.insert(it.key(), it.value().toString());
|
||||
|
||||
m.coverUrl = o.value(QStringLiteral("coverUrl")).toString();
|
||||
|
||||
const QJsonArray genres = o.value(QStringLiteral("genres")).toArray();
|
||||
for (const QJsonValue &v : genres) m.genres << v.toString();
|
||||
const QJsonArray tags = o.value(QStringLiteral("tags")).toArray();
|
||||
for (const QJsonValue &v : tags) m.tags << v.toString();
|
||||
|
||||
m.franchise = o.value(QStringLiteral("franchise")).toString();
|
||||
m.status = statusFromString(o.value(QStringLiteral("status")).toString());
|
||||
m.rating = o.value(QStringLiteral("rating")).toInt();
|
||||
m.favorite = o.value(QStringLiteral("favorite")).toBool();
|
||||
m.year = o.value(QStringLiteral("year")).toInt();
|
||||
m.overview = o.value(QStringLiteral("overview")).toString();
|
||||
m.notes = o.value(QStringLiteral("notes")).toString();
|
||||
|
||||
const QJsonArray segments = o.value(QStringLiteral("segments")).toArray();
|
||||
for (const QJsonValue &v : segments) m.segments.append(segmentFromJson(v.toObject()));
|
||||
|
||||
const QJsonObject custom = o.value(QStringLiteral("customFields")).toObject();
|
||||
for (auto it = custom.constBegin(); it != custom.constEnd(); ++it)
|
||||
m.customFields.insert(it.key(), it.value().toString());
|
||||
|
||||
m.dateAdded = strToDt(o.value(QStringLiteral("dateAdded")).toString());
|
||||
m.dateCompleted = strToDt(o.value(QStringLiteral("dateCompleted")).toString());
|
||||
m.externalId = o.value(QStringLiteral("externalId")).toString();
|
||||
m.externalSource = o.value(QStringLiteral("externalSource")).toString();
|
||||
return m;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
QByteArray toJson(const QVector<MediaItem> &items) {
|
||||
QJsonObject root;
|
||||
root[QStringLiteral("version")] = 1;
|
||||
root[QStringLiteral("exportedAt")] =
|
||||
QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
|
||||
QJsonArray arr;
|
||||
for (const MediaItem &m : items) arr.append(itemToJson(m));
|
||||
root[QStringLiteral("items")] = arr;
|
||||
|
||||
return QJsonDocument(root).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
bool fromJson(const QByteArray &json, QVector<MediaItem> *out, QString *error) {
|
||||
QJsonParseError perr;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(json, &perr);
|
||||
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
if (error) *error = QStringLiteral("Ungültiges JSON: %1").arg(perr.errorString());
|
||||
return false;
|
||||
}
|
||||
const QJsonArray arr = doc.object().value(QStringLiteral("items")).toArray();
|
||||
QVector<MediaItem> items;
|
||||
items.reserve(arr.size());
|
||||
for (const QJsonValue &v : arr) items.append(itemFromJson(v.toObject()));
|
||||
*out = items;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace LibrarySerializer
|
||||
} // namespace umt
|
||||
26
src/sync/LibrarySerializer.h
Normal file
26
src/sync/LibrarySerializer.h
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#pragma once
|
||||
|
||||
#include "core/MediaItem.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
namespace umt {
|
||||
|
||||
// Serializes the library to/from the canonical JSON snapshot shared with the
|
||||
// Android app (the `LibraryExport` schema: { version, exportedAt, items:[...] }).
|
||||
// Field names and string values mirror the desktop MediaItem so a snapshot can
|
||||
// round-trip between platforms.
|
||||
namespace LibrarySerializer {
|
||||
|
||||
// Produces a UTF-8 JSON snapshot of the given items.
|
||||
QByteArray toJson(const QVector<MediaItem> &items);
|
||||
|
||||
// Parses a JSON snapshot into items. Returns true on success; on a parse error
|
||||
// returns false and leaves *out untouched (all-or-nothing).
|
||||
bool fromJson(const QByteArray &json, QVector<MediaItem> *out, QString *error = nullptr);
|
||||
|
||||
} // namespace LibrarySerializer
|
||||
|
||||
} // namespace umt
|
||||
199
src/sync/SyncClient.cpp
Normal file
199
src/sync/SyncClient.cpp
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
#include "sync/SyncClient.h"
|
||||
|
||||
#include "core/Database.h"
|
||||
#include "core/Settings.h"
|
||||
#include "sync/CryptoEnvelope.h"
|
||||
#include "sync/LibrarySerializer.h"
|
||||
|
||||
#include <QEventLoop>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QUrl>
|
||||
|
||||
namespace umt {
|
||||
|
||||
SyncClient::SyncClient(Database *db, AppSettings *settings, QObject *parent)
|
||||
: QObject(parent)
|
||||
, m_db(db)
|
||||
, m_settings(settings)
|
||||
, m_nam(new QNetworkAccessManager(this))
|
||||
{
|
||||
}
|
||||
|
||||
bool SyncClient::isConfigured() const {
|
||||
return m_settings->storageMode() == QLatin1String("cloud")
|
||||
&& !baseUrl().isEmpty()
|
||||
&& !m_settings->proxyToken().isEmpty()
|
||||
&& !m_settings->syncPassphrase().isEmpty();
|
||||
}
|
||||
|
||||
QString SyncClient::baseUrl() const {
|
||||
QString u = m_settings->proxyUrl().trimmed();
|
||||
while (u.endsWith(QLatin1Char('/'))) u.chop(1);
|
||||
return u;
|
||||
}
|
||||
|
||||
SyncClient::HttpResponse SyncClient::request(const QString &method,
|
||||
const QString &path,
|
||||
const QByteArray &body) {
|
||||
HttpResponse out;
|
||||
QNetworkRequest req(QUrl(baseUrl() + path));
|
||||
req.setHeader(QNetworkRequest::UserAgentHeader,
|
||||
QStringLiteral("UltimateMediaTracker/1.0"));
|
||||
req.setRawHeader("Authorization",
|
||||
QByteArrayLiteral("Bearer ") + m_settings->proxyToken().toUtf8());
|
||||
if (!body.isEmpty())
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QStringLiteral("application/json"));
|
||||
|
||||
QNetworkReply *r = nullptr;
|
||||
if (method == QLatin1String("GET"))
|
||||
r = m_nam->get(req);
|
||||
else if (method == QLatin1String("PUT"))
|
||||
r = m_nam->put(req, body);
|
||||
else
|
||||
r = m_nam->sendCustomRequest(req, method.toUtf8(), body);
|
||||
|
||||
QEventLoop loop;
|
||||
connect(r, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
|
||||
out.statusCode = r->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
out.body = r->readAll();
|
||||
if (r->error() != QNetworkReply::NoError && out.statusCode == 0)
|
||||
out.networkError = r->errorString();
|
||||
r->deleteLater();
|
||||
return out;
|
||||
}
|
||||
|
||||
SyncClient::Result SyncClient::pull() {
|
||||
Result res;
|
||||
const HttpResponse resp = request(QStringLiteral("GET"),
|
||||
QStringLiteral("/sync/library"), {});
|
||||
if (!resp.networkError.isEmpty()) {
|
||||
res.status = Status::Error;
|
||||
res.message = QStringLiteral("Netzwerkfehler: %1").arg(resp.networkError);
|
||||
return res;
|
||||
}
|
||||
if (resp.statusCode == 404) {
|
||||
// No snapshot on the server yet — nothing to download.
|
||||
res.status = Status::Success;
|
||||
return res;
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
res.status = Status::Error;
|
||||
res.message = QStringLiteral("Server antwortete mit Status %1").arg(resp.statusCode);
|
||||
return res;
|
||||
}
|
||||
|
||||
const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
|
||||
const qlonglong revision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
|
||||
const QByteArray envelope =
|
||||
QByteArray::fromBase64(obj.value(QStringLiteral("payload")).toString().toUtf8());
|
||||
|
||||
QString cryptoErr;
|
||||
const QByteArray plain = CryptoEnvelope::decrypt(
|
||||
envelope, m_settings->syncPassphrase(), &cryptoErr);
|
||||
if (plain.isNull()) {
|
||||
res.status = Status::Error;
|
||||
res.message = cryptoErr;
|
||||
return res;
|
||||
}
|
||||
|
||||
QString replaceErr;
|
||||
if (!replaceLibrary(plain, &replaceErr)) {
|
||||
res.status = Status::Error;
|
||||
res.message = replaceErr;
|
||||
return res;
|
||||
}
|
||||
|
||||
m_settings->setSyncRevision(revision);
|
||||
res.status = Status::Success;
|
||||
return res;
|
||||
}
|
||||
|
||||
SyncClient::Result SyncClient::push(bool force) {
|
||||
Result res;
|
||||
|
||||
qlonglong baseRevision = m_settings->syncRevision();
|
||||
if (force) {
|
||||
// Re-read the current server revision so the overwrite is accepted.
|
||||
const HttpResponse cur = request(QStringLiteral("GET"),
|
||||
QStringLiteral("/sync/library"), {});
|
||||
if (!cur.networkError.isEmpty()) {
|
||||
res.status = Status::Error;
|
||||
res.message = QStringLiteral("Netzwerkfehler: %1").arg(cur.networkError);
|
||||
return res;
|
||||
}
|
||||
if (cur.statusCode == 200) {
|
||||
const QJsonObject obj = QJsonDocument::fromJson(cur.body).object();
|
||||
baseRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
|
||||
} else if (cur.statusCode == 404) {
|
||||
baseRevision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const QVector<MediaItem> items = m_db->queryItems(FilterCriteria{});
|
||||
const QByteArray json = LibrarySerializer::toJson(items);
|
||||
|
||||
QString cryptoErr;
|
||||
const QByteArray envelope = CryptoEnvelope::encrypt(
|
||||
json, m_settings->syncPassphrase(), &cryptoErr);
|
||||
if (envelope.isNull()) {
|
||||
res.status = Status::Error;
|
||||
res.message = cryptoErr;
|
||||
return res;
|
||||
}
|
||||
|
||||
QJsonObject reqObj;
|
||||
reqObj[QStringLiteral("baseRevision")] = baseRevision;
|
||||
reqObj[QStringLiteral("payload")] =
|
||||
QString::fromUtf8(envelope.toBase64());
|
||||
const QByteArray reqBody = QJsonDocument(reqObj).toJson(QJsonDocument::Compact);
|
||||
|
||||
const HttpResponse resp = request(QStringLiteral("PUT"),
|
||||
QStringLiteral("/sync/library"), reqBody);
|
||||
if (!resp.networkError.isEmpty()) {
|
||||
res.status = Status::Error;
|
||||
res.message = QStringLiteral("Netzwerkfehler: %1").arg(resp.networkError);
|
||||
return res;
|
||||
}
|
||||
if (resp.statusCode == 409) {
|
||||
const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
|
||||
res.status = Status::Conflict;
|
||||
res.serverRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
|
||||
return res;
|
||||
}
|
||||
if (resp.statusCode != 200) {
|
||||
res.status = Status::Error;
|
||||
res.message = QStringLiteral("Server antwortete mit Status %1").arg(resp.statusCode);
|
||||
return res;
|
||||
}
|
||||
|
||||
const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
|
||||
const qlonglong newRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
|
||||
m_settings->setSyncRevision(newRevision);
|
||||
res.status = Status::Success;
|
||||
return res;
|
||||
}
|
||||
|
||||
bool SyncClient::replaceLibrary(const QByteArray &snapshotJson, QString *error) {
|
||||
QVector<MediaItem> incoming;
|
||||
if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
|
||||
return false;
|
||||
|
||||
// All-or-nothing replace: wipe the current library, then insert the snapshot.
|
||||
const QVector<MediaItem> existing = m_db->queryItems(FilterCriteria{});
|
||||
for (const MediaItem &m : existing)
|
||||
m_db->deleteItem(m.id);
|
||||
for (MediaItem m : incoming) {
|
||||
m.id = -1;
|
||||
m_db->saveItem(m);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace umt
|
||||
66
src/sync/SyncClient.h
Normal file
66
src/sync/SyncClient.h
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
|
||||
namespace umt {
|
||||
|
||||
class Database;
|
||||
class AppSettings;
|
||||
|
||||
// Drives the whole-library snapshot sync against the combo server's
|
||||
// /sync/library endpoint. The library is encrypted client-side (CryptoEnvelope)
|
||||
// so the server only ever stores opaque ciphertext (zero-knowledge).
|
||||
//
|
||||
// Concurrency is optimistic: each push carries the base revision it was built
|
||||
// from; the server rejects a stale base with 409, surfaced here as Conflict so
|
||||
// the caller can offer "pull server state" or "overwrite".
|
||||
//
|
||||
// The network calls run synchronously (driven by a local event loop) since they
|
||||
// are triggered by an explicit, user-initiated "Sync now" action.
|
||||
class SyncClient : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
enum class Status { Success, Conflict, Error };
|
||||
|
||||
struct Result {
|
||||
Status status = Status::Error;
|
||||
QString message; // German, user-facing (on Error)
|
||||
qlonglong serverRevision = 0; // valid on Conflict
|
||||
};
|
||||
|
||||
SyncClient(Database *db, AppSettings *settings, QObject *parent = nullptr);
|
||||
|
||||
// True if cloud mode is selected and server URL / token / passphrase are set.
|
||||
bool isConfigured() const;
|
||||
|
||||
// Downloads the server snapshot, decrypts it and replaces the local library.
|
||||
// A 404 (no snapshot yet) is treated as Success (nothing to do).
|
||||
Result pull();
|
||||
|
||||
// Encrypts the whole local library and uploads it. With force=false a stale
|
||||
// base revision yields Conflict; with force=true it re-reads the server
|
||||
// revision and overwrites unconditionally.
|
||||
Result push(bool force = false);
|
||||
|
||||
private:
|
||||
struct HttpResponse {
|
||||
int statusCode = 0;
|
||||
QByteArray body;
|
||||
QString networkError; // non-empty on transport failure
|
||||
};
|
||||
HttpResponse request(const QString &method, const QString &path,
|
||||
const QByteArray &body);
|
||||
|
||||
QString baseUrl() const;
|
||||
// Replaces the entire local library with the given snapshot (all-or-nothing).
|
||||
bool replaceLibrary(const QByteArray &snapshotJson, QString *error);
|
||||
|
||||
Database *m_db;
|
||||
AppSettings *m_settings;
|
||||
QNetworkAccessManager *m_nam;
|
||||
};
|
||||
|
||||
} // namespace umt
|
||||
|
|
@ -7,9 +7,11 @@
|
|||
#include "ui/EditDialog.h"
|
||||
#include "ui/SettingsDialog.h"
|
||||
#include "core/Settings.h"
|
||||
#include "sync/SyncClient.h"
|
||||
#include "providers/ProviderManager.h"
|
||||
#include "providers/ImageCache.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QWidget>
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
|
|
@ -141,12 +143,21 @@ void MainWindow::buildUi()
|
|||
connect(themeBtn, &QPushButton::clicked, this, &MainWindow::toggleTheme);
|
||||
tb->addWidget(themeBtn);
|
||||
|
||||
m_syncBtn = new QPushButton(QStringLiteral("☁"), topBar);
|
||||
m_syncBtn->setObjectName(QStringLiteral("IconButton"));
|
||||
m_syncBtn->setFixedSize(40, rowH);
|
||||
m_syncBtn->setToolTip(QStringLiteral("Bibliothek synchronisieren"));
|
||||
connect(m_syncBtn, &QPushButton::clicked, this, &MainWindow::syncNow);
|
||||
tb->addWidget(m_syncBtn);
|
||||
|
||||
auto *settingsBtn = new QPushButton(QStringLiteral("⚙"), topBar);
|
||||
settingsBtn->setObjectName(QStringLiteral("IconButton"));
|
||||
settingsBtn->setFixedSize(40, rowH);
|
||||
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
|
||||
tb->addWidget(settingsBtn);
|
||||
|
||||
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
|
||||
|
||||
libLayout->addWidget(topBar);
|
||||
|
||||
// grid scroll area
|
||||
|
|
@ -364,11 +375,83 @@ void MainWindow::openSettings()
|
|||
connect(&dlg, &SettingsDialog::providersConfigChanged, this, [this]{
|
||||
m_providers->refreshConfig();
|
||||
m_theme->apply();
|
||||
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
|
||||
refresh();
|
||||
});
|
||||
dlg.exec();
|
||||
}
|
||||
|
||||
void MainWindow::syncNow()
|
||||
{
|
||||
SyncClient client(m_db, m_settings, this);
|
||||
if (!client.isConfigured()) {
|
||||
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
|
||||
QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. "
|
||||
"Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ "
|
||||
"stellen und Proxy-URL, Token sowie Sync-Passphrase eintragen."));
|
||||
return;
|
||||
}
|
||||
|
||||
m_syncBtn->setEnabled(false);
|
||||
QApplication::setOverrideCursor(Qt::WaitCursor);
|
||||
|
||||
// Upload local changes first; resolve a conflict by asking the user.
|
||||
SyncClient::Result up = client.push(false);
|
||||
if (up.status == SyncClient::Status::Conflict) {
|
||||
QApplication::restoreOverrideCursor();
|
||||
QMessageBox box(this);
|
||||
box.setIcon(QMessageBox::Question);
|
||||
box.setWindowTitle(QStringLiteral("Sync-Konflikt"));
|
||||
box.setText(QStringLiteral(
|
||||
"Auf dem Server liegt eine neuere Version der Bibliothek "
|
||||
"(Revision %1).\n\n"
|
||||
"Möchtest du den Serverstand herunterladen (deine lokalen "
|
||||
"Änderungen gehen verloren) oder lokal überschreiben "
|
||||
"(der Serverstand wird ersetzt)?").arg(up.serverRevision));
|
||||
QPushButton *loadBtn = box.addButton(
|
||||
QStringLiteral("Server laden"), QMessageBox::AcceptRole);
|
||||
box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole);
|
||||
box.exec();
|
||||
const bool loadServer = (box.clickedButton() == loadBtn);
|
||||
QApplication::setOverrideCursor(Qt::WaitCursor);
|
||||
if (loadServer) {
|
||||
const SyncClient::Result pulled = client.pull();
|
||||
QApplication::restoreOverrideCursor();
|
||||
m_syncBtn->setEnabled(true);
|
||||
if (pulled.status == SyncClient::Status::Success) {
|
||||
rebuildFilterLists();
|
||||
refresh();
|
||||
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
|
||||
QStringLiteral("Serverstand geladen."));
|
||||
} else {
|
||||
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), pulled.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
up = client.push(true); // overwrite
|
||||
}
|
||||
|
||||
if (up.status != SyncClient::Status::Success) {
|
||||
QApplication::restoreOverrideCursor();
|
||||
m_syncBtn->setEnabled(true);
|
||||
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Then pull the canonical server state back down so this device matches it.
|
||||
const SyncClient::Result down = client.pull();
|
||||
QApplication::restoreOverrideCursor();
|
||||
m_syncBtn->setEnabled(true);
|
||||
if (down.status != SyncClient::Status::Success) {
|
||||
QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message);
|
||||
return;
|
||||
}
|
||||
rebuildFilterLists();
|
||||
refresh();
|
||||
QMessageBox::information(this, QStringLiteral("Synchronisieren"),
|
||||
QStringLiteral("Bibliothek synchronisiert."));
|
||||
}
|
||||
|
||||
void MainWindow::toggleTheme()
|
||||
{
|
||||
m_settings->setDarkMode(!m_settings->darkMode());
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ private slots:
|
|||
void openEditExisting(int id);
|
||||
void deleteItem(int id);
|
||||
void openSettings();
|
||||
void syncNow();
|
||||
void toggleTheme();
|
||||
|
||||
private:
|
||||
|
|
@ -66,6 +67,7 @@ private:
|
|||
QLineEdit *m_search;
|
||||
QComboBox *m_sort;
|
||||
QPushButton *m_sortDir;
|
||||
QPushButton *m_syncBtn;
|
||||
FilterPanel *m_filters;
|
||||
QStackedWidget*m_stack;
|
||||
QWidget *m_libraryPage;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,44 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
|
|||
"Ohne Key funktionieren Filme, Serien, Mangas und Bücher weiterhin – nur "
|
||||
"die Spiele-Suche braucht ihn.")));
|
||||
|
||||
m_storageMode = new QComboBox(this);
|
||||
m_storageMode->addItem(QStringLiteral("Nur lokal (SQLite)"), QStringLiteral("local"));
|
||||
m_storageMode->addItem(QStringLiteral("Cloud-Sync (verschlüsselt)"), QStringLiteral("cloud"));
|
||||
m_storageMode->setCurrentIndex(
|
||||
qMax(0, m_storageMode->findData(m_settings->storageMode())));
|
||||
form->addRow(QStringLiteral("Speicherort"), withHelp(m_storageMode,
|
||||
QStringLiteral("Speicherort der Bibliothek"),
|
||||
QStringLiteral(
|
||||
"<b>Wo liegt deine Bibliothek?</b>"
|
||||
"<ul>"
|
||||
"<li><b>Nur lokal:</b> Die Bibliothek bleibt ausschließlich auf diesem "
|
||||
"Gerät (SQLite). Kein Netzwerk-Zugriff.</li>"
|
||||
"<li><b>Cloud-Sync:</b> Die komplette Bibliothek wird "
|
||||
"<b>Ende-zu-Ende verschlüsselt</b> (Argon2id + XChaCha20-Poly1305) und "
|
||||
"über den Server abgeglichen. Der Server speichert nur Chiffretext – "
|
||||
"Passphrase und Klartext verlassen das Gerät nie.</li>"
|
||||
"</ul>"
|
||||
"Server-URL und Token werden aus der Proxy-Anmeldung oben "
|
||||
"wiederverwendet.")));
|
||||
|
||||
m_syncPassphrase = new QLineEdit(this);
|
||||
m_syncPassphrase->setText(m_settings->syncPassphrase());
|
||||
m_syncPassphrase->setEchoMode(QLineEdit::Password);
|
||||
m_syncPassphrase->setPlaceholderText(QStringLiteral("Frei wählbare Sync-Passphrase"));
|
||||
form->addRow(QStringLiteral("Sync-Passphrase"), withHelp(m_syncPassphrase,
|
||||
QStringLiteral("Sync-Passphrase"),
|
||||
QStringLiteral(
|
||||
"<b>Die Sync-Passphrase ver- und entschlüsselt deine Bibliothek.</b>"
|
||||
"<ul>"
|
||||
"<li>Wähle eine starke, frei gewählte Passphrase.</li>"
|
||||
"<li><b>Dieselbe</b> Passphrase muss auf allen Geräten (Desktop und "
|
||||
"Handy) gesetzt sein, damit sie sich gegenseitig entschlüsseln "
|
||||
"können.</li>"
|
||||
"<li>Sie verlässt dein Gerät nie und wird nicht auf dem Server "
|
||||
"gespeichert. Geht sie verloren, sind die Cloud-Daten nicht mehr "
|
||||
"lesbar.</li>"
|
||||
"</ul>")));
|
||||
|
||||
m_autoCovers = new QCheckBox(QStringLiteral("Cover automatisch laden"), this);
|
||||
m_autoCovers->setChecked(m_settings->autoFetchCovers());
|
||||
form->addRow(QString(), m_autoCovers);
|
||||
|
|
@ -180,6 +218,8 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
|
|||
m_settings->setProxyUrl(m_proxyUrl->text().trimmed());
|
||||
m_settings->setProxyToken(m_proxyToken->text().trimmed());
|
||||
m_settings->setRawgApiKey(m_rawgKey->text().trimmed());
|
||||
m_settings->setStorageMode(m_storageMode->currentData().toString());
|
||||
m_settings->setSyncPassphrase(m_syncPassphrase->text());
|
||||
m_settings->setAutoFetchCovers(m_autoCovers->isChecked());
|
||||
m_settings->setCardWidth(m_cardSize->value());
|
||||
emit providersConfigChanged();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ private:
|
|||
QPushButton *m_proxyLogin;
|
||||
QLineEdit *m_proxyToken;
|
||||
QLineEdit *m_rawgKey;
|
||||
QComboBox *m_storageMode;
|
||||
QLineEdit *m_syncPassphrase;
|
||||
QCheckBox *m_autoCovers;
|
||||
QSlider *m_cardSize;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue