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:
@@ -315,6 +315,11 @@ Page {
|
|||||||
page.currentChapter = ch
|
page.currentChapter = ch
|
||||||
page.chapterFraction = fr
|
page.chapterFraction = fr
|
||||||
loadChapter()
|
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: {
|
onStatusChanged: {
|
||||||
@@ -327,4 +332,33 @@ Page {
|
|||||||
applyMarginsJS()
|
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
@@ -4,6 +4,7 @@
|
|||||||
#include "settings.h"
|
#include "settings.h"
|
||||||
|
|
||||||
#include <QCryptographicHash>
|
#include <QCryptographicHash>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
@@ -135,7 +136,123 @@ static bool looksLikeImage(const QByteArray &data)
|
|||||||
|| data.startsWith("GIF8") || data.startsWith("RIFF");
|
|| 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)
|
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/")
|
const QString url = m_settings->baseUrl() + QStringLiteral("/ajax/bookmark/")
|
||||||
+ QString::number(bookId) + QLatin1Char('/') + format;
|
+ 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> &)>(
|
static_cast<void (QNetworkReply::*)(const QList<QSslError> &)>(
|
||||||
&QNetworkReply::ignoreSslErrors));
|
&QNetworkReply::ignoreSslErrors));
|
||||||
}
|
}
|
||||||
connect(reply, &QNetworkReply::finished, this, [this, reply, url, key]() {
|
connect(reply, &QNetworkReply::finished, this, [this, reply, url, key, bookId]() {
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||||
|
const QByteArray respBody = reply->readAll();
|
||||||
qDebug() << "postBookmark status:" << status;
|
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
|
// 400/403 = probabile CSRF attivo: recupera il token dalla pagina di login e riprova
|
||||||
if ((status == 400 || status == 403) && !m_csrfRetried) {
|
if ((status == 400 || status == 403) && !m_csrfRetried) {
|
||||||
m_csrfRetried = true;
|
m_csrfRetried = true;
|
||||||
fetchCsrfTokenAndRetry(url, key);
|
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]() {
|
connect(reply, &QNetworkReply::finished, this, [this, reply, url, key]() {
|
||||||
reply->deleteLater();
|
reply->deleteLater();
|
||||||
const QByteArray body = reply->readAll();
|
const QString csrfToken = extractCsrfToken(reply->readAll());
|
||||||
// <input id="csrf_token" name="csrf_token" type="hidden" value="...">
|
if (!csrfToken.isEmpty()) {
|
||||||
QRegularExpression re(QStringLiteral(
|
postBookmarkWithCsrf(url, key, csrfToken);
|
||||||
"name=[\"']csrf_token[\"'][^>]*value=[\"']([^\"']+)[\"']"));
|
|
||||||
const QRegularExpressionMatch match = re.match(QString::fromUtf8(body));
|
|
||||||
if (match.hasMatch()) {
|
|
||||||
postBookmarkWithCsrf(url, key, match.captured(1));
|
|
||||||
} else {
|
} else {
|
||||||
qDebug() << "postBookmark: token CSRF non trovato nella pagina di login";
|
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)
|
void ApiClient::fetchImage(const QString &url, const QString &id)
|
||||||
{
|
{
|
||||||
if (url.isEmpty()) {
|
if (url.isEmpty()) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
class QNetworkReply;
|
class QNetworkReply;
|
||||||
class QNetworkRequest;
|
class QNetworkRequest;
|
||||||
class Settings;
|
class Settings;
|
||||||
@@ -12,6 +14,9 @@ class Settings;
|
|||||||
// Cliente OPDS: scarica i feed (getFeed), li parsa e li espone come QVariantList
|
// Cliente OPDS: scarica i feed (getFeed), li parsa e li espone come QVariantList
|
||||||
// di entry (title, authors, summary, coverUrl, formats, isNavigation, linkUrl).
|
// di entry (title, authors, summary, coverUrl, formats, isNavigation, linkUrl).
|
||||||
// Gestisce anche il fetch delle copertine in una cache locale su disco.
|
// 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
|
class ApiClient : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -23,24 +28,42 @@ public:
|
|||||||
Q_INVOKABLE void fetchImage(const QString &url, const QString &id);
|
Q_INVOKABLE void fetchImage(const QString &url, const QString &id);
|
||||||
// sincronizza la posizione di lettura su calibre-web (POST /ajax/bookmark)
|
// sincronizza la posizione di lettura su calibre-web (POST /ajax/bookmark)
|
||||||
Q_INVOKABLE void postBookmark(int bookId, const QString &format, const QString &key);
|
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:
|
signals:
|
||||||
void feedReady(const QVariantList &entries, const QString &nextUrl, const QString &feedTitle);
|
void feedReady(const QVariantList &entries, const QString &nextUrl, const QString &feedTitle);
|
||||||
void feedError(const QString &message);
|
void feedError(const QString &message);
|
||||||
void imageReady(const QString &id, const QString &localPath);
|
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:
|
private:
|
||||||
QNetworkReply *startGet(const QUrl &url);
|
QNetworkReply *startGet(const QUrl &url);
|
||||||
void applyAuth(QNetworkRequest &request);
|
void applyAuth(QNetworkRequest &request);
|
||||||
QString resolveUrl(const QString &relative) const;
|
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,
|
void postBookmarkWithCsrf(const QString &url, const QString &key,
|
||||||
const QString &csrfToken);
|
const QString &csrfToken);
|
||||||
void fetchCsrfTokenAndRetry(const QString &url, const QString &key);
|
void fetchCsrfTokenAndRetry(const QString &url, const QString &key);
|
||||||
|
void requestBookmark(int bookId, const QString &format);
|
||||||
|
|
||||||
Settings *m_settings;
|
Settings *m_settings;
|
||||||
QNetworkAccessManager m_nam;
|
QNetworkAccessManager m_nam;
|
||||||
QString m_cacheDir;
|
QString m_cacheDir;
|
||||||
bool m_csrfRetried = false;
|
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
|
#endif // APICLIENT_H
|
||||||
|
|||||||
Reference in New Issue
Block a user