From 9bdec3eb2d120702f0a5136ff4efc1ccba442ef2 Mon Sep 17 00:00:00 2001 From: Carlo Baratto Date: Thu, 13 Aug 2026 09:28:34 +0200 Subject: [PATCH] =?UTF-8?q?Sync=20avanzamento=20server=20completa:=20login?= =?UTF-8?q?=20di=20sessione=20calibre-web=20(form=20+=20csrf=5Ftoken=20+?= =?UTF-8?q?=20cookie)=20per=20le=20route=20web,=20fetchBookmark=20(GET=20/?= =?UTF-8?q?read//=20con=20estrazione=20bookmark:=20"chiave"),?= =?UTF-8?q?=20ripristino=20all'apertura=20del=20libro=20con=20posizione=20?= =?UTF-8?q?server=20dominante,=20POST=20a=20ogni=20pagina=20girata=20gi?= =?UTF-8?q?=C3=A0=20attivo;=20fallimento=20login=20gestito=20con=20cooldow?= =?UTF-8?q?n=2060s=20e=20fallback=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- qml/pages/ReaderPage.qml | 34 ++++++++ src/apiclient.cpp | 177 +++++++++++++++++++++++++++++++++++++-- src/apiclient.h | 23 +++++ 3 files changed, 226 insertions(+), 8 deletions(-) diff --git a/qml/pages/ReaderPage.qml b/qml/pages/ReaderPage.qml index 368cdb9..58db1f4 100644 --- a/qml/pages/ReaderPage.qml +++ b/qml/pages/ReaderPage.qml @@ -315,6 +315,11 @@ Page { page.currentChapter = ch page.chapterFraction = fr loadChapter() + + // ripristino anche dal server: la posizione remota vince, se esiste. + // Il login di sessione viene fatto qui (prima richiesta verso le route web) + if (page.bookId > 0) + apiClient.fetchBookmark(page.bookId, page.bookFormat) } onStatusChanged: { @@ -327,4 +332,33 @@ Page { applyMarginsJS() } } + + Connections { + target: apiClient + + // posizione letta dal server all'apertura del libro + onBookmarkReady: { + if (bookId !== page.bookId) + return + if (key.length === 0) + return // nessun segnalibro sul server: resta la posizione locale + var r = book.parseBookmarkKey(key) + if (!r || !r.hasOwnProperty("chapter")) + return + var same = (r.chapter === page.currentChapter) + && (Math.abs(r.fraction - page.chapterFraction) < 0.01) + if (same) + return + page.currentChapter = r.chapter + page.chapterFraction = r.fraction + // aggiorna anche la copia locale + appSettings.setBookmarkKey(page.bookId, page.bookFormat, key) + if (page.ready && book.chapterCount() > 0) + loadChapter() + } + // sync non disponibile (server irraggiungibile, login bloccato, ...): + // si continua in locale, senza disturbare il lettore + onBookmarkError: { } + onLoginFailed: { } + } } diff --git a/src/apiclient.cpp b/src/apiclient.cpp index d8a920e..77606e9 100644 --- a/src/apiclient.cpp +++ b/src/apiclient.cpp @@ -4,6 +4,7 @@ #include "settings.h" #include +#include #include #include #include @@ -135,7 +136,123 @@ static bool looksLikeImage(const QByteArray &data) || data.startsWith("GIF8") || data.startsWith("RIFF"); } +// --------------------------------------------------------------------------- +// Login di sessione calibre-web (route web: richiedono flask-login, il Basic +// auth serve solo per OPDS) +// --------------------------------------------------------------------------- + +QString ApiClient::extractCsrfToken(const QByteArray &body) const +{ + // + QRegularExpression re(QStringLiteral( + "name=[\"']csrf_token[\"'][^>]*value=[\"']([^\"']+)[\"']")); + const QRegularExpressionMatch match = re.match(QString::fromUtf8(body)); + return match.hasMatch() ? match.captured(1) : QString(); +} + +void ApiClient::ensureLogin(const std::function &after) +{ + if (m_loggedIn) { + after(); + return; + } + if (m_loginInProgress) { + m_loginQueue.append(after); + return; + } + // accesso anonimo: nessun login possibile, esegui comunque (l'endpoint + // deciderà); oppure cooldown dopo un login fallito (limiter server 3/min) + if (m_settings->username().isEmpty() + || QDateTime::currentMSecsSinceEpoch() < m_loginCooldownUntil) { + after(); + return; + } + + m_loginInProgress = true; + m_loginQueue.append(after); + + // 1. GET /login per il token CSRF + const QUrl loginUrl(m_settings->baseUrl() + QStringLiteral("/login")); + QNetworkRequest request(loginUrl); + request.setRawHeader("User-Agent", "harbour-calibreweb/0.1"); + QNetworkReply *reply = m_nam.get(request); + if (m_settings->ignoreSslErrors()) { + connect(reply, &QNetworkReply::sslErrors, reply, + static_cast &)>( + &QNetworkReply::ignoreSslErrors)); + } + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + reply->deleteLater(); + const QString token = extractCsrfToken(reply->readAll()); + if (token.isEmpty()) { + qDebug() << "login: token CSRF non trovato"; + finishLogin(false, QStringLiteral( + "Login non disponibile: pagina di login non raggiungibile")); + return; + } + + // 2. POST del form standard calibre-web + QNetworkRequest req(QUrl(m_settings->baseUrl() + QStringLiteral("/login"))); + req.setRawHeader("User-Agent", "harbour-calibreweb/0.1"); + req.setHeader(QNetworkRequest::ContentTypeHeader, + QLatin1String("application/x-www-form-urlencoded")); + applyAuth(req); + const QByteArray body = "username=" + QUrl::toPercentEncoding(m_settings->username()) + + "&password=" + QUrl::toPercentEncoding(m_settings->password()) + + "&csrf_token=" + QUrl::toPercentEncoding(token) + + "&next=/&remember_me=on&submit=Submit"; + QNetworkReply *post = m_nam.post(req, body); + if (m_settings->ignoreSslErrors()) { + connect(post, &QNetworkReply::sslErrors, post, + static_cast &)>( + &QNetworkReply::ignoreSslErrors)); + } + connect(post, &QNetworkReply::finished, this, [this, post]() { + post->deleteLater(); + const int status = post->attribute( + QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray respBody = post->readAll(); + // successo: redirect a / (QNAM segue) e il body non contiene il + // form di login; fallimento: la pagina di login viene ri-renderizzata + const bool stillForm = respBody.contains("name=\"password\"") + || respBody.contains("name='password'"); + if (status >= 400 || stillForm) { + qDebug() << "login fallito status:" << status + << "form-ancora-presente=" << stillForm; + finishLogin(false, QStringLiteral( + "Login fallito: credenziali non valide o accesso bloccato")); + } else { + qDebug() << "login OK status:" << status; + finishLogin(true, QString()); + } + }); + }); +} + +void ApiClient::finishLogin(bool ok, const QString &message) +{ + m_loginInProgress = false; + m_loggedIn = ok; + if (!ok) + m_loginCooldownUntil = QDateTime::currentMSecsSinceEpoch() + 60000; + const QList> queue = m_loginQueue; + m_loginQueue.clear(); + if (!ok && !message.isEmpty()) + emit loginFailed(message); + // esegui la coda anche in caso di fallimento: le richieste andranno a + // vuoto in modo rilevato (bookmarkError), senza bloccare il lettore + for (const auto &fn : queue) + fn(); +} + void ApiClient::postBookmark(int bookId, const QString &format, const QString &key) +{ + ensureLogin([this, bookId, format, key]() { + sendBookmark(bookId, format, key); + }); +} + +void ApiClient::sendBookmark(int bookId, const QString &format, const QString &key) { const QString url = m_settings->baseUrl() + QStringLiteral("/ajax/bookmark/") + QString::number(bookId) + QLatin1Char('/') + format; @@ -155,14 +272,26 @@ void ApiClient::postBookmark(int bookId, const QString &format, const QString &k static_cast &)>( &QNetworkReply::ignoreSslErrors)); } - connect(reply, &QNetworkReply::finished, this, [this, reply, url, key]() { + connect(reply, &QNetworkReply::finished, this, [this, reply, url, key, bookId]() { reply->deleteLater(); const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const QByteArray respBody = reply->readAll(); qDebug() << "postBookmark status:" << status; + // senza sessione il server risponde col form di login (302 seguito) + const bool authFail = respBody.contains("name=\"password\"") + || respBody.contains("name='password'"); + if (authFail) { + qDebug() << "postBookmark: sessione non valida (form di login nella risposta)"; + emit bookmarkError(bookId, QStringLiteral("Sync segnalibro: sessione non valida")); + return; + } // 400/403 = probabile CSRF attivo: recupera il token dalla pagina di login e riprova if ((status == 400 || status == 403) && !m_csrfRetried) { m_csrfRetried = true; fetchCsrfTokenAndRetry(url, key); + } else if (status >= 400) { + qDebug() << "postBookmark ERROR status:" << status; + emit bookmarkError(bookId, QStringLiteral("Sync segnalibro fallita (%1)").arg(status)); } }); } @@ -181,13 +310,9 @@ void ApiClient::fetchCsrfTokenAndRetry(const QString &url, const QString &key) } connect(reply, &QNetworkReply::finished, this, [this, reply, url, key]() { reply->deleteLater(); - const QByteArray body = reply->readAll(); - // - QRegularExpression re(QStringLiteral( - "name=[\"']csrf_token[\"'][^>]*value=[\"']([^\"']+)[\"']")); - const QRegularExpressionMatch match = re.match(QString::fromUtf8(body)); - if (match.hasMatch()) { - postBookmarkWithCsrf(url, key, match.captured(1)); + const QString csrfToken = extractCsrfToken(reply->readAll()); + if (!csrfToken.isEmpty()) { + postBookmarkWithCsrf(url, key, csrfToken); } else { qDebug() << "postBookmark: token CSRF non trovato nella pagina di login"; } @@ -219,6 +344,42 @@ void ApiClient::postBookmarkWithCsrf(const QString &url, const QString &key, }); } +void ApiClient::fetchBookmark(int bookId, const QString &format) +{ + if (bookId <= 0) + return; + ensureLogin([this, bookId, format]() { + requestBookmark(bookId, format); + }); +} + +void ApiClient::requestBookmark(int bookId, const QString &format) +{ + const QString url = m_settings->baseUrl() + QStringLiteral("/read/") + + QString::number(bookId) + QLatin1Char('/') + format; + qDebug() << "fetchBookmark:" << url; + + QNetworkReply *reply = startGet(QUrl(url)); + connect(reply, &QNetworkReply::finished, this, [this, reply, bookId, format]() { + reply->deleteLater(); + const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (reply->error() != QNetworkReply::NoError || status >= 400) { + qDebug() << "fetchBookmark ERROR status:" << status + << "err:" << reply->errorString(); + emit bookmarkError(bookId, QStringLiteral( + "Segnalibro server non disponibile (%1)").arg(status)); + return; + } + const QByteArray body = reply->readAll(); + // l'HTML del reader incorpora la posizione: bookmark: "chiave" + QRegularExpression re(QStringLiteral("bookmark:\\s*\"([^\"]*)\"")); + const QRegularExpressionMatch m = re.match(QString::fromUtf8(body)); + const QString key = m.hasMatch() ? m.captured(1).trimmed() : QString(); + qDebug() << "fetchBookmark OK book:" << bookId << "key=[" << key << "]"; + emit bookmarkReady(bookId, format, key); + }); +} + void ApiClient::fetchImage(const QString &url, const QString &id) { if (url.isEmpty()) { diff --git a/src/apiclient.h b/src/apiclient.h index 52482c0..66a67c1 100644 --- a/src/apiclient.h +++ b/src/apiclient.h @@ -5,6 +5,8 @@ #include #include +#include + class QNetworkReply; class QNetworkRequest; class Settings; @@ -12,6 +14,9 @@ class Settings; // Cliente OPDS: scarica i feed (getFeed), li parsa e li espone come QVariantList // di entry (title, authors, summary, coverUrl, formats, isNavigation, linkUrl). // Gestisce anche il fetch delle copertine in una cache locale su disco. +// Le route web di calibre-web (login, /read, /ajax/bookmark) richiedono una +// sessione flask-login: l'app fa login col form standard (username+password+ +// csrf_token) e riusa il cookie di sessione del QNetworkAccessManager. class ApiClient : public QObject { Q_OBJECT @@ -23,24 +28,42 @@ public: Q_INVOKABLE void fetchImage(const QString &url, const QString &id); // sincronizza la posizione di lettura su calibre-web (POST /ajax/bookmark) Q_INVOKABLE void postBookmark(int bookId, const QString &format, const QString &key); + // legge il segnalibro dal server: GET /read// e estrae + // bookmark: "chiave" dall'HTML del reader + Q_INVOKABLE void fetchBookmark(int bookId, const QString &format); signals: void feedReady(const QVariantList &entries, const QString &nextUrl, const QString &feedTitle); void feedError(const QString &message); void imageReady(const QString &id, const QString &localPath); + // chiave vuota = nessun segnalibro sul server + void bookmarkReady(int bookId, const QString &format, const QString &key); + void bookmarkError(int bookId, const QString &message); + void loginFailed(const QString &message); private: QNetworkReply *startGet(const QUrl &url); void applyAuth(QNetworkRequest &request); QString resolveUrl(const QString &relative) const; + // login di sessione: esegue after() subito se già autenticati, altrimenti + // fa login e poi esegue (coda per le richieste arrivate nel frattempo) + void ensureLogin(const std::function &after); + void finishLogin(bool ok, const QString &message); + QString extractCsrfToken(const QByteArray &body) const; + void sendBookmark(int bookId, const QString &format, const QString &key); void postBookmarkWithCsrf(const QString &url, const QString &key, const QString &csrfToken); void fetchCsrfTokenAndRetry(const QString &url, const QString &key); + void requestBookmark(int bookId, const QString &format); Settings *m_settings; QNetworkAccessManager m_nam; QString m_cacheDir; bool m_csrfRetried = false; + bool m_loggedIn = false; + bool m_loginInProgress = false; + qint64 m_loginCooldownUntil = 0; // ms epoch: niente retry login per 60 s dopo un fallimento + QList> m_loginQueue; }; #endif // APICLIENT_H