Files
thehub/wallet-backup-server/BackupWebServer.cpp
T
2025-11-02 17:17:04 +01:00

350 lines
13 KiB
C++

/*
* This file is part of the Flowee project
* Copyright (C) 2025 Tom Zander <tom@flowee.org>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include "BackupWebServer.h"
#include "streaming/BufferPools.h"
#include <httpengine/socket.h>
#include <primitives/PublicKey.h>
#include <qjsonobject.h>
#include <utils/Logger.h>
#include <cashaddr.h>
#include <QFile>
#include <QSettings>
#include <QTimer>
#include <QDir>
#include <QCoreApplication>
#include <QDirIterator>
#include <sha256.h>
constexpr int MinMessageSize = 77; // assuming the payload being only 4 bytes
namespace {
QByteArray toHtmlDate(const QDateTime &dt) {
const static QString outformat("ddd, dd MMM yyyy hh:mm:ss");
auto string = dt.toString(outformat);
string += " GMT";
return string.toLatin1();
}
}
BackupWebServer::BackupWebServer(QObject *parent)
: QObject(parent)
{
connect (&m_fileChangeMonitor, &QFileSystemWatcher::fileChanged, this, [=]() {
QTimer::singleShot(100, this, &BackupWebServer::reparseConfig);
});
}
void BackupWebServer::handleIncoming(std::weak_ptr<HttpEngine::Server> weakServer, HttpEngine::WebRequest *request)
{
auto server = weakServer.lock();
if (server == nullptr)
return;
auto *socket = request->socket();
QObject::connect(socket, SIGNAL(disconnected()), request, SLOT(deleteLater()));
if (socket->method() != HttpEngine::Socket::HEAD
&& socket->method() != HttpEngine::Socket::GET
&& socket->method() != HttpEngine::Socket::POST) {
logInfo() << "closing, unsupported method";
socket->setStatusCode(400);
socket->close();
return;
}
socket->setHeader("server", "Flowee");
if (socket->method() == HttpEngine::Socket::POST) {
if (socket->contentLength() > 50000
|| socket->bytesAvailable() > socket->contentLength()
|| socket->contentLength() < MinMessageSize) {
logInfo() << "closing, wrong content length:" << socket->contentLength();
// POST data size wrong
socket->setStatusCode(400);
socket->write("wrong size");
socket->close();
return;
}
if (socket->contentLength() > socket->bytesAvailable()) {
// wait for the full POST data to become available.
QObject::connect(socket, &HttpEngine::Socket::readChannelFinished, this, [this, request, weakServer]() {
this->handleIncoming(weakServer, request);
});
return;
}
auto pool = Streaming::pool(socket->contentLength());
int read = socket->read(pool->end(), socket->bytesAvailable());
pool->markUsed(read);
logDebug() << "read amount" << read;
auto message = pool->commit();
logDebug() << "Message size" << message.size();
auto timestampData = message.mid(0, 4);
uint32_t timestamp = *reinterpret_cast<const uint32_t*>(timestampData.begin());
if (timestamp < 1700000000 // too old.
|| timestamp > QDateTime::currentSecsSinceEpoch() + 1000) { // too new
logInfo() << "Invalid timestamp (too old or too new)";
socket->setStatusCode(400);
socket->write("wrong timestamp");
socket->close();
return;
}
auto dataSizeData = message.mid(4, 4);
uint32_t dataSize = *reinterpret_cast<const uint32_t*>(dataSizeData.begin());
if (message.size() - 65 - 4 - 4 != (int) dataSize) {
logInfo() << "Data-size invalid";
socket->setStatusCode(400);
socket->write("unrecognized format");
socket->close();
return;
}
auto data = message.mid(8, dataSize);
auto signature = message.mid(8 + dataSize, 65);
PublicKey pk;
CSHA256 hasher;
hasher.write(message.begin(), 8 + dataSize);
char buf[CSHA256::OUTPUT_SIZE];
hasher.finalize(buf);
uint256 hash(buf);
std::vector<uint8_t> sig(signature.begin(), signature.end());
if (!pk.recoverCompact(hash, sig) || !pk.verifyCompact(hash, sig)) {
logInfo() << "Signature invalid";
socket->setStatusCode(400);
socket->write("verification failed");
socket->close();
return;
}
auto id = pk.getKeyId();
CashAddress::Content c;
c.type = CashAddress::PUBKEY_TYPE;
c.hash = std::vector<uint8_t>(id.begin(), id.end());
auto s = CashAddress::encodeCashAddr("bitcoincash", c);
const int size = 11; // the size of the chain prefix 'bitcoincash'
const auto owner = QString::fromLatin1(s.c_str() + size + 1, s.size() - size -1); // the 1 is for the colon
QDir base(m_dataDir);
base.mkdir(owner);
const auto dirOfUser = m_dataDir + owner + '/';
const auto newFile = QString::number(timestamp);
const auto filename = dirOfUser + newFile;
logCritical() << "Writing file:" << filename;
QFile out(filename);
if (!out.open(QIODevice::WriteOnly)) {
logWarning() << "Can't write uploaded data file:" << filename;
socket->setStatusCode(501);
socket->writeHeaders();
socket->write("sorry, my bad");
socket->close();
return;
}
out.write(message.begin(), message.size());
out.close();
// after writing the new revision, named by its unix-timestamp
// we remove older ones and update symlinks (if needed) to have
// a simple way for clients to fetch the latest.
QStringList allRevisions;
QDirIterator revisionsIter(dirOfUser, QDir::Files);
while (revisionsIter.hasNext()) {
auto pathInfo = revisionsIter.nextFileInfo();
if (!pathInfo.isSymLink())
allRevisions.append(pathInfo.fileName());
}
std::sort(allRevisions.rbegin(), allRevisions.rend());
int index = 0;
for (const auto &revision : allRevisions) {
// the first (most recent) file
QFileInfo symlink(dirOfUser + QString::number(++index));
if (!(symlink.isSymLink() && symlink.readSymLink() == revision)) {
QFile::remove(symlink.absoluteFilePath());
if (index <= m_maxRevisions)
QFile::link(revision, symlink.absoluteFilePath());
}
if (index > m_maxRevisions)
QFile::remove(dirOfUser + revision);
}
socket->setStatusCode(200);
socket->setHeader("Content-Length", "2");
socket->writeHeaders();
socket->write("ok");
socket->close();
return;
}
// handle GET / HEAD
if (!request->path().startsWith(m_prefix)) {
logCritical() << "probable incorrect prefix provided in config";
socket->setStatusCode(500);
socket->setHeader("Content-Length", "16");
socket->writeHeaders();
socket->write("badly configured");
socket->close();
return;
}
// this is boring filesystem mapping.
const auto path = request->path().mid(m_prefix.size()); // notice that path() is already sanitized
// downloads always have to start with the address.
// Either they want the data for the address, in which case the address is all (maybe a slash)
// or otherwise they want a specific file which is an integer in the subdir of the address.
// either the indexed symlink, or a unix epoc numbered file.
// So do some super cheap checks to validate the path is as expected.
bool invalidPath = false;
if (path.size() < 2 || path.at(1) == '.') {
invalidPath = true;
} else {
const int nextSlash = path.indexOf('/', 1);
if ((nextSlash > 0 && path.indexOf('/', nextSlash + 1) > 0)
|| (nextSlash > 0 && path.size() - nextSlash > 11)) { // too much after slash.
invalidPath = true;
}
}
QFileInfo fi;
if (!invalidPath) {
fi = QFileInfo(m_dataDir + path);
if (!fi.isDir() && !fi.isFile())
invalidPath = true;
}
if (invalidPath) {
socket->setStatusCode(404);
socket->setHeader("Content-Length", "3");
socket->writeHeaders();
socket->write("404");
socket->close();
return;
}
const auto absFilePath = fi.absoluteFilePath();
if (fi.isFile()) {
logInfo() << "going to serve the file" << absFilePath;
QFile data(absFilePath);
if (!data.open(QIODevice::ReadOnly)) {
logWarning() << "Can't read file:" << fi.absoluteFilePath();
socket->setStatusCode(501);
socket->writeHeaders();
socket->write("sorry, my bad");
socket->close();
return;
}
socket->setHeader("Content-Length", QString::number(data.size()).toLatin1());
socket->setHeader("Date", toHtmlDate(QDateTime::currentDateTimeUtc()));
socket->setHeader("Content-Type", "application/octet-stream");
// only read the timestamp from the file.
data.seek(33);
uint32_t timestamp = 0;
data.read(reinterpret_cast<char*>(&timestamp), 4);
data.seek(0);
socket->setHeader("Last-Modified", toHtmlDate(QDateTime::fromSecsSinceEpoch(timestamp)));
socket->writeHeaders();
// then copy the file.
char buf[1024];
while (true) {
auto count = data.read(buf, 1024);
if (count <= 0)
break;
socket->write(buf, count);
}
socket->close();
return;
}
assert(fi.isDir());
logInfo() << "going to list the dir" << absFilePath;
// going to list the dir
QDirIterator iter(absFilePath);
QJsonObject root;
while (iter.hasNext()) {
auto entry = iter.nextFileInfo();
if (entry.isSymLink()) {
auto target = entry.symLinkTarget();
if (target.startsWith(absFilePath))
root[entry.fileName()] = target.mid(absFilePath.length() + 1);
}
}
socket->setHeader("Date", toHtmlDate(QDateTime::currentDateTimeUtc()));
socket->setHeader("Content-Type", "application/json");
QJsonDocument doc;
doc.setObject(root);
const auto json = doc.toJson();
socket->setHeader("Content-Length", QString::number(json.size()).toLatin1());
socket->writeHeaders();
socket->write(json);
socket->close();
}
void BackupWebServer::loadConfig(const QString &configFilename)
{
m_configPath = configFilename;
QTimer::singleShot(0, this, &BackupWebServer::reparseConfig);
}
void BackupWebServer::reparseConfig()
{
if (!m_configPath.isEmpty())
m_fileChangeMonitor.addPath(m_configPath);
QSettings config(m_configPath, QSettings::IniFormat);
int newPort = config.value("net/port", 42121).toInt();
QString prefix = config.value("generic/prefix", "/md").toString();
if (prefix.startsWith("/")) {
m_prefix = prefix;
} else {
logFatal() << "generic/prefix path not absolute";
QCoreApplication::exit(10);
return;
}
QString dataDir = config.value("generic/storage", QDir::currentPath()).toString();
if (dataDir.startsWith("/")) {
m_dataDir = dataDir;
} else {
logFatal() << "generic/storage path not absolute";
QCoreApplication::exit(10);
return;
}
if (!m_dataDir.endsWith('/'))
m_dataDir += "/";
if (!QDir("/").mkpath(m_dataDir)) {
logFatal() << "Storage dir invalid." << m_dataDir;
QCoreApplication::exit(10);
return;
}
if (newPort != m_port)
m_server.reset();
if (m_server.get() == nullptr) {
m_server.reset(new HttpEngine::Server());
std::weak_ptr<HttpEngine::Server> weak(m_server);
m_server->setHandler(std::bind(&BackupWebServer::handleIncoming, this, weak, std::placeholders::_1));
}
if (newPort != m_port) {
logCritical() << "Listening for HTTP connections on port:" << newPort;
m_server->listen(QHostAddress::Any, newPort);
m_port = newPort;
}
// TODO read max revisions
}