diff --git a/src/app/AppController.cpp b/src/app/AppController.cpp index 02d944c..6de0267 100644 --- a/src/app/AppController.cpp +++ b/src/app/AppController.cpp @@ -37,6 +37,8 @@ void AppController::initialize() emit serverRunningChanged(); } + m_server->setReceivePin(m_settings->receivePin()); + connect(m_discovery, &LocalSend::DiscoveryManager::deviceDiscovered, this, &AppController::onDeviceDiscovered); connect(m_discovery, &LocalSend::DiscoveryManager::deviceLost, @@ -60,6 +62,10 @@ void AppController::initialize() this, &AppController::onPrepareUploadResponse); connect(m_httpClient, &LocalSend::HttpClient::prepareUploadError, this, &AppController::onPrepareUploadError); + connect(m_httpClient, &LocalSend::HttpClient::prepareUploadPinRequired, + this, &AppController::onPrepareUploadPinRequired); + connect(m_httpClient, &LocalSend::HttpClient::prepareUploadTooManyAttempts, + this, &AppController::onPrepareUploadTooManyAttempts); connect(m_httpClient, &LocalSend::HttpClient::uploadProgress, this, &AppController::onUploadProgress); connect(m_httpClient, &LocalSend::HttpClient::uploadCompleted, @@ -379,6 +385,20 @@ bool AppController::hasPendingFiles() const return !m_pendingFilesList.isEmpty(); } +QString AppController::receivePin() const +{ + return m_settings->receivePin(); +} + +void AppController::setReceivePin(const QString& pin) +{ + if (m_settings->receivePin() != pin) { + m_settings->setReceivePin(pin); + m_server->setReceivePin(pin); + emit receivePinChanged(); + } +} + void AppController::addFiles(const QStringList& filePaths) { QMimeDatabase mimeDb; @@ -449,6 +469,7 @@ void AppController::sendFiles(const QString& deviceFingerprint, const QStringLis m_pendingSendPaths = filePaths; m_currentFileIndex = 0; m_sendProgress = 0.0; + m_pinFirstAttempt = true; emit sendingChanged(); emit sendProgressChanged(); @@ -552,6 +573,55 @@ void AppController::onPrepareUploadError(const QString& error) emit sendingChanged(); } +void AppController::onPrepareUploadPinRequired() +{ + qDebug() << "[AppController] onPrepareUploadPinRequired, firstAttempt:" << m_pinFirstAttempt; + emit pinRequired(m_pinFirstAttempt); + m_pinFirstAttempt = false; +} + +void AppController::onPrepareUploadTooManyAttempts() +{ + qWarning() << "[AppController] onPrepareUploadTooManyAttempts"; + emit sendError(QStringLiteral("Too many PIN attempts")); + m_currentSendSessionId.clear(); + emit sendingChanged(); +} + +void AppController::retryWithPin(const QString& pin) +{ + if (m_currentSendDeviceFingerprint.isEmpty() || !m_devices.contains(m_currentSendDeviceFingerprint)) { + m_currentSendSessionId.clear(); + emit sendingChanged(); + return; + } + + LocalSend::Device target = m_devices[m_currentSendDeviceFingerprint]; + + QMap files; + QMimeDatabase mimeDb; + + for (int i = 0; i < m_pendingSendPaths.size(); ++i) { + QFileInfo info(m_pendingSendPaths[i]); + if (!info.exists()) { + continue; + } + LocalSend::FileDto fileDto; + fileDto.id = QString::number(i); + fileDto.fileName = info.fileName(); + fileDto.size = info.size(); + fileDto.fileType = mimeDb.mimeTypeForFile(info).name(); + files.insert(fileDto.id, fileDto); + } + + LocalSend::PrepareUploadRequestDto request; + request.info = buildRegisterDto(); + request.files = files; + + qDebug() << "[AppController] Retrying prepare-upload with PIN to" << target.ip; + m_httpClient->prepareUpload(target, request, pin); +} + void AppController::onUploadProgress(qint64 sent, qint64 total) { if (total > 0) { diff --git a/src/app/AppController.h b/src/app/AppController.h index 5b704ad..1d373e0 100644 --- a/src/app/AppController.h +++ b/src/app/AppController.h @@ -23,6 +23,7 @@ class AppController : public QObject Q_PROPERTY(double sendProgress READ sendProgress NOTIFY sendProgressChanged) Q_PROPERTY(QVariantList pendingFiles READ pendingFiles NOTIFY pendingFilesChanged) Q_PROPERTY(bool hasPendingFiles READ hasPendingFiles NOTIFY pendingFilesChanged) + Q_PROPERTY(QString receivePin READ receivePin WRITE setReceivePin NOTIFY receivePinChanged) public: explicit AppController(QObject* parent = nullptr); @@ -49,6 +50,8 @@ public: double sendProgress() const; QVariantList pendingFiles() const; bool hasPendingFiles() const; + QString receivePin() const; + void setReceivePin(const QString& pin); Q_INVOKABLE void startDiscovery(); Q_INVOKABLE void stopDiscovery(); @@ -63,6 +66,7 @@ public: Q_INVOKABLE void addFiles(const QStringList& filePaths); Q_INVOKABLE void removePendingFile(int index); Q_INVOKABLE void clearPendingFiles(); + Q_INVOKABLE void retryWithPin(const QString& pin); signals: void aliasChanged(); @@ -81,6 +85,8 @@ signals: void receiveError(const QString& sessionId, const QString& error); void sendCompleted(const QString& sessionId); void sendError(const QString& error); + void pinRequired(bool firstAttempt); + void receivePinChanged(); private slots: void onDeviceDiscovered(const LocalSend::Device& device); @@ -98,6 +104,8 @@ private slots: void onPrepareUploadResponse(const LocalSend::PrepareUploadResponseDto& response); void onPrepareUploadError(const QString& error); + void onPrepareUploadPinRequired(); + void onPrepareUploadTooManyAttempts(); void onUploadProgress(qint64 sent, qint64 total); void onUploadCompleted(); void onUploadError(const QString& error); @@ -119,6 +127,7 @@ private: QVariantList m_pendingFilesList; int m_currentFileIndex = 0; double m_sendProgress = 0.0; + bool m_pinFirstAttempt = true; LocalSend::InfoDto buildInfoDto() const; QString generateUniqueFilePath(const QString& baseDir, const QString& fileName) const; diff --git a/src/app/qml/main.qml b/src/app/qml/main.qml index 37fee6e..928ae33 100644 --- a/src/app/qml/main.qml +++ b/src/app/qml/main.qml @@ -286,9 +286,15 @@ ApplicationWindow { function onSendError(error) { sendProgressDialog.close() + pinDialog.close() errorDialog.text = error errorDialog.open() } + + function onPinRequired(firstAttempt) { + pinDialog.isFirstAttempt = firstAttempt + pinDialog.open() + } } Dialog { @@ -317,6 +323,112 @@ ApplicationWindow { } } + Dialog { + id: pinDialog + anchors.centerIn: parent + modal: true + closePolicy: Popup.NoAutoClose + title: qsTr("PIN Required") + + property bool isFirstAttempt: true + + ColumnLayout { + spacing: 12 + + Label { + text: pinDialog.isFirstAttempt + ? qsTr("The receiver requires a PIN to accept files.") + : qsTr("Invalid PIN. Please try again.") + wrapMode: Text.WordWrap + Layout.fillWidth: true + color: pinDialog.isFirstAttempt ? palette.text : "red" + } + + TextField { + id: pinInput + Layout.fillWidth: true + placeholderText: qsTr("Enter PIN") + echoMode: TextInput.Password + focus: true + onAccepted: pinDialog.submitPin() + } + } + + footer: DialogButtonBox { + Button { + text: qsTr("Cancel") + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + } + Button { + text: qsTr("Submit") + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + enabled: pinInput.text.length > 0 + } + } + + onAccepted: submitPin() + onRejected: { + appController.cancelSend() + close() + } + + function submitPin() { + if (pinInput.text.length > 0) { + appController.retryWithPin(pinInput.text) + pinInput.text = "" + } + } + + onOpened: { + pinInput.text = "" + pinInput.forceActiveFocus() + } + } + + Dialog { + id: setPinDialog + anchors.centerIn: parent + modal: true + title: qsTr("Set Receive PIN") + + ColumnLayout { + spacing: 12 + + Label { + text: qsTr("Enter a PIN that senders must provide to transfer files to this device. Leave empty to disable.") + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + TextField { + id: setPinInput + Layout.fillWidth: true + placeholderText: qsTr("Enter PIN") + onAccepted: setPinDialog.accepted() + } + } + + footer: DialogButtonBox { + Button { + text: qsTr("Cancel") + DialogButtonBox.buttonRole: DialogButtonBox.RejectRole + } + Button { + text: qsTr("OK") + DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole + } + } + + onAccepted: { + appController.receivePin = setPinInput.text + } + + onOpened: { + setPinInput.text = appController.receivePin + setPinInput.forceActiveFocus() + } + } + function formatSize(bytes) { if (bytes < 1024) return bytes + " B" if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB" @@ -597,6 +709,25 @@ ApplicationWindow { checked: appController.quickSave onCheckedChanged: appController.quickSave = checked } + + Label { text: qsTr("Receive PIN:") } + RowLayout { + Layout.fillWidth: true + Label { + text: appController.receivePin.length > 0 ? qsTr("Enabled") : qsTr("Disabled") + color: appController.receivePin.length > 0 ? "green" : palette.mid + } + Item { Layout.fillWidth: true } + Button { + text: appController.receivePin.length > 0 ? qsTr("Change") : qsTr("Set PIN") + onClicked: setPinDialog.open() + } + Button { + text: qsTr("Remove") + visible: appController.receivePin.length > 0 + onClicked: appController.receivePin = "" + } + } } FolderDialog { diff --git a/src/core/include/LocalSendCore/HttpClient.h b/src/core/include/LocalSendCore/HttpClient.h index c327dbf..c714889 100644 --- a/src/core/include/LocalSendCore/HttpClient.h +++ b/src/core/include/LocalSendCore/HttpClient.h @@ -22,7 +22,7 @@ public: void getInfo(const Device& device); void registerDevice(const Device& device, const RegisterDto& dto); - void prepareUpload(const Device& device, const PrepareUploadRequestDto& dto); + void prepareUpload(const Device& device, const PrepareUploadRequestDto& dto, const QString& pin = QString()); void uploadFile(const Device& device, const QString& sessionId, const QString& fileId, const QString& token, const QString& filePath); void cancel(const Device& device, const QString& sessionId); @@ -34,6 +34,8 @@ signals: void registerError(const QString& error); void prepareUploadResponse(const PrepareUploadResponseDto& response); void prepareUploadError(const QString& error); + void prepareUploadPinRequired(); + void prepareUploadTooManyAttempts(); void uploadProgress(qint64 sent, qint64 total); void uploadCompleted(); void uploadError(const QString& error); diff --git a/src/core/include/LocalSendCore/HttpServer.h b/src/core/include/LocalSendCore/HttpServer.h index be30526..8a12c69 100644 --- a/src/core/include/LocalSendCore/HttpServer.h +++ b/src/core/include/LocalSendCore/HttpServer.h @@ -29,6 +29,7 @@ public: void stop(); void setLocalInfo(const InfoDto& info, const QString& fingerprint); + void setReceivePin(const QString& pin); #ifdef HAS_QTHTTPSERVER void setSslConfiguration(const QSslConfiguration& config); #endif @@ -62,6 +63,8 @@ private: InfoDto m_localInfo; QString m_localFingerprint; + QString m_receivePin; + QMap m_pinAttempts; quint16 m_port = 0; #ifdef HAS_QTHTTPSERVER @@ -71,6 +74,7 @@ private: QFuture handlePrepareUploadRequest(const QHttpServerRequest& request); QHttpServerResponse handleUploadRequest(const QHttpServerRequest& request); QHttpServerResponse handleCancelRequest(const QHttpServerRequest& request); + bool checkPin(const QHttpServerRequest& request, const QHostAddress& peer); #endif }; diff --git a/src/core/include/LocalSendCore/Settings.h b/src/core/include/LocalSendCore/Settings.h index 924e2ad..a69d82a 100644 --- a/src/core/include/LocalSendCore/Settings.h +++ b/src/core/include/LocalSendCore/Settings.h @@ -29,6 +29,9 @@ public: bool quickSave() const; void setQuickSave(bool enabled); + QString receivePin() const; + void setReceivePin(const QString& pin); + QString deviceModel() const; void setDeviceModel(const QString& model); diff --git a/src/core/src/HttpClient.cpp b/src/core/src/HttpClient.cpp index 10665b0..98c68e1 100644 --- a/src/core/src/HttpClient.cpp +++ b/src/core/src/HttpClient.cpp @@ -99,9 +99,14 @@ void HttpClient::registerDevice(const Device& device, const RegisterDto& dto) }); } -void HttpClient::prepareUpload(const Device& device, const PrepareUploadRequestDto& dto) +void HttpClient::prepareUpload(const Device& device, const PrepareUploadRequestDto& dto, const QString& pin) { QUrl url = buildUrl(device, ApiRoute::PREPARE_UPLOAD); + if (!pin.isEmpty()) { + QUrlQuery query; + query.addQueryItem(QStringLiteral("pin"), pin); + url.setQuery(query); + } QByteArray data = QJsonDocument(dto.toJson()).toJson(QJsonDocument::Compact); qDebug() << "[HttpClient] prepareUpload to" << url.toString(); @@ -112,6 +117,20 @@ void HttpClient::prepareUpload(const Device& device, const PrepareUploadRequestD connect(reply, &QNetworkReply::finished, this, [this, reply]() { reply->deleteLater(); + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + if (statusCode == 401) { + qDebug() << "[HttpClient] prepareUpload: PIN required (401)"; + emit prepareUploadPinRequired(); + return; + } + + if (statusCode == 429) { + qWarning() << "[HttpClient] prepareUpload: Too many attempts (429)"; + emit prepareUploadTooManyAttempts(); + return; + } + if (reply->error() != QNetworkReply::NoError) { qWarning() << "[HttpClient] prepareUpload error:" << reply->errorString(); emit prepareUploadError(reply->errorString()); diff --git a/src/core/src/HttpServer.cpp b/src/core/src/HttpServer.cpp index 0d0d989..e5a3f0c 100644 --- a/src/core/src/HttpServer.cpp +++ b/src/core/src/HttpServer.cpp @@ -33,6 +33,38 @@ void HttpServer::setSslConfiguration(const QSslConfiguration& config) m_sslConfig = config; } +void HttpServer::setReceivePin(const QString& pin) +{ + m_receivePin = pin; + m_pinAttempts.clear(); +} + +bool HttpServer::checkPin(const QHttpServerRequest& request, const QHostAddress& peer) +{ + if (m_receivePin.isEmpty()) { + return true; + } + + QString peerIp = peer.toString(); + int attempts = m_pinAttempts.value(peerIp, 0); + + if (attempts >= 3) { + return false; + } + + QUrlQuery query(request.url().query()); + QString requestPin = query.queryItemValue(QStringLiteral("pin")); + + if (requestPin != m_receivePin) { + if (!requestPin.isEmpty()) { + m_pinAttempts[peerIp] = attempts + 1; + } + return false; + } + + return true; +} + bool HttpServer::start(quint16 port, bool https) { Q_UNUSED(https) @@ -180,6 +212,41 @@ QHttpServerResponse HttpServer::handleRegisterRequest(const QHttpServerRequest& QFuture HttpServer::handlePrepareUploadRequest(const QHttpServerRequest& request) { + QHostAddress peer = request.remoteAddress(); + + if (!m_receivePin.isEmpty()) { + QString peerIp = peer.toString(); + int attempts = m_pinAttempts.value(peerIp, 0); + + if (attempts >= 3) { + auto promise = std::make_shared>(); + promise->start(); + QJsonObject errorObj; + errorObj[QStringLiteral("message")] = QStringLiteral("Too many attempts."); + promise->addResult(QHttpServerResponse(QJsonDocument(errorObj).toJson(QJsonDocument::Compact), + QHttpServerResponse::StatusCode::Forbidden)); + promise->finish(); + return promise->future(); + } + + QUrlQuery query(request.url().query()); + QString requestPin = query.queryItemValue(QStringLiteral("pin")); + + if (requestPin != m_receivePin) { + if (!requestPin.isEmpty()) { + m_pinAttempts[peerIp] = attempts + 1; + } + auto promise = std::make_shared>(); + promise->start(); + QJsonObject errorObj; + errorObj[QStringLiteral("message")] = QStringLiteral("Invalid pin."); + promise->addResult(QHttpServerResponse(QJsonDocument(errorObj).toJson(QJsonDocument::Compact), + QHttpServerResponse::StatusCode::Unauthorized)); + promise->finish(); + return promise->future(); + } + } + QJsonParseError error; QJsonDocument doc = QJsonDocument::fromJson(request.body(), &error); diff --git a/src/core/src/Settings.cpp b/src/core/src/Settings.cpp index ed67e3c..6e0cf98 100644 --- a/src/core/src/Settings.cpp +++ b/src/core/src/Settings.cpp @@ -78,6 +78,23 @@ void Settings::setQuickSave(bool enabled) } } +QString Settings::receivePin() const +{ + return m_settings.value(QStringLiteral("receivePin")).toString(); +} + +void Settings::setReceivePin(const QString& pin) +{ + if (receivePin() != pin) { + if (pin.isEmpty()) { + m_settings.remove(QStringLiteral("receivePin")); + } else { + m_settings.setValue(QStringLiteral("receivePin"), pin); + } + m_settings.sync(); + } +} + QString Settings::deviceModel() const { return m_settings.value(QStringLiteral("deviceModel"),