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.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
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user