350 lines
13 KiB
C++
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*>(×tamp), 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
|
|
}
|