Sync avanzamento server completa: login di sessione calibre-web (form + csrf_token + cookie) per le route web, fetchBookmark (GET /read/<id>/<format> con estrazione bookmark: "chiave"), ripristino all'apertura del libro con posizione server dominante, POST a ogni pagina girata già attivo; fallimento login gestito con cooldown 60s e fallback locale

This commit is contained in:
2026-08-13 09:28:34 +02:00
parent 2652f413a5
commit 9bdec3eb2d
3 changed files with 226 additions and 8 deletions
+34
View File
@@ -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: { }
}
}
+169 -8
View File
@@ -4,6 +4,7 @@
#include "settings.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDebug>
#include <QDir>
#include <QFile>
@@ -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
{
// <input id="csrf_token" name="csrf_token" type="hidden" value="...">
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<void()> &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<void (QNetworkReply::*)(const QList<QSslError> &)>(
&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<void (QNetworkReply::*)(const QList<QSslError> &)>(
&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<std::function<void()>> 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<void (QNetworkReply::*)(const QList<QSslError> &)>(
&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();
// <input id="csrf_token" name="csrf_token" type="hidden" value="...">
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()) {
+23
View File
@@ -5,6 +5,8 @@
#include <QObject>
#include <QVariantList>
#include <functional>
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/<id>/<format> 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<void()> &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<std::function<void()>> m_loginQueue;
};
#endif // APICLIENT_H