From c9fa963e71258c5adfb71cf1996cd1bcb33df0bb Mon Sep 17 00:00:00 2001 From: Roland Reichwein Date: Sun, 26 Feb 2023 08:54:17 +0100 Subject: Start with copy of whiteboard --- Makefile | 88 ++++++ common.mk | 71 +++++ compiledsql.cpp | 46 +++ compiledsql.h | 45 +++ config.cpp | 83 +++++ config.h | 28 ++ connectionregistry.cpp | 110 +++++++ connectionregistry.h | 55 ++++ debian/README.Debian | 51 ++++ debian/changelog | 75 +++++ debian/compat | 1 + debian/control | 16 + debian/copyright | 4 + debian/rules | 13 + debian/source/format | 1 + debian/whiteboard.conf | 34 +++ debian/whiteboard.dirs | 1 + debian/whiteboard.docs | 1 + debian/whiteboard.whiteboard.service | 13 + diff.cpp | 188 ++++++++++++ diff.h | 37 +++ html/banner256.png | Bin 0 -> 5647 bytes html/index.html | 34 +++ html/pdf-icon-100.png | Bin 0 -> 4526 bytes html/pdf-icon-30.png | Bin 0 -> 1356 bytes html/pdf-icon-50.png | Bin 0 -> 2317 bytes html/pdf-icon.svg | 56 ++++ html/stats.html | 35 +++ html/stats.js | 96 ++++++ html/whiteboard.css | 150 +++++++++ html/whiteboard.js | 351 +++++++++++++++++++++ main.cpp | 8 + qrcode.cpp | 37 +++ qrcode.h | 11 + remote-install.sh | 24 ++ storage.cpp | 235 ++++++++++++++ storage.h | 64 ++++ tests/Makefile | 79 +++++ tests/test-compiledsql.cpp | 77 +++++ tests/test-config.cpp | 63 ++++ tests/test-connectionregistry.cpp | 170 +++++++++++ tests/test-diff.cpp | 199 ++++++++++++ tests/test-qrcode.cpp | 140 +++++++++ tests/test-storage.cpp | 283 +++++++++++++++++ tests/test-whiteboard.cpp | 255 ++++++++++++++++ webassembly/Makefile | 32 ++ webserver.conf.example | 8 + whiteboard.conf | 34 +++ whiteboard.cpp | 571 +++++++++++++++++++++++++++++++++++ whiteboard.h | 35 +++ 50 files changed, 4008 insertions(+) create mode 100755 Makefile create mode 100644 common.mk create mode 100644 compiledsql.cpp create mode 100644 compiledsql.h create mode 100644 config.cpp create mode 100644 config.h create mode 100644 connectionregistry.cpp create mode 100644 connectionregistry.h create mode 100644 debian/README.Debian create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100755 debian/rules create mode 100644 debian/source/format create mode 100644 debian/whiteboard.conf create mode 100644 debian/whiteboard.dirs create mode 100644 debian/whiteboard.docs create mode 100644 debian/whiteboard.whiteboard.service create mode 100644 diff.cpp create mode 100644 diff.h create mode 100644 html/banner256.png create mode 100644 html/index.html create mode 100644 html/pdf-icon-100.png create mode 100644 html/pdf-icon-30.png create mode 100644 html/pdf-icon-50.png create mode 100644 html/pdf-icon.svg create mode 100644 html/stats.html create mode 100644 html/stats.js create mode 100644 html/whiteboard.css create mode 100644 html/whiteboard.js create mode 100644 main.cpp create mode 100644 qrcode.cpp create mode 100644 qrcode.h create mode 100755 remote-install.sh create mode 100644 storage.cpp create mode 100644 storage.h create mode 100644 tests/Makefile create mode 100644 tests/test-compiledsql.cpp create mode 100644 tests/test-config.cpp create mode 100644 tests/test-connectionregistry.cpp create mode 100644 tests/test-diff.cpp create mode 100644 tests/test-qrcode.cpp create mode 100644 tests/test-storage.cpp create mode 100644 tests/test-whiteboard.cpp create mode 100644 webassembly/Makefile create mode 100644 webserver.conf.example create mode 100644 whiteboard.conf create mode 100644 whiteboard.cpp create mode 100644 whiteboard.h diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..aafa42a --- /dev/null +++ b/Makefile @@ -0,0 +1,88 @@ +# +# Makefile +# +# Environment: Debian +# + +include common.mk + +PROJECTNAME=whiteboard + +DISTROS=base debian11 ubuntu2210 +TGZNAME=$(PROJECTNAME)-$(VERSION).tar.xz + +INCLUDES=-I. +HEADERS=config.h qrcode.h storage.h whiteboard.h compiledsql.h connectionregistry.h diff.h +SOURCES=$(HEADERS:.h=.cpp) +OBJECTS=$(HEADERS:.h=.o) +TARGETS=whiteboard + +build: $(TARGETS) + $(MAKE) -C webassembly + +all: build + ./whiteboard -c whiteboard.conf + +install: + mkdir -p $(DESTDIR)/usr/bin + cp whiteboard $(DESTDIR)/usr/bin + + mkdir -p $(DESTDIR)/usr/lib/whiteboard/html + cp -r html/* $(DESTDIR)/usr/lib/whiteboard/html/ + + uglifyjs html/whiteboard.js -m -c > $(DESTDIR)/usr/lib/whiteboard/html/whiteboard.js || cp html/whiteboard.js $(DESTDIR)/usr/lib/whiteboard/html/whiteboard.js + htmlmin html/index.html $(DESTDIR)/usr/lib/whiteboard/html/index.html + cleancss -o $(DESTDIR)/usr/lib/whiteboard/html/whiteboard.css html/whiteboard.css + + mkdir -p $(DESTDIR)/etc + cp whiteboard.conf $(DESTDIR)/etc + +# link +whiteboard: $(OBJECTS) main.o + $(CXX) $(LDFLAGS) $^ $(LDLIBS) $(LIBS) -o $@ + +# .cpp -> .o +%.o: %.cpp + $(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@ + +test: + $(MAKE) -C tests + +clean: + -rm -f *.o $(TARGETS) *.gcov + -rm -f html/libwhiteboard.wasm html/libwhiteboard.js + $(MAKE) -C tests clean + $(MAKE) -C webassembly clean + +deb: + dpkg-buildpackage + +deb-src: clean + dh_clean + dh_auto_clean + dpkg-source -b -I.git -Iresult . + +$(DISTROS): deb-src + sudo pbuilder build --basetgz /var/cache/pbuilder/$@.tgz --buildresult result/$@ ../$(PROJECTNAME)_$(VERSION).dsc + debsign result/$@/$(PROJECTNAME)_$(VERSION)_amd64.changes + +DISTFILES=$(shell git ls-files 2>/dev/null) + +dist: clean + rm -rf $(PROJECTNAME)-$(VERSION) + mkdir $(PROJECTNAME)-$(VERSION) + cp --parents -r $(DISTFILES) $(PROJECTNAME)-$(VERSION) + tar cfJ ../$(PROJECTNAME)-$(VERSION).tar.xz $(PROJECTNAME)-$(VERSION) + rm -rf $(PROJECTNAME)-$(VERSION) + ls -l ../$(PROJECTNAME)-$(VERSION).tar.xz + +upload: dist + scp ../$(TGZNAME) antcom.de:/var/www/reichwein.it-download/ + scp -r result antcom.de: + scp -r remote-install.sh antcom.de: + ssh antcom.de ./remote-install.sh $(PROJECTNAME) $(VERSION) + ssh antcom.de rm -rf remote-install.sh result + +debs: $(DISTROS) + +.PHONY: clean diff --git a/common.mk b/common.mk new file mode 100644 index 0000000..5f4c77c --- /dev/null +++ b/common.mk @@ -0,0 +1,71 @@ +CXX=clang++-14 +#CXX=g++-12 + +ifeq ($(shell which $(CXX)),) +CXX=clang++-13 +endif + +ifeq ($(shell which $(CXX)),) +CXX=clang++-11 +endif + +ifeq ($(shell which $(CXX)),) +CXX=clang++-10 +endif + +ifeq ($(shell which $(CXX)),) +CXX=clang++ +endif + +ifeq ($(shell which $(CXX)),) +CXX=g++-9 +endif + +ifeq ($(shell which $(CXX)),) +CXX=g++ +endif + +ifeq ($(CXXFLAGS),) +CXXFLAGS=-g -O2 +endif + +CXXFLAGS+=-Wall -fPIE -Wpedantic -gdwarf-4 +LDFLAGS+=-pie + +ifeq ($(CXX),g++-9) +CXXFLAGS+=-std=c++17 +else +CXXFLAGS+=-std=c++20 +endif + +ifeq ($(CXX),clang++-10) +LIBS+=-fuse-ld=lld-10 -lstdc++ +CXXTYPE=clang++ +else ifeq ($(CXX),clang++-11) +#LIBS+=-fuse-ld=lld-11 -lc++ -lc++abi +LLVMPROFDATA=llvm-profdata-11 +LLVMCOV=llvm-cov-11 +CXXTYPE=clang++ +else ifeq ($(CXX),clang++-14) +#LIBS+=-fuse-ld=lld-14 -lc++ -lc++abi +LLVMPROFDATA=llvm-profdata-14 +LLVMCOV=llvm-cov-14 +CXXTYPE=clang++ +else +LIBS+=-lstdc++ -lstdc++fs +CXXTYPE=g++ +endif + +CXXFLAGS+=$(shell pkg-config --cflags qrcodegencpp GraphicsMagick++ fmt sqlite3) +LIBS+=-lboost_filesystem -lpthread +LIBS+=-lSQLiteCpp $(shell pkg-config --libs qrcodegencpp GraphicsMagick++ fmt sqlite3) +LIBS+=-lreichwein + +SRC_ROOT=$(shell echo $(MAKEFILE_LIST) | tr " " "\n" | grep common.mk | sed -e 's/\([^ ]*\)common.mk/\1/g') +ifeq ($(SRC_ROOT),) +SRC_ROOT=. +endif + +VERSION=$(shell dpkg-parsechangelog --show-field Version --file $(SRC_ROOT)/debian/changelog) +CXXFLAGS+=-DWHITEBOARD_VERSION=\"$(VERSION)\" + diff --git a/compiledsql.cpp b/compiledsql.cpp new file mode 100644 index 0000000..b8d6d70 --- /dev/null +++ b/compiledsql.cpp @@ -0,0 +1,46 @@ +#include "compiledsql.h" + +#include + +#include + +CompiledSQL::CompiledSQL(SQLite::Database& db, const std::string& stmt): + m_db{db}, + m_query{stmt}, + m_stmt{}, + m_isSelect{} +{ + if ( +#if __cplusplus >= 202002 + stmt.starts_with("SELECT ") +#else + boost::algorithm::starts_with(stmt, "SELECT ") +#endif + ) { + m_isSelect = true; + } else { + m_isSelect = false; + } +} + +bool CompiledSQL::execute() +{ + if (m_isSelect) { + return m_stmt->executeStep(); + } else { + return m_stmt->exec(); + } +} + +CompiledSQL::Guard::Guard(CompiledSQL& cs): m_cs{cs} +{ + if (!m_cs.m_stmt) { + m_cs.m_stmt = std::make_shared(m_cs.m_db, m_cs.m_query); + } +} + +CompiledSQL::Guard::~Guard() +{ + m_cs.m_stmt->reset(); +} + diff --git a/compiledsql.h b/compiledsql.h new file mode 100644 index 0000000..bb1062c --- /dev/null +++ b/compiledsql.h @@ -0,0 +1,45 @@ +// Helper Class for SQLite backed storage + +#pragma once + +#include + +#include + +class CompiledSQL +{ +public: + CompiledSQL(SQLite::Database& db, const std::string& stmt); + + // index 1-based as in SQLite + template + void bind(int index, T value) + { + m_stmt->bind(index, value); + } + + bool execute(); + + // index 0-based as in SQLite + template + T getColumn(const int index) + { + return m_stmt->getColumn(index); + } + + class Guard + { + public: + Guard(CompiledSQL& cs); + ~Guard(); + private: + CompiledSQL& m_cs; + }; + +private: + SQLite::Database& m_db; + std::string m_query; + std::shared_ptr m_stmt; + bool m_isSelect; // In SQLite, SELECT statements will be handled w/ executeStep(), others w/ exec() +}; + diff --git a/config.cpp b/config.cpp new file mode 100644 index 0000000..afb5cd2 --- /dev/null +++ b/config.cpp @@ -0,0 +1,83 @@ +#include "config.h" + +#include +#include + +#include +#include + +namespace pt = boost::property_tree; +using namespace std::string_literals; + +namespace { + const std::string default_datapath {"/var/lib/whiteboard"}; + const uint64_t default_maxage{0}; // timeout in seconds; 0 = no timeout + const std::string default_listen {"::1:9000"}; + const int default_threads{1}; + const int default_max_connections{1000}; +} + +Config::Config(const std::string& config_filename): + m_dataPath{default_datapath}, + m_maxage{default_maxage}, + m_listenAddress{"::1"}, + m_listenPort{9000}, + m_threads{default_threads}, + m_max_connections{default_max_connections} +{ + + try { + pt::ptree tree; + + pt::read_xml(config_filename, tree, pt::xml_parser::no_comments | pt::xml_parser::trim_whitespace); + + m_dataPath = tree.get("config.datapath", default_datapath); + m_maxage = tree.get("config.maxage", default_maxage); + std::string listen {tree.get("config.port", default_listen)}; + auto pos{listen.find_last_of(':')}; + if (pos == std::string::npos) + throw std::runtime_error("Bad port address: "s + listen); + + m_listenAddress = listen.substr(0, pos); + m_listenPort = std::stoi(listen.substr(pos + 1)); + if (m_listenPort < 0 || m_listenPort > 65535) + throw std::runtime_error("Bad listen port: "s + std::to_string(m_listenPort)); + + m_threads = tree.get("config.threads", default_threads); + + m_max_connections = tree.get("config.maxconnections", default_max_connections); + } catch (const std::exception& ex) { + std::cerr << "Error reading config file " << config_filename << ". Using defaults." << std::endl; + } +} + +std::string Config::getDataPath() const +{ + return m_dataPath; +} + +uint64_t Config::getMaxage() const +{ + return m_maxage; +} + +std::string Config::getListenAddress() const +{ + return m_listenAddress; +} + +int Config::getListenPort() const +{ + return m_listenPort; +} + +int Config::getThreads() const +{ + return m_threads; +} + +int Config::getMaxConnections() const +{ + return m_max_connections; +} + diff --git a/config.h b/config.h new file mode 100644 index 0000000..ce2fceb --- /dev/null +++ b/config.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +const std::string default_config_filename{"/etc/whiteboard.conf"}; + +class Config +{ +private: + std::string m_dataPath; + uint64_t m_maxage; + std::string m_listenAddress; // ip address v4/v6 + int m_listenPort; + int m_threads; + int m_max_connections; + +public: + Config(const std::string& config_filename = default_config_filename); + std::string getDataPath() const; + uint64_t getMaxage() const; + + std::string getListenAddress() const; + int getListenPort() const; + + int getThreads() const; + + int getMaxConnections() const; +}; diff --git a/connectionregistry.cpp b/connectionregistry.cpp new file mode 100644 index 0000000..e3c0c11 --- /dev/null +++ b/connectionregistry.cpp @@ -0,0 +1,110 @@ +#include "connectionregistry.h" + +#include +#include + +void ConnectionRegistry::setId(ConnectionRegistry::connection c, const std::string& id) +{ + if (!m_connections.at(c).empty()) { + auto& connections_set{m_ids.at(id)}; + connections_set.erase(c); + if (connections_set.empty()) + m_ids.erase(id); + } + + m_connections.at(c) = id; + + if (!id.empty()) { + if (!m_ids.contains(id)) + m_ids[id] = {}; + m_ids.at(id).insert(c); + } + +} + +void ConnectionRegistry::addConnection(ConnectionRegistry::connection c) +{ + if (m_connections.contains(c)) + throw std::runtime_error("ConnectionRegistry::addConnection: connection already exists"); + + m_connections.emplace(c, ""); + + std::cout << "Info: Added connection, now " << m_connections.size() << std::endl; +} + +void ConnectionRegistry::delConnection(ConnectionRegistry::connection c) +{ + if (!m_connections.contains(c)) + throw std::runtime_error("ConnectionRegistry::delConnection: connection doesn't exist"); + + std::string id {m_connections.at(c)}; + + m_connections.erase(c); + + if (!id.empty()) { + auto& connections_set{m_ids.at(id)}; + connections_set.erase(c); + if (connections_set.empty()) + m_ids.erase(id); + } + + std::cout << "Info: Deleted connection, now " << m_connections.size() << std::endl; +} + +std::unordered_set::iterator ConnectionRegistry::begin(const std::string& id) +{ + return m_ids.at(id).begin(); +} + +std::unordered_set::iterator ConnectionRegistry::end(const std::string& id) +{ + return m_ids.at(id).end(); +} + +std::unordered_map::iterator ConnectionRegistry::begin() +{ + return m_connections.begin(); +} + +std::unordered_map::iterator ConnectionRegistry::end() +{ + return m_connections.end(); +} + +void ConnectionRegistry::dump() const +{ + std::cout << "Connection Registry:" << std::endl; + + std::cout << "Connections: " << std::endl; + for (auto& i: m_connections) { + std::cout << " " << i.first << ": " << i.second << std::endl; + } + + std::cout << "IDs: " << std::endl; + for (auto& i: m_ids) { + std::cout << " " << i.first << ":"; + + for (auto& j: i.second) { + std::cout << " " << j; + } + std::cout << std::endl; + } +} + +size_t ConnectionRegistry::number_of_connections() const +{ + return m_connections.size(); +} + +ConnectionRegistry::RegistryGuard::RegistryGuard(ConnectionRegistry& registry, ConnectionRegistry::connection c): + m_registry{registry}, + m_connection{c} +{ + m_registry.addConnection(m_connection); +} + +ConnectionRegistry::RegistryGuard::~RegistryGuard() +{ + m_registry.delConnection(m_connection); +} + diff --git a/connectionregistry.h b/connectionregistry.h new file mode 100644 index 0000000..c3c6884 --- /dev/null +++ b/connectionregistry.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +class ConnectionRegistry +{ +public: + using connection = std::shared_ptr>; + + ConnectionRegistry() = default; + ~ConnectionRegistry() = default; + + void setId(connection c, const std::string& id); + + // used via RegistryGuard + void addConnection(connection c); + void delConnection(connection c); + + // iterate over all connections associated with a certain id + std::unordered_set::iterator begin(const std::string& id); + std::unordered_set::iterator end(const std::string& id); + + // iterate over all connections + std::unordered_map::iterator begin(); + std::unordered_map::iterator end(); + + void dump() const; + + size_t number_of_connections() const; + +private: + // map connection to id + std::unordered_map m_connections; + // map id to list of related connections, used for iteration over connections to notify about changes + std::unordered_map> m_ids; + +public: + class RegistryGuard + { + public: + RegistryGuard(ConnectionRegistry& registry, connection c); + ~RegistryGuard(); + private: + ConnectionRegistry& m_registry; + connection m_connection; + }; + +}; + diff --git a/debian/README.Debian b/debian/README.Debian new file mode 100644 index 0000000..7450383 --- /dev/null +++ b/debian/README.Debian @@ -0,0 +1,51 @@ +whiteboard for Debian +===================== + +This package is the Debian version of whiteboard. + +It is a websocket application communicating to a webserver, e.g. Reichwein.IT webserver. + +Via cron or systemd, whiteboard data in /var/lib/whiteboard is cleaned up once a day. +Data location and maximum data age can be configured via /etc/whiteboard.conf. + +Configuration +------------- + +* You can add this to /etc/webserver.conf + + + static-files + /usr/lib/whiteboard/html + + + websocket + 127.0.0.1:9014 + + +* Edit /etc/whiteboard.conf to adjust the whiteboard data path if different + from /var/lib/whiteboard + +* Enable: + + # systemctl enable whiteboard.service + +* Start: + + # systemctl start whiteboard + +* Stop: + + # systemctl stop whiteboard + +* Query Status: + + # systemctl status whiteboard + + and observe /var/log/syslog + + +Contact +------- + +Reichwein IT + diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..de01abe --- /dev/null +++ b/debian/changelog @@ -0,0 +1,75 @@ +whiteboard (1.9) UNRELEASED; urgency=medium + + * Updated build environment + * Added missing image files + + -- Roland Reichwein Mon, 20 Feb 2023 10:10:27 +0100 + +whiteboard (1.8) unstable; urgency=medium + + * Added config.maxconnections, defaults to 1000 + * Fixed package dependencies for PDF generation + * Touch documents on read (i.e. reset timeout on read) + * Validate id, server-side + + -- Roland Reichwein Sun, 19 Feb 2023 18:42:32 +0100 + +whiteboard (1.7) unstable; urgency=medium + + * Fix crash on stats.html page reload/close + * Focus on reconnect button + * Added Markdown to PDF download via pandoc + + -- Roland Reichwein Fri, 10 Feb 2023 18:01:37 +0100 + +whiteboard (1.6) unstable; urgency=medium + + * Added stats.html + * Use boost strands instead of mutex + + -- Roland Reichwein Mon, 06 Feb 2023 07:58:39 +0100 + +whiteboard (1.5) unstable; urgency=medium + + * Move from FCGI to websocket interface + * Position changes w/o file transmit + * Print version on main page + * Add reconnect button + * Add diff handling + + -- Roland Reichwein Mon, 30 Jan 2023 21:05:35 +0100 + +whiteboard (1.4) unstable; urgency=medium + + * Move from filesystem based documents to sqlite + * Add tests + * Separated out libreichwein + + -- Roland Reichwein Sat, 21 Jan 2023 12:20:20 +0100 + +whiteboard (1.3) unstable; urgency=medium + + * Page design (center) + * Follow editor's cursor + + -- Roland Reichwein Mon, 05 Dec 2022 19:22:27 +0100 + +whiteboard (1.2) unstable; urgency=medium + + * Fix build on Debian 11 + * UglifyJS on best-effort base + + -- Roland Reichwein Sat, 03 Dec 2022 17:03:11 +0100 + +whiteboard (1.1) unstable; urgency=medium + + * Compress Javascript + * Add QR Code + + -- Roland Reichwein Sat, 03 Dec 2022 16:10:06 +0100 + +whiteboard (1.0) unstable; urgency=medium + + * Initial release + + -- Roland Reichwein Sat, 05 Nov 2022 13:34:57 +0100 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..48082f7 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +12 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..9c75069 --- /dev/null +++ b/debian/control @@ -0,0 +1,16 @@ +Source: whiteboard +Section: web +Priority: optional +Maintainer: Roland Reichwein +Build-Depends: debhelper (>= 12), libboost-all-dev | libboost1.71-all-dev, clang | g++-9, llvm | g++-9, lld | g++-9, uglifyjs, python3-pkg-resources, htmlmin, cleancss, libqrcodegencpp-dev, libgraphicsmagick++-dev, pkg-config, libfmt-dev, libsqlitecpp-dev, googletest, gcovr, libreichwein-dev, emscripten +Standards-Version: 4.5.0 +Homepage: http://www.reichwein.it/whiteboard/ + +Package: whiteboard +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, libxml2-utils, pandoc, texlive-latex-recommended, texlive-latex-extra +Recommends: webserver +Homepage: http://www.reichwein.it/whiteboard/ +Description: Web application for an collaborative editor + Whiteboard is a text editor running on an HTML5 webpage (including a server + part) that enables collaborative editing and presenting. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..5007f59 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,4 @@ +Author: Roland Reichwein , 2022 + +Both upstream source code and Debian packaging is available +under the conditions of CC0 1.0 Universal diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..72c5b8c --- /dev/null +++ b/debian/rules @@ -0,0 +1,13 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_fixperms: + dh_fixperms + chmod a+rwx debian/whiteboard/var/lib/whiteboard + + +override_dh_auto_install: + dh_auto_install + dh_installsystemd --name whiteboard diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/debian/whiteboard.conf b/debian/whiteboard.conf new file mode 100644 index 0000000..6446d39 --- /dev/null +++ b/debian/whiteboard.conf @@ -0,0 +1,34 @@ + + + /var/lib/whiteboard + + + ::1:8765 + + + 2592000 + + + 4 + + + 500 + diff --git a/debian/whiteboard.dirs b/debian/whiteboard.dirs new file mode 100644 index 0000000..7b03c85 --- /dev/null +++ b/debian/whiteboard.dirs @@ -0,0 +1 @@ +var/lib/whiteboard diff --git a/debian/whiteboard.docs b/debian/whiteboard.docs new file mode 100644 index 0000000..0812baa --- /dev/null +++ b/debian/whiteboard.docs @@ -0,0 +1 @@ +webserver.conf.example diff --git a/debian/whiteboard.whiteboard.service b/debian/whiteboard.whiteboard.service new file mode 100644 index 0000000..b41e655 --- /dev/null +++ b/debian/whiteboard.whiteboard.service @@ -0,0 +1,13 @@ +[Unit] +Description=Whiteboard +After=network.target + +[Service] +Type=simple +# Restart=always +ExecStart=/usr/bin/whiteboard + +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/diff.cpp b/diff.cpp new file mode 100644 index 0000000..6ac24e7 --- /dev/null +++ b/diff.cpp @@ -0,0 +1,188 @@ +#include "diff.h" + +#include +#include +#include + +#include + +namespace pt = boost::property_tree; + +Diff::Diff() +{ +} + +Diff::Diff(const std::string& old_version, const std::string& new_version) +{ + create(old_version, new_version); +} + +std::string Diff::apply(const std::string& old_version) const +{ + std::string result{old_version}; + + if (m_pos0 <= m_pos1 && m_pos1 <= old_version.size()) { + result.erase(m_pos0, m_pos1 - m_pos0); + result.insert(m_pos0, m_data); + } + + return result; +} + +void Diff::create(const std::string& old_version, const std::string& new_version) +{ + auto front_mismatch{std::mismatch(old_version.cbegin(), old_version.cend(), new_version.cbegin(), new_version.cend())}; + std::string::difference_type old_pos0 {front_mismatch.first - old_version.cbegin()}; + auto& new_pos0 {old_pos0}; + + // equal + if (old_pos0 == old_version.size() && new_pos0 == new_version.size()) { + m_pos0 = 0; + m_pos1 = 0; + m_data.clear(); + return; + } + + // append at end + if (old_pos0 == old_version.size()) { + m_pos0 = old_pos0; + m_pos1 = old_pos0; + m_data = new_version.substr(new_pos0); + return; + } + + // remove from end + if (new_pos0 == new_version.size()) { + m_pos0 = old_pos0; + m_pos1 = old_version.size(); + m_data.clear(); + return; + } + + auto back_mismatch{std::mismatch(old_version.crbegin(), old_version.crend(), new_version.crbegin(), new_version.crend())}; + // i.e. the indices starting from which we can assume equality + size_t old_pos1 {old_version.size() - (back_mismatch.first - old_version.crbegin())}; + size_t new_pos1 {new_version.size() - (back_mismatch.second - new_version.crbegin())}; + + // complete equality is already handled above + + // insert at start + if (old_pos1 == 0) { + m_pos0 = 0; + m_pos1 = 0; + m_data = new_version.substr(0, new_pos1); + return; + } + + // remove from start + if (new_pos1 == 0) { + m_pos0 = 0; + m_pos1 = old_pos1; + m_data.clear(); + return; + } + + // re-adjust if search crossed + if (old_pos0 > old_pos1) { + old_pos0 = old_pos1; + new_pos0 = old_pos1; + } + if (new_pos0 > new_pos1) { + old_pos0 = new_pos1; + new_pos0 = new_pos1; + } + + // insert in the middle + if (old_pos0 == old_pos1) { + m_pos0 = old_pos0; + m_pos1 = old_pos0; + m_data = new_version.substr(new_pos0, new_pos1 - new_pos0); + return; + } + + // remove from the middle + if (new_pos0 == new_pos1) { + m_pos0 = old_pos0; + m_pos1 = old_pos1; + m_data.clear(); + return; + } + + // last resort: remove and add in the middle + m_pos0 = old_pos0; + m_pos1 = old_pos1; + m_data = new_version.substr(old_pos0, new_pos1 - new_pos0); +} + +Diff::Diff(const boost::property_tree::ptree& ptree) +{ + create(ptree); +} + +void Diff::create(const boost::property_tree::ptree& ptree) +{ + m_pos0 = ptree.get("diff.start"); + m_pos1 = ptree.get("diff.end"); + + if (m_pos0 > m_pos1) + throw std::runtime_error("Bad range in diff"); + + m_data = ptree.get("diff.data"); +} + +Diff::Diff(const std::string& xml) +{ + create(xml); +} + +void Diff::create(const std::string& xml) +{ + pt::ptree ptree; + std::istringstream ss{xml}; + pt::read_xml(ss, ptree); + + create(ptree); +} + +bool Diff::empty() const +{ + return m_pos0 == m_pos1 && m_data.empty(); +} + +boost::property_tree::ptree Diff::get_structure() const +{ + pt::ptree ptree; + ptree.put("diff.start", std::to_string(m_pos0)); + ptree.put("diff.end", std::to_string(m_pos1)); + ptree.put("diff.data", m_data); + + return ptree; +} + +std::string Diff::get_xml() const +{ + pt::ptree ptree{get_structure()}; + + std::ostringstream oss; + // write_xml_element instead of write_xml to omit header + //pt::xml_parser::write_xml(oss, xml); + pt::xml_parser::write_xml_element(oss, {}, ptree, -1, boost::property_tree::xml_writer_settings{}); + return oss.str(); +} + +extern "C" { + + const char* diff_create(const char* old_version, const char* new_version) + { + Diff diff{old_version, new_version}; + return strdup(diff.get_xml().c_str()); + } + + const char* diff_apply(const char* old_version, const char* diff) + { + Diff d{diff}; + + return strdup(d.apply(old_version).c_str()); + } + +} diff --git a/diff.h b/diff.h new file mode 100644 index 0000000..5c2c335 --- /dev/null +++ b/diff.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +#include + +class Diff +{ +public: + Diff(); + Diff(const std::string& old_version, const std::string& new_version); + void create(const std::string& old_version, const std::string& new_version); + + Diff(const boost::property_tree::ptree& ptree); + void create(const boost::property_tree::ptree& ptree); + + Diff(const std::string& xml); + void create(const std::string& xml); + + std::string apply(const std::string& old_version) const; + + bool empty() const; + + boost::property_tree::ptree get_structure() const; + std::string get_xml() const; + +private: + // diff replaces space from m_pos0 (inclusive) to m_pos1 (exclusive) with m_data + size_t m_pos0{}; + size_t m_pos1{}; + std::string m_data; +}; + +extern "C" { + const char* diff_create(const char* old_version, const char* new_version); + const char* diff_apply(const char* old_version, const char* diff); +} diff --git a/html/banner256.png b/html/banner256.png new file mode 100644 index 0000000..89efb4f Binary files /dev/null and b/html/banner256.png differ diff --git a/html/index.html b/html/index.html new file mode 100644 index 0000000..b72e23b --- /dev/null +++ b/html/index.html @@ -0,0 +1,34 @@ + + + + + + + Reichwein Whiteboard + + + + + + + +
+

Whiteboard

+ +
+
+ + + Starting up... + + +
+
+ Reichwein.IT Whiteboard by https://www.reichwein.it
+
+ + + + diff --git a/html/pdf-icon-100.png b/html/pdf-icon-100.png new file mode 100644 index 0000000..9a20387 Binary files /dev/null and b/html/pdf-icon-100.png differ diff --git a/html/pdf-icon-30.png b/html/pdf-icon-30.png new file mode 100644 index 0000000..5f60d2f Binary files /dev/null and b/html/pdf-icon-30.png differ diff --git a/html/pdf-icon-50.png b/html/pdf-icon-50.png new file mode 100644 index 0000000..62873ea Binary files /dev/null and b/html/pdf-icon-50.png differ diff --git a/html/pdf-icon.svg b/html/pdf-icon.svg new file mode 100644 index 0000000..8b30bbb --- /dev/null +++ b/html/pdf-icon.svg @@ -0,0 +1,56 @@ + + + + PDF file icon + + + + + + + + + + image/svg+xml + + PDF file icon + 08/10/2018 + + + Adobe Systems + + + + + CMetalCore + + + Fuente del texto "PDF": +Franklin Gothic Medium Cond + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/html/stats.html b/html/stats.html new file mode 100644 index 0000000..3f7e141 --- /dev/null +++ b/html/stats.html @@ -0,0 +1,35 @@ + + + + + + + Reichwein Whiteboard + + + + + + +
+

Whiteboard

+

Statistics

+ + + + + +
Active Connections:
Number of Documents:
Database Size (gross):Bytes
Database Size (net):Bytes
+
+ Starting up... + +
+
+ Reichwein.IT Whiteboard by https://www.reichwein.it
+
+ + + + diff --git a/html/stats.js b/html/stats.js new file mode 100644 index 0000000..89c674a --- /dev/null +++ b/html/stats.js @@ -0,0 +1,96 @@ +// started on main page load +function init() { + init_stats(); +} + +function set_status(message) +{ + if (message == "") { + document.getElementById("status").textContent = message; + document.getElementById("status").style.display = 'inline'; + } else { + document.getElementById("status").textContent = ""; + document.getElementById("status").style.display = 'none'; + } +} + +var websocket; + +// +// Callbacks for websocket data of different types +// + +function on_stats(numberofdocuments, numberofconnections, dbsizegross, dbsizenet) +{ + document.getElementById("numberofdocuments").textContent = numberofdocuments; + document.getElementById("numberofconnections").textContent = numberofconnections; + document.getElementById("dbsizegross").textContent = dbsizegross; + document.getElementById("dbsizenet").textContent = dbsizenet; +} + +function on_version(version) +{ + document.getElementById("version").textContent = version; +} + +function on_message(e) { + var parser = new DOMParser(); + var xmlDocument = parser.parseFromString(e.data, "text/xml"); + + var type = xmlDocument.getElementsByTagName("type")[0].textContent; + + if (type == "stats") { + on_stats(xmlDocument.getElementsByTagName("numberofdocuments")[0].textContent, + xmlDocument.getElementsByTagName("numberofconnections")[0].textContent, + xmlDocument.getElementsByTagName("dbsizegross")[0].textContent, + xmlDocument.getElementsByTagName("dbsizenet")[0].textContent); + } else if (type == "version") { + on_version(xmlDocument.getElementsByTagName("version")[0].textContent); + } else if (type == "error") { + alert(xmlDocument.getElementsByTagName("message")[0].textContent); + } else { + alert("Unhandled message type: " + e.data + "|" + type); + } +} + +function connect_websocket() { + document.getElementById("reconnect").style.display = 'none'; + set_status("Connecting..."); + var newlocation = location.origin + location.pathname; + newlocation = newlocation.replace(/^http/, 'ws'); + newlocation = newlocation.replace(/stats.html$/, ''); + if (newlocation.slice(-1) != "/") + newlocation += "/"; + newlocation += "websocket"; + + websocket = new WebSocket(newlocation); + + websocket.onmessage = function(e) { on_message(e); }; + + websocket.onopen = function(e) { + websocket.send("getversion"); + websocket.send("getstats"); + set_status(""); // ok + }; + + websocket.onclose = function(e) { + alert("Server connection closed."); + document.getElementById("reconnect").style.display = 'inline'; + }; + + websocket.onerror = function(e) { + alert("Error: Server connection closed."); + document.getElementById("reconnect").style.display = 'inline'; + }; +} + +// button in html +function on_reconnect_click() { + connect_websocket(); +} + +function init_stats() { + set_status("Loading..."); + connect_websocket(); +} + diff --git a/html/whiteboard.css b/html/whiteboard.css new file mode 100644 index 0000000..2d222d5 --- /dev/null +++ b/html/whiteboard.css @@ -0,0 +1,150 @@ +body { + font-family: sans-serif; +} + +figcaption { + text-align: center; + font-size: 8px; + color: #808080; +} + +figure { + display: inline-block; +} + +p { + margin: 30px 0px 30px 0px; +} + +div.status { + color: #FF0000; +} + +span.helper { + display: inline-block; + height: 100%; + vertical-align: middle; +} + +img.center-img { + vertical-align: middle; +} + +.clickable { + cursor: pointer; +} + +textarea { + /* + height: 30vh; + padding: 1em; + font-size: 1.5em; + text-align: left; + border: 1px solid #000; + */ + box-sizing: border-box; + resize: none; + + width: 100%; + height: 540px; +} + +.qrwindow { + position: fixed; + top: 50%; + left: 50%; + width: 400px; + height: 400px; + margin-top: -200px; + margin-left: -200px; + background-color: #FFFFFF; + opacity: 1; + z-index: 10; + border-width: 3px; + border-style: solid; + border-color: #FFFFFF; + padding: 10pt; + box-sizing: border-box; +} + +.qrcode { + width: 374px; + height: 374px; + + image-rendering: optimizeSpeed; /* */ + image-rendering: -moz-crisp-edges; /* Firefox */ + image-rendering: -o-crisp-edges; /* Opera */ + image-rendering: -webkit-optimize-contrast; /* Chrome (and Safari) */ + image-rendering: pixelated; /* Chrome as of 2019 */ + image-rendering: optimize-contrast; /* CSS3 Proposed */ + -ms-interpolation-mode: nearest-neighbor; /* IE8+ */ +} + +.mobile { + width: 300px; + border-width: 80px 15px 80px 15px; + border-style: solid; + border-radius: 30px; + border-color: #000000; +} + +.logo { + display: block; + margin: 0 auto; +} + +.screenshot { + width: 400px; + border: 2px solid; + border-color: #8888AA; +} + +img.banner { + vertical-align: -5px; +} + +.button { + color:#FFFFFF; + background-color:#50B050; + text-decoration: none; + padding: 15px 20px; + font-size: 16px; + border: none; + border-radius: 6px; + cursor: pointer; +} + +.buttonred { + color:#FFFFFF; + background-color:#B05050; + text-decoration: none; + padding: 15px 20px; + font-size: 16px; + border: none; + border-radius: 6px; + cursor: pointer; +} + +@media only screen and (min-width: 1px) and (max-width: 630px) { + +.qrwindow { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + margin: 0; +} + +} + +@media only screen and (min-width: 631px) and (max-width: 950px) { +} + +@media only screen and (min-width: 951px) { + div.page { + max-width: 950px; + width: 100%; + margin: 0 auto; + } +} diff --git a/html/whiteboard.js b/html/whiteboard.js new file mode 100644 index 0000000..379c757 --- /dev/null +++ b/html/whiteboard.js @@ -0,0 +1,351 @@ +// started on main page load +function init() { + init_board(); +} + +var revision; +var modify_in_progress = 0; +var baseline = ""; // data contents relating to revision, acknowledged by server +var baseline_candidate = ""; // will become baseline, after ack by server + +// helper for breaking feedback loop +var caretpos = 0; + +function set_status(message) +{ + if (message == "") { + document.getElementById("status").textContent = message; + document.getElementById("status").style.display = 'inline'; + } else { + document.getElementById("status").textContent = ""; + document.getElementById("status").style.display = 'none'; + } +} + +function showQRWindow() +{ + document.getElementById("qrwindow").style.display = 'block'; +} + +function hideQRWindow() +{ + document.getElementById("qrwindow").style.display = 'none'; +} + +var websocket; + +// +// Callbacks for websocket data of different types +// + +function on_getfile(data, rev, pos) +{ + var board = document.getElementById("board"); + if (board.value != data) { + board.value = data; + } + revision = rev; + baseline = data; + textAreaSetPos("board", pos); +} + +function on_getdiff(diff, rev, pos) +{ + if (rev != revision + 1) + console.log("Revision skipped on diff receive: " + rev + " after " + revision); + + var board = document.getElementById("board"); + + var old_version_ptr = allocateUTF8(board.value); + var diff_ptr = allocateUTF8(new XMLSerializer().serializeToString(diff)); + var new_version_ptr = Module._diff_apply(old_version_ptr, diff_ptr); + var data = UTF8ToString(new_version_ptr); + board.value = data; + _free(old_version_ptr); + _free(new_version_ptr); + _free(diff_ptr); + + revision = rev; + baseline = data; + textAreaSetPos("board", pos); +} + +function on_getpos(pos) +{ + textAreaSetPos("board", pos); +} + +function on_newid(id) +{ + var new_location = document.location.origin + document.location.pathname + '?id=' + id; + window.location.href = new_location; +} + +function on_qrcode(png) +{ + var url = "data:image/png;base64," + png; + var img = document.getElementById("qrcode"); + img.src = url; + showQRWindow(); +} + +function on_version(version) +{ + document.getElementById("version").textContent = version; +} + +function on_pdf(pdf) +{ + var a = document.getElementById("download-a"); + a.href = "data:application/pdf;base64," + pdf; + a.download = get_id() + ".pdf" + a.click(); +} + +function on_modify_ack(rev) +{ + if (rev != revision + 1) + console.log("Revision skipped on published local change: " + rev + " after " + revision); + + revision = rev; + baseline = baseline_candidate; + modify_in_progress = 0; +} + +function on_message(e) { + var parser = new DOMParser(); + var xmlDocument = parser.parseFromString(e.data, "text/xml"); + + var type = xmlDocument.getElementsByTagName("type")[0].textContent; + + if (type == "getfile") { + on_getfile(xmlDocument.getElementsByTagName("data")[0].textContent, + parseInt(xmlDocument.getElementsByTagName("revision")[0].textContent), + parseInt(xmlDocument.getElementsByTagName("pos")[0].textContent)); + } else if (type == "getdiff") { + on_getdiff(xmlDocument.getElementsByTagName("diff")[0], + parseInt(xmlDocument.getElementsByTagName("revision")[0].textContent), + parseInt(xmlDocument.getElementsByTagName("pos")[0].textContent)); + } else if (type == "getpos") { + on_getpos(parseInt(xmlDocument.getElementsByTagName("pos")[0].textContent)); + } else if (type == "modify") { + on_modify_ack(parseInt(xmlDocument.getElementsByTagName("revision")[0].textContent)); + } else if (type == "newid") { + on_newid(xmlDocument.getElementsByTagName("id")[0].textContent); + } else if (type == "qrcode") { + on_qrcode(xmlDocument.getElementsByTagName("png")[0].textContent); + } else if (type == "version") { + on_version(xmlDocument.getElementsByTagName("version")[0].textContent); + } else if (type == "pdf") { + on_pdf(xmlDocument.getElementsByTagName("pdf")[0].textContent); + } else if (type == "error") { + alert(xmlDocument.getElementsByTagName("message")[0].textContent); + } else { + alert("Unhandled message type: " + e.data + "|" + type); + } +} + +function handleSelection() { + const activeElement = document.activeElement + + if (activeElement && activeElement.id === 'board') { + if (caretpos != activeElement.selectionStart) { + on_selectionchange(activeElement.selectionStart); + caretpos = activeElement.selectionStart; + } + } +} + +function connect_websocket() { + document.getElementById("reconnect").style.display = 'none'; + set_status("Connecting..."); + var newlocation = location.origin + location.pathname; + newlocation = newlocation.replace(/^http/, 'ws'); + if (newlocation.slice(-1) != "/") + newlocation += "/"; + newlocation += "websocket"; + + websocket = new WebSocket(newlocation); + + websocket.onmessage = function(e) { on_message(e); }; + + websocket.onopen = function(e) { + const searchParams = (new URL(document.location)).searchParams; + if (!searchParams.has('id')) { + redirect_to_new_page(); + return; + } + + websocket.send("getversion"); + websocket.send("getfile" + get_id() + ""); + set_status(""); // ok + document.getElementById("board").focus(); + }; + + websocket.onclose = function(e) { + alert("Server connection closed."); + document.getElementById("reconnect").style.display = 'inline'; + document.getElementById("reconnect").focus(); + }; + + websocket.onerror = function(e) { + alert("Error: Server connection closed."); + document.getElementById("reconnect").style.display = 'inline'; + document.getElementById("reconnect").focus(); + }; +} + +// button in html +function on_reconnect_click() { + connect_websocket(); +} + +function init_board() { + set_status("Loading..."); + Module.onRuntimeInitialized = () => { + connect_websocket(); + }; + + var board = document.getElementById("board"); + board.addEventListener("input", function() {on_input(); }); + // Need this workaround (different from direct on_selectionchange) for Chrome. + // Otherwise, callback will not be called on Chrome. + document.addEventListener("selectionchange", handleSelection); + //board.addEventListener("selectionchange", function() {on_selectionchange(); }); + + document.getElementById("qrwindow").onclick = function() { + hideQRWindow(); + } + + document.onkeydown = function(evt) { + if (evt.key == "Escape") { + hideQRWindow(); + } + } + + document.getElementById("board").focus(); +} + +function get_id() +{ + const searchParams = (new URL(document.location)).searchParams; + return searchParams.get('id'); +} + +// from html +function on_new_page() +{ + redirect_to_new_page(); +} + +function redirect_to_new_page() +{ + websocket.send("newid"); +} + +// local change done +function on_input() +{ + if (modify_in_progress == 1) { + console.log("Deferring on_input handler by 100ms"); + setTimeout(function(){on_input();}, 100); // re-try after 100ms + return; + } + modify_in_progress = 1; + + var parser = new DOMParser(); + var xmlDocument = parser.parseFromString("", "text/xml"); + + var requestElement = xmlDocument.getElementsByTagName("request")[0]; + + var commandElement = xmlDocument.createElement("command"); + commandElement.appendChild(document.createTextNode("modify")); + requestElement.appendChild(commandElement); + + var idElement = xmlDocument.createElement("id"); + idElement.appendChild(document.createTextNode(get_id())); + requestElement.appendChild(idElement); + + baseline_candidate = document.getElementById("board").value; + + if (baseline == baseline_candidate) { + modify_in_progress = 0; + return; + } + + var revisionElement = xmlDocument.createElement("baserev"); + revisionElement.appendChild(document.createTextNode(revision)); + requestElement.appendChild(revisionElement); + + var old_version = allocateUTF8(baseline); + var new_version = allocateUTF8(baseline_candidate); + var diff = Module._diff_create(old_version, new_version); + var diffDocument = parser.parseFromString(UTF8ToString(diff), "text/xml"); + _free(old_version); + _free(new_version); + _free(diff); + requestElement.appendChild(xmlDocument.importNode(diffDocument.getElementsByTagName("diff")[0], true)); + + var posElement = xmlDocument.createElement("pos"); + posElement.appendChild(document.createTextNode(document.getElementById("board").selectionStart)); + requestElement.appendChild(posElement); + + websocket.send(new XMLSerializer().serializeToString(xmlDocument)); +} + +// for cursor position +function on_selectionchange(pos) +{ + var parser = new DOMParser(); + var xmlDocument = parser.parseFromString("", "text/xml"); + + var requestElement = xmlDocument.getElementsByTagName("request")[0]; + + var commandElement = xmlDocument.createElement("command"); + commandElement.appendChild(document.createTextNode("cursorpos")); + requestElement.appendChild(commandElement); + + var idElement = xmlDocument.createElement("id"); + idElement.appendChild(document.createTextNode(get_id())); + requestElement.appendChild(idElement); + + var posElement = xmlDocument.createElement("pos"); + posElement.appendChild(document.createTextNode(pos)); + requestElement.appendChild(posElement); + + websocket.send(new XMLSerializer().serializeToString(xmlDocument)); +} + +function textAreaSetPos(id, pos) +{ + if (document.getElementById(id).selectionStart != pos) { + document.getElementById(id).selectionStart = pos; + document.getElementById(id).selectionEnd = pos; + caretpos = pos; + } +} + +// HTML button +function on_qrcode_click() +{ + var parser = new DOMParser(); + var xmlDocument = parser.parseFromString("", "text/xml"); + + var requestElement = xmlDocument.getElementsByTagName("request")[0]; + + var commandElement = xmlDocument.createElement("command"); + commandElement.appendChild(document.createTextNode("qrcode")); + requestElement.appendChild(commandElement); + + var idElement = xmlDocument.createElement("url"); + idElement.appendChild(document.createTextNode(document.location)); + requestElement.appendChild(idElement); + + websocket.send(new XMLSerializer().serializeToString(xmlDocument)); +} + +function on_pdf_click() +{ + websocket.send("pdf" + get_id() + ""); +} + diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..e1c1cf1 --- /dev/null +++ b/main.cpp @@ -0,0 +1,8 @@ +#include "whiteboard.h" + +int main(int argc, char* argv[]) +{ + Whiteboard whiteboard; + return whiteboard.run(argc, argv); +} + diff --git a/qrcode.cpp b/qrcode.cpp new file mode 100644 index 0000000..da747a5 --- /dev/null +++ b/qrcode.cpp @@ -0,0 +1,37 @@ +#include "qrcode.h" + +#include + +#include + +#include + +using namespace qrcodegen; +using namespace Magick; + +void QRCode::init() +{ + Magick::InitializeMagick(NULL); +} + +std::string QRCode::getQRCode(const std::string& data) +{ + QrCode qrc {QrCode::encodeText(data.c_str(), QrCode::Ecc::MEDIUM)}; + + int size {qrc.getSize()}; + + Image image(fmt::format("{0}x{0}", size).c_str(), "white"); + image.type(GrayscaleType); + + for (int x = 0; x < size; x++) { + for (int y = 0; y < size; y++) { + image.pixelColor(x, y, qrc.getModule(x, y) ? "black" : "white"); + } + } + + image.magick("PNG"); + + Blob blob; + image.write(&blob); + return std::string{(char*)blob.data(), blob.length()}; +} diff --git a/qrcode.h b/qrcode.h new file mode 100644 index 0000000..f37c318 --- /dev/null +++ b/qrcode.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace QRCode +{ + void init(); + + // returns PNG file contents + std::string getQRCode(const std::string& data); +} diff --git a/remote-install.sh b/remote-install.sh new file mode 100755 index 0000000..6683d2e --- /dev/null +++ b/remote-install.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# +# Script to be run on target server to install *.deb and *.tgz +# + +set -e + +if [ "$#" != "2" ] ; then + echo "Usage: remote-install.sh " + exit 0 +fi + +PROJECTNAME=$1 +VERSION=$2 +DISTROS="debian11" + +cd /var/www/reichwein.it-debian + +for i in $DISTROS; do + echo "Installing ${PROJECTNAME} for $i ..." + reprepro -C $i --ignore=wrongdistribution include stable /home/rr/result/$i/${PROJECTNAME}_${VERSION}_amd64.changes + echo "Copying ${PROJECTNAME} for $i to direct download location ..." + #cp /home/rr/result/$i/${PROJECTNAME}_${VERSION}_*.deb /var/www/reichwein.it-download/$i/ +done diff --git a/storage.cpp b/storage.cpp new file mode 100644 index 0000000..92f274f --- /dev/null +++ b/storage.cpp @@ -0,0 +1,235 @@ +#include "storage.h" + +#include "config.h" + +#include +#include +#include + +#include + +using namespace std::string_literals; + +Storage::Storage(const Config& config): + m_db(config.getDataPath() + "/whiteboard.db3", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE), + m_maxage(config.getMaxage()), + + // Note about VARCHAR(N): "SQLite does not impose any length restrictions" - handled elsewhere in application + m_stmt_create(m_db, "CREATE TABLE IF NOT EXISTS documents (id VARCHAR(16) PRIMARY KEY, value BLOB, rev INTEGER, cursorpos INTEGER, timestamp BIGINT)"), + m_stmt_getNumberOfDocuments(m_db, "SELECT COUNT(*) FROM documents"), + m_stmt_cleanup(m_db, "DELETE FROM documents WHERE timestamp + ? < ?"), + m_stmt_vacuum(m_db, "VACUUM"), + m_stmt_exists(m_db, "SELECT id FROM documents WHERE id = ?"), + m_stmt_getDocument(m_db, "SELECT value FROM documents WHERE id = ?"), + m_stmt_getRevision(m_db, "SELECT rev FROM documents WHERE id = ?"), + m_stmt_getCursorPos(m_db, "SELECT cursorpos FROM documents WHERE id = ?"), + m_stmt_getRow(m_db, "SELECT value, rev, cursorpos FROM documents WHERE id = ?"), + m_stmt_setDocument(m_db, "UPDATE documents SET value = ?, timestamp = ?, rev = rev + 1 WHERE id = ?"), + m_stmt_setDocument_new(m_db, "INSERT INTO documents (id, value, rev, cursorpos, timestamp) values (?, ?, ?, ?, ?)"), + m_stmt_setRevision(m_db, "UPDATE documents SET rev = ? WHERE id = ?"), + m_stmt_setCursorPos(m_db, "UPDATE documents SET cursorpos = ? WHERE id = ?"), + m_stmt_setRow(m_db, "INSERT OR REPLACE INTO documents (id, value, rev, cursorpos, timestamp) values (?, ?, ?, ?, ?)"), + m_stmt_getDbSizeGross(m_db, "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"), + m_stmt_getDbSizeNet(m_db, "SELECT (page_count - freelist_count) * page_size as size FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size()"), + m_stmt_touchDocument(m_db, "UPDATE documents SET timestamp = ? WHERE id = ?") +{ + CompiledSQL::Guard g{m_stmt_create}; + m_stmt_create.execute(); +} + +Storage::~Storage() +{ +} + +uint64_t Storage::getNumberOfDocuments() +{ + CompiledSQL::Guard g{m_stmt_getNumberOfDocuments}; + if (!m_stmt_getNumberOfDocuments.execute()) + throw std::runtime_error("Count not possible"); + + return m_stmt_getNumberOfDocuments.getColumn(0); +} + +uint64_t Storage::dbsize_gross() +{ + CompiledSQL::Guard g{m_stmt_getDbSizeGross}; + if (!m_stmt_getDbSizeGross.execute()) + throw std::runtime_error("DB size count (gross) not possible"); + + return m_stmt_getDbSizeGross.getColumn(0); +} + +uint64_t Storage::dbsize_net() +{ + CompiledSQL::Guard g{m_stmt_getDbSizeNet}; + if (!m_stmt_getDbSizeNet.execute()) + throw std::runtime_error("DB size count (net) not possible"); + + return m_stmt_getDbSizeNet.getColumn(0); +} + +namespace { + uint64_t unixepoch() + { + const auto p1 = std::chrono::system_clock::now(); + return std::chrono::duration_cast(p1.time_since_epoch()).count(); + } +} // namespace + +void Storage::cleanup() +{ + if (m_maxage != 0) { + CompiledSQL::Guard g{m_stmt_cleanup}; + m_stmt_cleanup.bind(1, static_cast(m_maxage)); + m_stmt_cleanup.bind(2, static_cast(unixepoch())); + m_stmt_cleanup.execute(); + } + + CompiledSQL::Guard g{m_stmt_vacuum}; + m_stmt_vacuum.execute(); +} + +bool Storage::exists(const std::string& id) +{ + CompiledSQL::Guard g{m_stmt_exists}; + m_stmt_exists.bind(1, id); + + return m_stmt_exists.execute(); +} + +std::string Storage::getDocument(const std::string& id) +{ + CompiledSQL::Guard g{m_stmt_getDocument}; + m_stmt_getDocument.bind(1, id); + + if (!m_stmt_getDocument.execute()) + throw std::runtime_error("id "s + id + " not found"s); + + return m_stmt_getDocument.getColumn(0); +} + +int Storage::getRevision(const std::string& id) +{ + CompiledSQL::Guard g{m_stmt_getRevision}; + m_stmt_getRevision.bind(1, id); + + if (!m_stmt_getRevision.execute()) + throw std::runtime_error("id "s + id + " not found"s); + + return m_stmt_getRevision.getColumn(0); +} + +int Storage::getCursorPos(const std::string& id) +{ + CompiledSQL::Guard g{m_stmt_getCursorPos}; + m_stmt_getCursorPos.bind(1, id); + + if (!m_stmt_getCursorPos.execute()) + throw std::runtime_error("id "s + id + " not found"s); + + return m_stmt_getCursorPos.getColumn(0); +} + +std::tuple Storage::getRow(const std::string& id) +{ + CompiledSQL::Guard g{m_stmt_getRow}; + m_stmt_getRow.bind(1, id); + + if (!m_stmt_getRow.execute()) + throw std::runtime_error("id "s + id + " not found"s); + + return {m_stmt_getRow.getColumn(0), m_stmt_getRow.getColumn(1), m_stmt_getRow.getColumn(2)}; +} + +void Storage::setDocument(const std::string& id, const std::string& document) +{ + CompiledSQL::Guard g{m_stmt_setDocument}; + m_stmt_setDocument.bind(1, document); + m_stmt_setDocument.bind(2, static_cast(unixepoch())); + m_stmt_setDocument.bind(3, id); + + if (!m_stmt_setDocument.execute()) { + CompiledSQL::Guard g{m_stmt_setDocument_new}; + m_stmt_setDocument_new.bind(1, id); + m_stmt_setDocument_new.bind(2, document); + m_stmt_setDocument_new.bind(3, 0); + m_stmt_setDocument_new.bind(4, 0); + m_stmt_setDocument_new.bind(5, static_cast(unixepoch())); + if (!m_stmt_setDocument_new.execute()) + throw std::runtime_error("Unable to create document with id "s + id); + } +} + +void Storage::setRevision(const std::string& id, int rev) +{ + CompiledSQL::Guard g{m_stmt_setRevision}; + m_stmt_setRevision.bind(1, rev); + m_stmt_setRevision.bind(2, id); + + if (!m_stmt_setRevision.execute()) + throw std::runtime_error("Unable to set revision for id "s + id); +} + +void Storage::setCursorPos(const std::string& id, int cursorPos) +{ + CompiledSQL::Guard g{m_stmt_setCursorPos}; + m_stmt_setCursorPos.bind(1, cursorPos); + m_stmt_setCursorPos.bind(2, id); + + if (!m_stmt_setCursorPos.execute()) + throw std::runtime_error("Unable to set cursor position for id "s + id); +} + +void Storage::setRow(const std::string& id, const std::string& document, int rev, int cursorPos) +{ + CompiledSQL::Guard g{m_stmt_setRow}; + m_stmt_setRow.bind(1, id); + m_stmt_setRow.bind(2, document); + m_stmt_setRow.bind(3, rev); + m_stmt_setRow.bind(4, cursorPos); + m_stmt_setRow.bind(5, static_cast(unixepoch())); + if (!m_stmt_setRow.execute()) + throw std::runtime_error("Unable to insert row with id "s + id); +} + +uint32_t checksum32(const std::string& s) +{ + uint32_t result{0}; + for (unsigned int i = 0; i < s.size(); i++) { + result = ((result >> 1) | ((result & 1) << 31)) ^ (s[i] & 0xFF); + } + return result & 0x7FFFFFFF; +} + +std::string Storage::generate_id() +{ + static std::random_device r; + static std::default_random_engine e1(r()); + static std::uniform_int_distribution uniform_dist(0, 35); + + // limit tries + for (int j = 0; j < 100000; j++) { + std::string result; + for (int i = 0; i < 6; i++) { + char c{static_cast('0' + uniform_dist(e1))}; + if (c > '9') + c = c - '9' - 1 + 'a'; + result.push_back(c); + } + + if (!exists(result)) + return result; + } + + return "endofcodes"; +} + +void Storage::touchDocument(const std::string& id) +{ + CompiledSQL::Guard g{m_stmt_touchDocument}; + m_stmt_touchDocument.bind(1, static_cast(unixepoch())); + m_stmt_touchDocument.bind(2, id); + if (!m_stmt_touchDocument.execute()) + throw std::runtime_error("Unable to touch document with id "s + id); +} + diff --git a/storage.h b/storage.h new file mode 100644 index 0000000..131b786 --- /dev/null +++ b/storage.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +#include + +#include "config.h" +#include "compiledsql.h" + +class Storage +{ +public: + Storage(const Config& config); + ~Storage(); + + uint64_t getNumberOfDocuments(); + uint64_t dbsize_gross(); + uint64_t dbsize_net(); + bool exists(const std::string& id); + + std::string getDocument(const std::string& id); + int getRevision(const std::string& id); + int getCursorPos(const std::string& id); + std::tuple getRow(const std::string& id); + + void setDocument(const std::string& id, const std::string& document); + void setRevision(const std::string& id, int rev); + void setCursorPos(const std::string& id, int cursorPos); + void setRow(const std::string& id, const std::string& document, int rev, int cursorPos); + + void touchDocument(const std::string& id); + + void cleanup(); + + std::string generate_id(); + +private: + SQLite::Database m_db; + uint64_t m_maxage; + + // shared_ptr to work around initialization in constructor + CompiledSQL m_stmt_create; + CompiledSQL m_stmt_getNumberOfDocuments; + CompiledSQL m_stmt_cleanup; + CompiledSQL m_stmt_vacuum; + CompiledSQL m_stmt_exists; + CompiledSQL m_stmt_getDocument; + CompiledSQL m_stmt_getRevision; + CompiledSQL m_stmt_getCursorPos; + CompiledSQL m_stmt_getRow; + CompiledSQL m_stmt_setDocument; + CompiledSQL m_stmt_setDocument_new; + CompiledSQL m_stmt_setRevision; + CompiledSQL m_stmt_setCursorPos; + CompiledSQL m_stmt_setRow; + CompiledSQL m_stmt_getDbSizeGross; + CompiledSQL m_stmt_getDbSizeNet; + CompiledSQL m_stmt_touchDocument; +}; + +uint32_t checksum32(const std::string& s); + diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..f3ec6c8 --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,79 @@ +CXXFLAGS=-g -O0 + +include ../common.mk + +ifeq ($(CXXTYPE),clang++) +CXXFLAGS+=-fprofile-instr-generate -fcoverage-mapping +LDFLAGS+=-fprofile-instr-generate -fcoverage-mapping +else +# GCC +CXXFLAGS+=--coverage +LDFLAGS+=--coverage +endif + +UNITS=storage.cpp config.cpp compiledsql.cpp qrcode.cpp whiteboard.cpp connectionregistry.cpp diff.cpp + +UNITTESTS=test-config.cpp \ + test-storage.cpp \ + test-connectionregistry.cpp \ + test-compiledsql.cpp \ + test-qrcode.cpp \ + test-whiteboard.cpp \ + test-diff.cpp + +CXXFLAGS+=\ + -I/usr/src/googletest/googletest/include \ + -I/usr/src/googletest/googlemock/include \ + -I/usr/src/googletest/googletest \ + -I/usr/src/googletest/googlemock \ + -I.. + +test: unittests + # https://clang.llvm.org/docs/SourceBasedCodeCoverage.html +ifeq ($(CXXTYPE),clang++) + LLVM_PROFILE_FILE="unittests.profraw" ./unittests + $(LLVMPROFDATA) merge -sparse unittests.profraw -o unittests.profdata + $(LLVMCOV) report --ignore-filename-regex='google' --ignore-filename-regex='test-' --ignore-filename-regex='Magick' --show-region-summary=0 -instr-profile unittests.profdata unittests +else + ./unittests + gcovr -r .. +endif + +coverage: + $(LLVMCOV) show -instr-profile unittests.profdata $(UNITS:.cpp=.o) + +unittests: libgmock.a $(UNITTESTS:.cpp=.o) $(UNITS:.cpp=.o) + $(CXX) $(LDFLAGS) $^ $(LDLIBS) $(LIBS) -o $@ + +%.o: %.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +config.o: ../config.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +connectionregistry.o: ../connectionregistry.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +diff.o: ../diff.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +storage.o: ../storage.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +compiledsql.o: ../compiledsql.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +whiteboard.o: ../whiteboard.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +qrcode.o: ../qrcode.cpp + $(CXX) $(CXXFLAGS) -o $@ -c $< + +libgmock.a: + $(CXX) $(CXXFLAGS) -c /usr/src/googletest/googletest/src/gtest-all.cc + $(CXX) $(CXXFLAGS) -c /usr/src/googletest/googlemock/src/gmock-all.cc + $(CXX) $(CXXFLAGS) -c /usr/src/googletest/googlemock/src/gmock_main.cc + ar -rv libgmock.a gmock-all.o gtest-all.o gmock_main.o + +clean: + -rm -f *.o *.a unittests *.gcda *.gcno *.profraw *.profdata *.gcov diff --git a/tests/test-compiledsql.cpp b/tests/test-compiledsql.cpp new file mode 100644 index 0000000..d14220c --- /dev/null +++ b/tests/test-compiledsql.cpp @@ -0,0 +1,77 @@ +#include + +#include +#include +#include +#include + +#include "libreichwein/file.h" + +#include "config.h" +#include "storage.h" +#include "whiteboard.h" + +namespace fs = std::filesystem; + +namespace { + const std::string testDbFilename{"./whiteboard.db3"}; +} + +class CompiledSQLTest: public ::testing::Test +{ +protected: + CompiledSQLTest(){ + } + + ~CompiledSQLTest() override{ + } + + void SetUp() override + { + std::error_code ec; + fs::remove(testDbFilename, ec); + } + + void TearDown() override + { + std::error_code ec; + fs::remove(testDbFilename, ec); + } + +}; + +TEST_F(CompiledSQLTest, create) +{ + SQLite::Database db{testDbFilename, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE}; + + { + CompiledSQL stmt1{db, "CREATE TABLE documents (id VARCHAR(16) PRIMARY KEY)"}; + CompiledSQL::Guard g{stmt1}; + stmt1.execute(); + } + + { + CompiledSQL stmt2{db, "INSERT INTO documents (id) values (?)"}; + CompiledSQL::Guard g{stmt2}; + stmt2.bind(1, "abc"); + ASSERT_TRUE(stmt2.execute()); + } + + { + CompiledSQL stmt3{db, "SELECT id FROM documents WHERE id = ?"}; + CompiledSQL::Guard g{stmt3}; + stmt3.bind(1, "abc"); + ASSERT_TRUE(stmt3.execute()); + EXPECT_EQ(stmt3.getColumn(0), "abc"); + } + + { + CompiledSQL stmt4{db, "SELECT id FROM documents WHERE id = ?"}; + CompiledSQL::Guard g{stmt4}; + stmt4.bind(1, "def"); + ASSERT_FALSE(stmt4.execute()); + } + + EXPECT_TRUE(fs::exists(testDbFilename)); +} + diff --git a/tests/test-config.cpp b/tests/test-config.cpp new file mode 100644 index 0000000..816dfea --- /dev/null +++ b/tests/test-config.cpp @@ -0,0 +1,63 @@ +#include + +#include +#include +#include + +#include "libreichwein/file.h" + +#include "config.h" + +namespace fs = std::filesystem; +using namespace Reichwein; + +namespace { + const std::string testConfigFilename{"./test.conf"}; + const std::string testDbFilename{"./whiteboard.db3"}; +} + +class ConfigTest: public ::testing::Test +{ +protected: + ConfigTest(){ + } + + ~ConfigTest(){ + } +}; + +TEST_F(ConfigTest, defaultData) +{ + std::string filename{testConfigFilename + "doesntexist"}; + std::error_code ec; + fs::remove(filename, ec); + ASSERT_TRUE(!fs::exists(filename)); + { + Config config{filename}; + EXPECT_EQ(config.getDataPath(), "/var/lib/whiteboard"); + EXPECT_EQ(config.getMaxage(), 0UL); + ASSERT_TRUE(!fs::exists(filename)); + } + + ASSERT_TRUE(!fs::exists(filename)); +} + +TEST_F(ConfigTest, testData) +{ + File::setFile(testConfigFilename, R"CONFIG( + + /some/other/location + 2592000 + +)CONFIG"); + + { + Config config{testConfigFilename}; + EXPECT_EQ(config.getDataPath(), "/some/other/location"); + EXPECT_EQ(config.getMaxage(), 2592000UL); + } + + std::error_code ec; + fs::remove(testConfigFilename, ec); +} + diff --git a/tests/test-connectionregistry.cpp b/tests/test-connectionregistry.cpp new file mode 100644 index 0000000..dbc2b7e --- /dev/null +++ b/tests/test-connectionregistry.cpp @@ -0,0 +1,170 @@ +#include + +#include +#include +#include + +#include +#include + +#include "libreichwein/file.h" + +#include "connectionregistry.h" + +namespace fs = std::filesystem; +using namespace Reichwein; + +class ConnectionRegistryTest: public ::testing::Test +{ +protected: + ConnectionRegistryTest(){ + } + + ~ConnectionRegistryTest(){ + } +}; + +TEST_F(ConnectionRegistryTest, constructor) +{ + ConnectionRegistry cr{}; +} + +TEST_F(ConnectionRegistryTest, test_addConnection) +{ + boost::asio::io_context ioc{1}; + + boost::asio::ip::tcp::socket ts0{ioc}; + ConnectionRegistry::connection c0 {std::make_shared(std::move(ts0))}; + + boost::asio::ip::tcp::socket ts1{ioc}; + ConnectionRegistry::connection c1 {std::make_shared(std::move(ts1))}; + + ConnectionRegistry cr{}; + + cr.addConnection(c0); + EXPECT_THROW(cr.addConnection(c0), std::exception); + cr.addConnection(c1); + EXPECT_THROW(cr.addConnection(c0), std::exception); + EXPECT_THROW(cr.addConnection(c1), std::exception); +} + +TEST_F(ConnectionRegistryTest, test_delConnection) +{ + boost::asio::io_context ioc{1}; + + boost::asio::ip::tcp::socket ts0{ioc}; + ConnectionRegistry::connection c0 {std::make_shared(std::move(ts0))}; + + boost::asio::ip::tcp::socket ts1{ioc}; + ConnectionRegistry::connection c1 {std::make_shared(std::move(ts1))}; + + ConnectionRegistry cr{}; + + EXPECT_THROW(cr.delConnection(c0), std::exception); + + cr.addConnection(c0); + cr.delConnection(c0); + + cr.addConnection(c0); + cr.addConnection(c1); + cr.delConnection(c0); + EXPECT_THROW(cr.delConnection(c0), std::exception); + cr.delConnection(c1); + EXPECT_THROW(cr.delConnection(c1), std::exception); +} + +TEST_F(ConnectionRegistryTest, test_setId) +{ + boost::asio::io_context ioc{1}; + boost::asio::ip::tcp::socket ts{ioc}; + + ConnectionRegistry::connection c {std::make_shared(std::move(ts))}; + + ConnectionRegistry cr{}; + + EXPECT_THROW(cr.setId(c, "id1"), std::exception); + cr.addConnection(c); + cr.setId(c, "id2"); +} + +TEST_F(ConnectionRegistryTest, test_dump) +{ + boost::asio::io_context ioc{1}; + boost::asio::ip::tcp::socket ts{ioc}; + + ConnectionRegistry::connection c {std::make_shared(std::move(ts))}; + + ConnectionRegistry cr{}; + + cr.addConnection(c); + cr.dump(); + + cr.setId(c, "id1"); + cr.dump(); +} + +TEST_F(ConnectionRegistryTest, test_iterators) +{ + boost::asio::io_context ioc{1}; + boost::asio::ip::tcp::socket ts{ioc}; + + ConnectionRegistry::connection c {std::make_shared(std::move(ts))}; + + ConnectionRegistry cr{}; + + EXPECT_THROW(cr.begin(""), std::exception); + EXPECT_THROW(cr.end(""), std::exception); + EXPECT_EQ(std::distance(cr.begin(), cr.end()), 0); + + cr.addConnection(c); + EXPECT_THROW(cr.begin(""), std::exception); + EXPECT_THROW(cr.end(""), std::exception); + EXPECT_EQ(std::distance(cr.begin(), cr.end()), 1); + + cr.setId(c, "id1"); + + EXPECT_EQ(std::distance(cr.begin("id1"), cr.end("id1")), 1); + EXPECT_EQ(std::distance(cr.begin(), cr.end()), 1); +} + +TEST_F(ConnectionRegistryTest, test_guard) +{ + boost::asio::io_context ioc{1}; + boost::asio::ip::tcp::socket ts{ioc}; + + ConnectionRegistry::connection c {std::make_shared(std::move(ts))}; + + ConnectionRegistry cr{}; + + { + ConnectionRegistry::RegistryGuard rg{cr, c}; + + EXPECT_THROW(cr.addConnection(c), std::exception); + } + + EXPECT_THROW(cr.delConnection(c), std::exception); +} + +TEST_F(ConnectionRegistryTest, number_of_connections) +{ + boost::asio::io_context ioc{1}; + + boost::asio::ip::tcp::socket ts0{ioc}; + ConnectionRegistry::connection c0 {std::make_shared(std::move(ts0))}; + + boost::asio::ip::tcp::socket ts1{ioc}; + ConnectionRegistry::connection c1 {std::make_shared(std::move(ts1))}; + + ConnectionRegistry cr{}; + + EXPECT_EQ(cr.number_of_connections(), 0); + cr.addConnection(c0); + EXPECT_EQ(cr.number_of_connections(), 1); + cr.addConnection(c1); + EXPECT_EQ(cr.number_of_connections(), 2); + cr.delConnection(c0); + EXPECT_EQ(cr.number_of_connections(), 1); + cr.delConnection(c1); + EXPECT_EQ(cr.number_of_connections(), 0); +} + diff --git a/tests/test-diff.cpp b/tests/test-diff.cpp new file mode 100644 index 0000000..d09c58e --- /dev/null +++ b/tests/test-diff.cpp @@ -0,0 +1,199 @@ +#include + +#include +#include +#include + +#include + +#include + +#include "libreichwein/file.h" + +#include "diff.h" + +namespace fs = std::filesystem; +namespace pt = boost::property_tree; +using namespace Reichwein; + +class DiffTest: public ::testing::Test +{ +protected: + DiffTest(){ + } + + ~DiffTest(){ + } +}; + +TEST_F(DiffTest, constructor) +{ + // empty constructor + { + Diff d{}; + EXPECT_EQ(d.get_xml(), "00"); + } + + // constructor via xml diff + { + EXPECT_THROW(Diff d{""}, std::exception); + } + { + EXPECT_THROW(Diff d{""}, std::exception); + EXPECT_THROW(Diff d{"0abc"}, std::exception); + EXPECT_THROW(Diff d{"0abc"}, std::exception); + EXPECT_THROW(Diff d{"00"}, std::exception); + EXPECT_THROW(Diff d{"50abc"}, std::exception); + EXPECT_THROW(Diff d{"0abc"}, std::exception); + } + + { + Diff d{"00abc"}; + EXPECT_EQ(d.get_xml(), "00abc"); + } + + { + Diff d{"55abc"}; + EXPECT_EQ(d.get_xml(), "55abc"); + } + + { + Diff d{"550abc"}; + EXPECT_EQ(d.get_xml(), "550abc"); + } + + // constructor via ptree + { + pt::ptree ptree; + EXPECT_THROW(Diff d{ptree}, std::exception); + + ptree.put("diff.start", 0); + ptree.put("diff.end", 0); + ptree.put("diff.data", "abc"); + Diff d{ptree}; + EXPECT_EQ(d.get_xml(), "00abc"); + } + + // constructor via versions + { + Diff d{"", ""}; + EXPECT_EQ(d.get_xml(), "00"); + } + { + Diff d{"a", "a"}; + EXPECT_EQ(d.get_xml(), "00"); + } + { + Diff d{"a", "b"}; + EXPECT_EQ(d.get_xml(), "01b"); + } + { + Diff d{"0a1", "0b1"}; + EXPECT_EQ(d.get_xml(), "12b"); + } + { + Diff d{"0abc1", "00b01"}; + EXPECT_EQ(d.get_xml(), "140b0"); + } + { + Diff d{"0ab1", "00b01"}; + EXPECT_EQ(d.get_xml(), "130b0"); + } + { + Diff d{"0abc1", "00b1"}; + EXPECT_EQ(d.get_xml(), "140b"); + } + { + Diff d{"0abc1", ""}; + EXPECT_EQ(d.get_xml(), "05"); + } + { + Diff d{"bc1", "0abc1"}; + EXPECT_EQ(d.get_xml(), "000a"); + } + { + Diff d{"0abc1", "0ab"}; + EXPECT_EQ(d.get_xml(), "35"); + } + { + Diff d{"0abc1", "0abc123"}; + EXPECT_EQ(d.get_xml(), "5523"); + } + { + Diff d{"0abc1", "010abc1"}; + EXPECT_EQ(d.get_xml(), "0001"); + } + { + Diff d{"0abc1", "0ac1"}; + EXPECT_EQ(d.get_xml(), "23"); + } + { + Diff d{"0abc1", "0abxc1"}; + EXPECT_EQ(d.get_xml(), "33x"); + } + { + Diff d{"abc", "c"}; + EXPECT_EQ(d.get_xml(), "02"); + } + { + Diff d{"aaaa", "aa"}; + EXPECT_EQ(d.get_xml(), "24"); + } + { + Diff d{"baaaa", "baa"}; + EXPECT_EQ(d.get_xml(), "35"); + } + { + Diff d{"baaaab", "baab"}; + EXPECT_EQ(d.get_xml(), "13"); + } + { + Diff d{"baaaab", "baaaaaaab"}; + EXPECT_EQ(d.get_xml(), "11aaa"); + } +} + +TEST_F(DiffTest, empty) +{ + { + Diff d; + EXPECT_TRUE(d.empty()); + } + + { + Diff d{"13"}; + EXPECT_FALSE(d.empty()); + } + + { + Diff d{"11"}; + EXPECT_TRUE(d.empty()); + } + + { + Diff d{"11abc"}; + EXPECT_FALSE(d.empty()); + } + + { + Diff d{"00"}; + EXPECT_TRUE(d.empty()); + } +} + +TEST_F(DiffTest, diff_create) +{ + const char* result {diff_create("0abc1", "0ab")}; + + EXPECT_EQ(std::string(result), "35"); + free((void*)result); // this will be done by javascript side in real scenario +} + +TEST_F(DiffTest, diff_apply) +{ + const char* result {diff_apply("0abc1", "35")}; + + EXPECT_EQ(std::string(result), "0ab"); + free((void*)result); // this will be done by javascript side in real scenario +} + diff --git a/tests/test-qrcode.cpp b/tests/test-qrcode.cpp new file mode 100644 index 0000000..96cfdb0 --- /dev/null +++ b/tests/test-qrcode.cpp @@ -0,0 +1,140 @@ +#include + +#include +#include +#include +#include + +#include "libreichwein/file.h" + +#include "qrcode.h" + +namespace fs = std::filesystem; + +class QRCodeTest: public ::testing::Test +{ +protected: + QRCodeTest(){ + } + + ~QRCodeTest() override{ + } + + void SetUp() override + { + QRCode::init(); + } + + void TearDown() override + { + } + +}; + +TEST_F(QRCodeTest, empty) +{ + unsigned char empty_png[] = { + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, + 0x10, 0x00, 0x00, 0x00, 0x00, 0xdc, 0xec, 0x26, 0x09, 0x00, 0x00, 0x00, + 0xa7, 0x49, 0x44, 0x41, 0x54, 0x28, 0xcf, 0x7d, 0x92, 0x4b, 0x12, 0xc3, + 0x30, 0x08, 0x43, 0x79, 0x99, 0xde, 0xff, 0xca, 0xea, 0x82, 0x51, 0xf9, + 0xc4, 0xae, 0x17, 0x89, 0x01, 0x03, 0x02, 0x89, 0x18, 0x47, 0x8a, 0x00, + 0x29, 0x2d, 0xe8, 0xb1, 0x4f, 0x86, 0x7b, 0x48, 0x02, 0x7b, 0x57, 0xcc, + 0xa6, 0xd4, 0x43, 0xd3, 0x96, 0x22, 0x9e, 0x38, 0x9e, 0x9e, 0xe2, 0xf3, + 0x9c, 0x1f, 0x15, 0xde, 0x81, 0xf5, 0x5d, 0x43, 0x02, 0x3f, 0x3e, 0xd5, + 0x1f, 0xf8, 0x36, 0xee, 0x88, 0x08, 0xae, 0x59, 0x37, 0xac, 0x00, 0xfe, + 0xee, 0x9d, 0x76, 0x6f, 0x48, 0x52, 0xb5, 0xcc, 0x7b, 0xfd, 0xed, 0x1f, + 0x1b, 0xa8, 0xec, 0x64, 0x6d, 0x51, 0x31, 0xab, 0x15, 0xf2, 0xb2, 0xdc, + 0x91, 0x4e, 0x24, 0x48, 0x7d, 0xa3, 0x53, 0x03, 0xaf, 0x0d, 0x54, 0xd3, + 0x9b, 0x6c, 0x16, 0xa1, 0x7b, 0x34, 0x29, 0x82, 0xa9, 0x9e, 0x84, 0x61, + 0xc6, 0x66, 0xfd, 0xc7, 0x9b, 0x73, 0x93, 0x2d, 0xc0, 0xf4, 0xc3, 0x1f, + 0x65, 0xf1, 0x1b, 0xd8, 0x30, 0xae, 0xca, 0x9a, 0x83, 0x02, 0x1c, 0x94, + 0xe5, 0x4d, 0x27, 0x42, 0x57, 0x3e, 0x34, 0xde, 0x74, 0x16, 0xf2, 0x2f, + 0x2f, 0xd5, 0xa5, 0xd0, 0x5a, 0x61, 0xde, 0x49, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 + }; + // Test prepared with: + //File::setFile("empty.png", QRCode::getQRCode("")); + //then: xxd -i tests/empty.png >> tests/test-qrcode.cpp + + EXPECT_EQ(QRCode::getQRCode(""), std::string(reinterpret_cast(&empty_png[0]), sizeof(empty_png))); +} + +TEST_F(QRCodeTest, simple) +{ + unsigned char simple_png[] = { + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00, 0x15, + 0x10, 0x00, 0x00, 0x00, 0x00, 0xdc, 0xec, 0x26, 0x09, 0x00, 0x00, 0x00, + 0xa5, 0x49, 0x44, 0x41, 0x54, 0x28, 0xcf, 0x75, 0x52, 0xd1, 0x0e, 0xc0, + 0x40, 0x04, 0x6b, 0x17, 0xff, 0xff, 0xcb, 0xf6, 0xe0, 0x4c, 0x39, 0x93, + 0x6c, 0x09, 0x47, 0x55, 0x01, 0x9a, 0xb9, 0xbb, 0xbb, 0x03, 0xf9, 0xa9, + 0x99, 0x86, 0x48, 0x80, 0x8c, 0xe4, 0x2c, 0xd5, 0xb7, 0xcf, 0x2d, 0xa4, + 0xfa, 0xeb, 0x9b, 0x61, 0xb5, 0xd9, 0x1c, 0x00, 0x9e, 0x2d, 0x2d, 0x88, + 0xcc, 0xb8, 0xdd, 0x18, 0xa4, 0x3b, 0xa9, 0xad, 0x7f, 0x1b, 0xff, 0x29, + 0xc0, 0xbd, 0x8a, 0x5c, 0xf0, 0x52, 0xc7, 0x2e, 0x90, 0x2a, 0x91, 0x51, + 0x4b, 0xfa, 0xc9, 0x2e, 0xfc, 0x40, 0x55, 0x6c, 0xb2, 0xe1, 0xf5, 0x4e, + 0xd3, 0x7f, 0xaa, 0xf2, 0xa6, 0x52, 0x24, 0x48, 0x77, 0xd9, 0xfa, 0x9c, + 0x7c, 0xae, 0xf7, 0x52, 0xa0, 0xab, 0xaa, 0x71, 0xeb, 0x5b, 0xa9, 0x21, + 0xfa, 0x50, 0x67, 0x5b, 0xe3, 0x7a, 0xd0, 0x67, 0x8f, 0x0e, 0xe4, 0x59, + 0x6c, 0xa4, 0x94, 0x96, 0xda, 0x29, 0xca, 0x80, 0xf5, 0x5c, 0x52, 0x53, + 0x3d, 0x9a, 0xa3, 0xcb, 0x7d, 0xaf, 0x93, 0xf9, 0xa1, 0xd2, 0xb9, 0xea, + 0xc6, 0x92, 0xf1, 0x7a, 0x0f, 0xaa, 0xee, 0x8c, 0x03, 0x2f, 0x88, 0x71, + 0xb5, 0xba, 0xf0, 0x88, 0xba, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 + }; + // Test prepared with: + //File::setFile("simple.png", QRCode::getQRCode("abc")); + //then: xxd -i tests/simple.png >> tests/test-qrcode.cpp + EXPECT_EQ(QRCode::getQRCode("abc"), std::string(reinterpret_cast(&simple_png[0]), sizeof(simple_png))); +} + +TEST_F(QRCodeTest, url) +{ + unsigned char url_png[] = { + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x1d, 0x00, 0x00, 0x00, 0x1d, + 0x10, 0x00, 0x00, 0x00, 0x00, 0x23, 0x68, 0xe4, 0x90, 0x00, 0x00, 0x01, + 0x1f, 0x49, 0x44, 0x41, 0x54, 0x38, 0xcb, 0x75, 0x54, 0xdb, 0x0e, 0xc5, + 0x30, 0x08, 0x92, 0xa5, 0xff, 0xff, 0xcb, 0x9c, 0x07, 0xe7, 0x41, 0xb4, + 0x6b, 0xb2, 0x64, 0x36, 0x5e, 0xa8, 0xa0, 0x11, 0x76, 0xc8, 0x08, 0x92, + 0x24, 0xcb, 0xde, 0x7f, 0x75, 0x4e, 0xba, 0xe7, 0x01, 0x22, 0x48, 0x20, + 0xbf, 0xb4, 0x2b, 0xe1, 0xfc, 0x03, 0x4e, 0x85, 0xd4, 0x75, 0x06, 0xe5, + 0xbf, 0xc2, 0xf3, 0xd6, 0x7d, 0x9f, 0x58, 0x07, 0x50, 0xbd, 0xc2, 0x11, + 0x97, 0xb3, 0x42, 0x3b, 0xd0, 0xb4, 0x33, 0x7c, 0x87, 0x1e, 0x7f, 0x81, + 0xd7, 0x51, 0x0a, 0x01, 0xbe, 0x25, 0x69, 0x1d, 0xfe, 0xfe, 0xac, 0xea, + 0x2d, 0x8b, 0x57, 0x50, 0xed, 0xe1, 0xbb, 0x73, 0x3a, 0xaf, 0x65, 0x7b, + 0xba, 0xd7, 0xea, 0x64, 0x94, 0x93, 0x1c, 0x2a, 0xd8, 0xd3, 0x92, 0x7f, + 0x95, 0xec, 0x90, 0xa9, 0x9f, 0xb2, 0x94, 0xee, 0x88, 0xf4, 0xba, 0x72, + 0x4e, 0xf5, 0x5a, 0x69, 0x6b, 0xf0, 0xdc, 0x6b, 0xf8, 0x4b, 0x85, 0xac, + 0x3f, 0x8a, 0x7c, 0xba, 0x0c, 0x66, 0x78, 0xe7, 0x55, 0xdc, 0x96, 0xda, + 0x5e, 0x99, 0x2b, 0x38, 0xc5, 0x3f, 0x61, 0x6d, 0x0a, 0x81, 0xf0, 0x56, + 0x4c, 0x4e, 0x7d, 0xe4, 0x7c, 0x24, 0x9f, 0x39, 0x74, 0xe5, 0x86, 0x86, + 0x87, 0x04, 0x2e, 0x23, 0xd0, 0xdb, 0x20, 0x8a, 0xe6, 0x60, 0x4f, 0x91, + 0x7c, 0x32, 0xe6, 0x2f, 0xdc, 0x12, 0x79, 0x31, 0x0a, 0x56, 0xe7, 0xb2, + 0xef, 0x07, 0x98, 0x8f, 0x8d, 0xe0, 0x6c, 0xd4, 0x04, 0xb6, 0xe1, 0xbf, + 0xc2, 0x98, 0x97, 0x5d, 0x43, 0xaa, 0x5f, 0xdb, 0xca, 0x6f, 0x57, 0xd3, + 0xee, 0xe4, 0xec, 0x3e, 0x9c, 0x4d, 0x8e, 0x81, 0xc2, 0x2d, 0x5d, 0x56, + 0x7e, 0x32, 0x44, 0xbc, 0xed, 0x6a, 0x1a, 0x87, 0xf4, 0x2a, 0xe8, 0x6b, + 0xad, 0x75, 0x51, 0xfa, 0x66, 0xf4, 0x07, 0x01, 0x97, 0x8d, 0x08, 0x90, + 0x09, 0xb5, 0xef, 0x64, 0xe7, 0xbf, 0x49, 0x62, 0x8e, 0xfa, 0xec, 0xfb, + 0x26, 0xad, 0x8d, 0xba, 0x3b, 0xf5, 0xf6, 0xf8, 0x22, 0x95, 0xf7, 0x0f, + 0x4c, 0x20, 0xaf, 0x2d, 0xca, 0xaf, 0x0d, 0xc5, 0x00, 0x00, 0x00, 0x00, + 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82 + }; + // Test prepared with: + //File::setFile("url.png", QRCode::getQRCode("https://reichwein.it/whiteboard")); + //then: xxd -i tests/url.png >> tests/test-qrcode.cpp + EXPECT_EQ(QRCode::getQRCode("https://reichwein.it/whiteboard"), std::string(reinterpret_cast(&url_png[0]), sizeof(url_png))); +} + +TEST_F(QRCodeTest, deterministic) +{ + EXPECT_EQ(QRCode::getQRCode("data1"), QRCode::getQRCode("data1")); +} + +TEST_F(QRCodeTest, different_data) +{ + EXPECT_NE(QRCode::getQRCode("data1"), QRCode::getQRCode("data2")); +} diff --git a/tests/test-storage.cpp b/tests/test-storage.cpp new file mode 100644 index 0000000..6239f8f --- /dev/null +++ b/tests/test-storage.cpp @@ -0,0 +1,283 @@ +#include + +#include +#include +#include +#include + +#include "libreichwein/file.h" + +#include "config.h" +#include "storage.h" + +namespace fs = std::filesystem; +using namespace Reichwein; +using namespace std::string_literals; + +namespace { + const std::string testConfigFilename{"./test.conf"}; + const std::string testDbFilename{"./whiteboard.db3"}; +} + +class StorageTest: public ::testing::Test +{ +protected: + StorageTest(){ + } + + ~StorageTest() override{ + } + + void SetUp() override + { + File::setFile(testConfigFilename, R"CONFIG( + + . + 2592000 + +)CONFIG"); + std::error_code ec; + fs::remove(testDbFilename, ec); + + m_config = std::make_shared(testConfigFilename); + } + + void TearDown() override + { + std::error_code ec; + fs::remove(testDbFilename, ec); + fs::remove(testConfigFilename, ec); + } + + std::shared_ptr m_config; +}; + +TEST_F(StorageTest, create) +{ + ASSERT_TRUE(!fs::exists(testDbFilename)); + + { + ASSERT_EQ(m_config->getDataPath(), "."); + ASSERT_TRUE(!fs::exists(testDbFilename)); + Storage storage(*m_config); + } + + ASSERT_TRUE(fs::exists(testDbFilename)); +} + +TEST_F(StorageTest, getNumberOfDocuments) +{ + Storage storage(*m_config); + EXPECT_EQ(storage.getNumberOfDocuments(), 0UL); + storage.setDocument("123", "abc"); + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + storage.setDocument("def", "xyz"); + EXPECT_EQ(storage.getNumberOfDocuments(), 2UL); +} + +TEST_F(StorageTest, cleanup_empty) +{ + Storage storage(*m_config); + EXPECT_EQ(storage.getNumberOfDocuments(), 0UL); + storage.cleanup(); + EXPECT_EQ(storage.getNumberOfDocuments(), 0UL); +} + +TEST_F(StorageTest, cleanup) +{ + Storage storage(*m_config); + EXPECT_EQ(storage.getNumberOfDocuments(), 0UL); + storage.setDocument("123", "abc"); + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + storage.cleanup(); + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); +} + +TEST_F(StorageTest, exists) +{ + Storage storage(*m_config); + EXPECT_EQ(storage.exists(""), false); + EXPECT_EQ(storage.exists("0"), false); + EXPECT_EQ(storage.exists("123"), false); + EXPECT_EQ(storage.exists("abcdz"), false); + + storage.setDocument("", "abc"); + EXPECT_EQ(storage.exists(""), true); + storage.setDocument("0", "abc"); + EXPECT_EQ(storage.exists("0"), true); + storage.setDocument("123", "abc"); + EXPECT_EQ(storage.exists("123"), true); + storage.setDocument("abcdz", "abc"); + EXPECT_EQ(storage.exists("abcdz"), true); +} + +TEST_F(StorageTest, setDocument) +{ + Storage storage(*m_config); + storage.setDocument("0", "abc"); + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + EXPECT_EQ(storage.getDocument("0"), "abc"); +} + +TEST_F(StorageTest, touchDocument) +{ + Storage storage(*m_config); + EXPECT_THROW(storage.touchDocument("0"), std::exception); + storage.setDocument("0", "abc"); + storage.touchDocument("0"); + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + EXPECT_EQ(storage.getDocument("0"), "abc"); +} + +TEST_F(StorageTest, setRevision) +{ + Storage storage(*m_config); + storage.setDocument("0", "abc"); + storage.setRevision("0", 123); + + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + EXPECT_EQ(storage.getRevision("0"), 123); + + try { + storage.setRevision("1", 123); + FAIL(); + } catch(const std::exception& ex) { + EXPECT_EQ("Unable to set revision for id 1"s, ex.what()); + } catch(...) { + FAIL(); + } +} + +TEST_F(StorageTest, setCursorPos) +{ + Storage storage(*m_config); + storage.setDocument("0", "abc"); + storage.setCursorPos("0", 1234); + + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + EXPECT_EQ(storage.getCursorPos("0"), 1234); + + try { + storage.setCursorPos("1", 12345); + FAIL(); + } catch(const std::exception& ex) { + EXPECT_EQ("Unable to set cursor position for id 1"s, ex.what()); + } catch(...) { + FAIL(); + } +} + +TEST_F(StorageTest, setRow) +{ + Storage storage(*m_config); + storage.setRow("0", "abc", 56, 67); + + EXPECT_EQ(storage.getNumberOfDocuments(), 1UL); + EXPECT_EQ(storage.getDocument("0"), "abc"); + EXPECT_EQ(storage.getRevision("0"), 56); + EXPECT_EQ(storage.getCursorPos("0"), 67); +} + +TEST_F(StorageTest, getDocument) +{ + Storage storage(*m_config); + storage.setDocument("0", "xyz"); + storage.setDocument("0bc", "xyz2"); + storage.setDocument("iabc", "xyz3"); + storage.setDocument("zxy", "xyz4"); + + EXPECT_EQ(storage.getDocument("0"), "xyz"); +} + +TEST_F(StorageTest, getRevision) +{ + Storage storage(*m_config); + storage.setRow("0", "abc", 123, 456); + + EXPECT_EQ(storage.getRevision("0"), 123); +} + +TEST_F(StorageTest, getCursorPos) +{ + Storage storage(*m_config); + storage.setRow("0", "abc", 123, 456); + + EXPECT_EQ(storage.getCursorPos("0"), 456); +} + +TEST_F(StorageTest, getRow) +{ + Storage storage(*m_config); + storage.setRow("0", "abc", 123, 456); + + auto row{storage.getRow("0")}; + EXPECT_EQ(std::get<0>(row), "abc"); + EXPECT_EQ(std::get<1>(row), 123); + EXPECT_EQ(std::get<2>(row), 456); +} + +TEST_F(StorageTest, revision_increment) +{ + Storage storage(*m_config); + storage.setDocument("0", "xyz"); + storage.setDocument("0bc", "xyz2"); + storage.setDocument("iabc", "xyz3"); + + EXPECT_EQ(storage.getRevision("0"), 0); + EXPECT_EQ(storage.getRevision("0bc"), 0); + EXPECT_EQ(storage.getRevision("iabc"), 0); + + storage.setDocument("0bc", "xyz234"); + EXPECT_EQ(storage.getRevision("0bc"), 1); + + storage.setDocument("0bc", "xyz2345"); + EXPECT_EQ(storage.getRevision("0"), 0); + EXPECT_EQ(storage.getRevision("0bc"), 2); + EXPECT_EQ(storage.getRevision("iabc"), 0); +} + +TEST_F(StorageTest, generate_id) +{ + Storage storage(*m_config); + for (int i = 0; i < 100; i++) { + std::string a{storage.generate_id()}; + std::string b{storage.generate_id()}; + + EXPECT_NE(a, b); + EXPECT_NE(a, ""); + EXPECT_NE(b, ""); + + EXPECT_GE(a.size(), 6); + + for (char c: a + b) { + EXPECT_TRUE((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')); + } + } +} + +TEST_F(StorageTest, checksum32) +{ + EXPECT_EQ(checksum32(""), 0); + EXPECT_EQ(checksum32("0"), 48); + EXPECT_EQ(checksum32("\x00"), 0); + EXPECT_EQ(checksum32("123"), 1073741862); + EXPECT_EQ(checksum32("a"), 97); + EXPECT_EQ(checksum32("ab"), 82); + EXPECT_EQ(checksum32("abc"), 1073741898); +} + +TEST_F(StorageTest, db_size) +{ + Storage storage(*m_config); + auto dbsize_gross{storage.dbsize_gross()}; + auto dbsize_net{storage.dbsize_net()}; + + EXPECT_LE(0, storage.dbsize_net()); + EXPECT_LE(storage.dbsize_net(), storage.dbsize_gross()); + + storage.setDocument("0", "xyz"); + + EXPECT_LE(dbsize_net, storage.dbsize_net()); + EXPECT_LE(dbsize_gross, storage.dbsize_gross()); +} + diff --git a/tests/test-whiteboard.cpp b/tests/test-whiteboard.cpp new file mode 100644 index 0000000..3f70bcf --- /dev/null +++ b/tests/test-whiteboard.cpp @@ -0,0 +1,255 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "libreichwein/file.h" +#include "libreichwein/process.h" + +#include "config.h" +#include "storage.h" +#include "whiteboard.h" + +namespace bp = boost::process; +namespace fs = std::filesystem; +using namespace Reichwein; +using namespace std::string_literals; + +namespace { + const fs::path webserverConfigFilename{"./webserver.conf"}; + + const fs::path testConfigFilename{"./whiteboard.conf"}; + const fs::path testDbFilename{"./whiteboard.db3"}; +} + +class WhiteboardTest: public ::testing::Test +{ +protected: + WhiteboardTest(){ + } + + ~WhiteboardTest() override{ + } + + void SetUp() override + { + File::setFile(testConfigFilename, R"CONFIG( + + ::1:9876 + . + 2592000 + 4 + 3 + +)CONFIG"); + std::error_code ec; + fs::remove(testDbFilename, ec); + + m_config = std::make_shared(testConfigFilename); + + m_pid = fork(); + if (m_pid == -1) { + throw std::runtime_error("Error on fork(): "s + strerror(errno)); + } else if (m_pid == 0) { // child + Whiteboard whiteboard; + std::vector argvv{{"whiteboard", "-c", testConfigFilename.generic_string()}}; + char* argv[] = {argvv[0].data(), argvv[1].data(), argvv[2].data(), nullptr}; + whiteboard.run(argvv.size(), argv); + exit(0); + } + Process::wait_for_pid_listening_on(m_pid, 9876); + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + + void TearDown() override + { + if (m_pid == 0) + throw std::runtime_error("Whiteboard not running on requesting SIGTERM"); + + if (kill(m_pid, SIGTERM) != 0) + throw std::runtime_error("Unable to SIGTERM Whiteboard"); + + if (int result = waitpid(m_pid, NULL, 0); result != m_pid) + throw std::runtime_error("waitpid returned "s + std::to_string(result)); + + std::error_code ec; + fs::remove(testDbFilename, ec); + fs::remove(testConfigFilename, ec); + } + + std::shared_ptr m_config; + pid_t m_pid{}; +}; + +class WebsocketClient +{ +public: + WebsocketClient() + { + std::string host = "::1"; + auto const port = "9876" ; + + // These objects perform our I/O + boost::asio::ip::tcp::resolver resolver{ioc_}; + ws_ = std::make_unique>(ioc_); + + // Look up the domain name + resolver_results_ = resolver.resolve(host, port); + + connect(); + handshake(); + } + + void connect() + { + // Make the connection on the IP address we get from a lookup + ep_ = boost::asio::connect(boost::beast::get_lowest_layer(*ws_), resolver_results_); + } + + void handshake() + { + // Update the host_ string. This will provide the value of the + // Host HTTP header during the WebSocket handshake. + // See https://tools.ietf.org/html/rfc7230#section-5.4 + std::string host{"[::1]:9876"}; + + // Set a decorator to change the User-Agent of the handshake + ws_->set_option(boost::beast::websocket::stream_base::decorator( + [](boost::beast::websocket::request_type& req) + { + req.set(boost::beast::http::field::user_agent, + std::string("Reichwein.IT Test Websocket Client")); + })); + + // Perform the websocket handshake + ws_->handshake(host, "/"); + + } + + void write(const std::string& data) + { + ws_->write(boost::asio::buffer(data)); + } + + std::string read() + { + boost::beast::flat_buffer buffer; + ws_->read(buffer); + return {boost::asio::buffers_begin(buffer.data()), boost::asio::buffers_end(buffer.data())}; + } + + ~WebsocketClient() + { + } + + bool is_open() + { + return ws_->is_open(); + } + +private: + boost::asio::io_context ioc_; + boost::asio::ip::tcp::resolver::results_type resolver_results_; + std::unique_ptr> ws_; + boost::asio::ip::tcp::endpoint ep_; +}; + +// +// tests via websocket server in separate process (hides coverage) +// + +TEST_F(WhiteboardTest, websocket_server_connection) +{ + WebsocketClient wc; +} + +TEST_F(WhiteboardTest, websocket_server_generate_id) +{ + WebsocketClient wc; + + wc.write("newid"); + std::string result0 {wc.read()}; + ASSERT_TRUE(boost::algorithm::starts_with(result0, "newid")); + ASSERT_TRUE(boost::algorithm::ends_with(result0, "")); + ASSERT_EQ(result0.size(), 58); + + wc.write("newid"); + std::string result1 {wc.read()}; + ASSERT_TRUE(boost::algorithm::starts_with(result1, "newid")); + ASSERT_TRUE(boost::algorithm::ends_with(result1, "")); + ASSERT_EQ(result1.size(), 58); + + ASSERT_NE(result0, result1); +} + +// check number of threads as configured +TEST_F(WhiteboardTest, threads) +{ + ASSERT_GE(Process::number_of_threads(m_pid), 4); +} + +TEST_F(WhiteboardTest, max_connections) +{ + WebsocketClient wc1; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ASSERT_TRUE(wc1.is_open()); + + WebsocketClient wc2; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ASSERT_TRUE(wc2.is_open()); + + WebsocketClient wc3; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + ASSERT_TRUE(wc3.is_open()); + + ASSERT_THROW(WebsocketClient wc4, std::exception); +} + +TEST_F(WhiteboardTest, id) +{ + WebsocketClient wc; + + wc.write("getfile1"); + std::string result {wc.read()}; + + EXPECT_EQ(result, "getfile00"); + + wc.write("getfile"); + result = wc.read(); + EXPECT_EQ(result, "errorMessage handling error: Invalid id (empty)"); + + wc.write("getfile01234567890123456789"); + result = wc.read(); + EXPECT_EQ(result, "errorMessage handling error: Invalid id (size > 16)"); + + wc.write("getfileX"); + result = wc.read(); + EXPECT_EQ(result, "errorMessage handling error: Invalid id char: X"); + + wc.write("getfilea."); + result = wc.read(); + EXPECT_EQ(result, "errorMessage handling error: Invalid id char: ."); + + wc.write("getfilea$b"); + result = wc.read(); + EXPECT_EQ(result, "errorMessage handling error: Invalid id char: $"); +} + diff --git a/webassembly/Makefile b/webassembly/Makefile new file mode 100644 index 0000000..0487d2a --- /dev/null +++ b/webassembly/Makefile @@ -0,0 +1,32 @@ +TARGET=libwhiteboard.wasm +TARGETJS=$(TARGET:.wasm=.js) + +OBJS=diff.o + +CXX=em++ + +CXXFLAGS=-I./include -O2 -std=c++20 +LDFLAGS=-s WASM=1 -s EXPORTED_FUNCTIONS="['_diff_create', '_diff_apply', '_free']" +# Note: Instead of the above explicit EXPORTED_FUNCTIONS, the following causes ~7x wasm file size: +#-s LINKABLE=1 -s EXPORT_ALL=1 + +default: $(TARGET) + +$(OBJS): include + +include: + mkdir include + cp -r /usr/include/boost include/boost + +$(TARGET): $(OBJS) + $(CXX) $(LDFLAGS) $(OBJS) -o $(TARGETJS) + cp $(TARGETJS) $(TARGET) ../html/ + +diff.o: ../diff.cpp + $(CXX) -c $< $(CXXFLAGS) -o $@ + # run again in case em++ just asked to re-run (on pbuilder/buildd) + test -e $@ || $(CXX) -c $< $(CXXFLAGS) -o $@ + +clean: + -rm -f *.o *.js *.wasm *.html + -rm -rf include diff --git a/webserver.conf.example b/webserver.conf.example new file mode 100644 index 0000000..9193a5b --- /dev/null +++ b/webserver.conf.example @@ -0,0 +1,8 @@ + + static-files + /usr/lib/whiteboard/html + + + websocket + 127.0.0.1:9014 + diff --git a/whiteboard.conf b/whiteboard.conf new file mode 100644 index 0000000..4622db9 --- /dev/null +++ b/whiteboard.conf @@ -0,0 +1,34 @@ + + + /var/lib/whiteboard + + + ::1:9014 + + + 2592000 + + + 4 + + + 500 + diff --git a/whiteboard.cpp b/whiteboard.cpp new file mode 100644 index 0000000..8fb5415 --- /dev/null +++ b/whiteboard.cpp @@ -0,0 +1,571 @@ +#include "whiteboard.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "libreichwein/base64.h" +#include "libreichwein/file.h" +#include "libreichwein/tempfile.h" +#include "libreichwein/xml.h" + +#include "config.h" +#include "diff.h" +#include "qrcode.h" +#include "storage.h" + +namespace pt = boost::property_tree; +using namespace std::string_literals; +using namespace std::placeholders; +namespace fs = std::filesystem; +namespace bp = boost::process; + +namespace { + + void usage() { + std::cout << + "Usage: \n" + " whiteboard [options]\n" + "\n" + "Options:\n" + " -c : specify configuration file including path\n" + " -C : clean up database according to timeout rules (config: maxage)\n" + " -h : this help\n" + "\n" + "Without options, whiteboard will be started as websocket application" + << std::endl; + } + +} // namespace + +Whiteboard::Whiteboard() +{ +} + +namespace { + +pt::ptree make_ptree(const std::initializer_list>& key_values) +{ + pt::ptree ptree; + for (const auto& i: key_values) { + ptree.put(fmt::format("serverinfo.{}", i.first), i.second); + } + + return ptree; +} + +std::string make_xml(const std::initializer_list>& key_values) +{ + pt::ptree ptree{make_ptree(key_values)}; + return Reichwein::XML::plain_xml(ptree); +} + +// throws on invalid id +void validate_id(const std::string& id) { + if (!id.size()) + throw std::runtime_error("Invalid id (empty)"); + + if (id.size() > 16) + throw std::runtime_error("Invalid id (size > 16)"); + + for (const auto c: id) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'z'))) + throw std::runtime_error("Invalid id char: "s + c); + } +} + +} // namespace + +class session: public std::enable_shared_from_this +{ +public: + using connection = std::shared_ptr>; + + session(ConnectionRegistry& registry, Storage& storage, std::mutex& storage_mutex, boost::asio::ip::tcp::socket socket): + m_registry(registry), + m_storage(storage), + m_storage_mutex(storage_mutex), + m_ws(std::make_shared(std::move(socket))), + m_connection_guard(m_registry, m_ws) + { + } + + ~session() + { + if (m_stats_timer) + m_stats_timer->cancel(); + m_stats_timer = nullptr; + } + + void do_read_handshake() + { + // Set a decorator to change the Server of the handshake + m_ws->set_option(boost::beast::websocket::stream_base::decorator( + [](boost::beast::websocket::response_type& res) + { + res.set(boost::beast::http::field::server, + std::string("Reichwein.IT Whiteboard")); + })); + + boost::beast::http::async_read(m_ws->next_layer(), m_buffer, m_parser, + boost::asio::bind_executor(m_ws->next_layer().get_executor(), boost::beast::bind_front_handler(&session::on_read_handshake, shared_from_this()))); + } + + void on_read_handshake(boost::beast::error_code ec, std::size_t bytes_transferred) + { + boost::ignore_unused(bytes_transferred); + if (ec) { + std::cerr << "Error on session handshake read: " << ec.message() << std::endl; + } else { + do_accept_handshake(); + } + } + + void do_accept_handshake() + { + m_req = m_parser.get(); + + m_ws->async_accept(m_req, boost::beast::bind_front_handler(&session::on_accept_handshake, shared_from_this())); + } + + void on_accept_handshake(boost::beast::error_code ec) + { + if (ec) { + std::cerr << "Error on session handshake accept: " << ec.message() << std::endl; + } else { + do_read(); + } + } + + void do_read() + { + if (m_buffer.size() > 0) { + m_buffer.consume(m_buffer.size()); + } + + m_ws->async_read(m_buffer, boost::beast::bind_front_handler(&session::on_read, shared_from_this())); + } + + void on_read(boost::beast::error_code ec, std::size_t bytes_transferred) + { + boost::ignore_unused(bytes_transferred); + if (ec) { + if (ec != boost::beast::websocket::error::closed) + std::cerr << "Error on session read: " << ec.message() << std::endl; + } else { + do_write(); + } + } + + void do_write() + { + m_ws->text(m_ws->got_text()); + std::string data(boost::asio::buffers_begin(m_buffer.data()), boost::asio::buffers_end(m_buffer.data())); + data = handle_request(data); + if (m_buffer.size() > 0) { + m_buffer.consume(m_buffer.size()); + } + if (data.size() > 0) { + boost::beast::ostream(m_buffer) << data; + m_ws->async_write(m_buffer.data(), + boost::asio::bind_executor(m_ws->next_layer().get_executor(), + boost::beast::bind_front_handler(&session::on_write, shared_from_this()))); + } else { + do_read(); + } + } + + void on_write(boost::beast::error_code ec, std::size_t bytes_transferred) + { + boost::ignore_unused(bytes_transferred); + + if (ec) { + std::cerr << "Error on session write: " << ec.message() << std::endl; + } else { + do_read(); + } + } + + void run() + { + do_read_handshake(); + } + + void on_write_notify(std::shared_ptr data, std::shared_ptr buffer, boost::beast::error_code ec, std::size_t bytes_transferred) + { + boost::ignore_unused(bytes_transferred); + + if (ec) { + std::cerr << "Error on session write notify: " << ec.message() << std::endl; + } + } + + void notify_other_connections_diff(const std::string& id, const Diff& diff) + { + std::for_each(m_registry.begin(id), m_registry.end(id), [&](const connection& ci) + { + if (m_ws != ci) { + pt::ptree ptree {make_ptree({ + {"type", "getdiff"}, + {"revision", std::to_string(m_storage.getRevision(id))}, + {"pos", std::to_string(m_storage.getCursorPos(id)) } + })}; + ptree.put_child("serverinfo.diff", diff.get_structure().get_child("diff")); + auto data{std::make_shared(Reichwein::XML::plain_xml(ptree))}; + auto buffer{std::make_shared(data->data(), data->size())}; + try { + ci->async_write(*buffer, boost::asio::bind_executor(ci->next_layer().get_executor(), + boost::beast::bind_front_handler(&session::on_write_notify, shared_from_this(), data, buffer))); + } catch (const std::exception& ex) { + std::cerr << "Warning: Notify getdiff write for " << ci << " not possible, id " << id << std::endl; + m_registry.dump(); + } + } + }); + } + + void notify_other_connections_pos(const std::string& id) + { + std::for_each(m_registry.begin(id), m_registry.end(id), [&](const connection& ci) + { + if (m_ws != ci) { + auto data{std::make_shared(make_xml({ + {"type", "getpos"}, + {"pos", std::to_string(m_storage.getCursorPos(id)) } + }))}; + auto buffer{std::make_shared(data->data(), data->size())}; + try { + ci->async_write(*buffer, boost::asio::bind_executor(ci->next_layer().get_executor(), + boost::beast::bind_front_handler(&session::on_write_notify, shared_from_this(), data, buffer))); + } catch (const std::exception& ex) { + std::cerr << "Warning: Notify getpos write for " << ci << " not possible, id " << id << std::endl; + m_registry.dump(); + } + } + }); + } + + std::string stats_xml() + { + return make_xml({ + {"type", "stats" }, + {"dbsizegross", std::to_string(m_storage.dbsize_gross()) }, + {"dbsizenet", std::to_string(m_storage.dbsize_net()) }, + {"numberofdocuments", std::to_string(m_storage.getNumberOfDocuments()) }, + {"numberofconnections", std::to_string(m_registry.number_of_connections()) }, + }); + } + + void on_write_stats(std::shared_ptr data, std::shared_ptr buffer, boost::beast::error_code ec, std::size_t bytes_transferred) + { + boost::ignore_unused(bytes_transferred); + + if (ec) { + std::cerr << "Error on session write stats: " << ec.message() << std::endl; + } + } + + void stats_callback(const boost::system::error_code&) + { + if (m_stats_timer) { + auto data{std::make_shared(stats_xml())}; + auto buffer{std::make_shared(data->data(), data->size())}; + m_ws->async_write(*buffer, boost::asio::bind_executor(m_ws->next_layer().get_executor(), + boost::beast::bind_front_handler(&session::on_write_stats, shared_from_this(), data, buffer))); + + m_stats_timer->expires_at(m_stats_timer->expires_at() + boost::asio::chrono::seconds(5)); + m_stats_timer->async_wait(boost::beast::bind_front_handler(&session::stats_callback, this)); + } + } + + void setup_stats_timer() + { + if (!m_stats_timer) { + m_stats_timer = std::make_shared(m_ws->next_layer().get_executor(), boost::asio::chrono::seconds(5)); + m_stats_timer->async_wait(boost::beast::bind_front_handler(&session::stats_callback, this)); + } + } + + std::string handle_request(const std::string& request) + { + try { + std::lock_guard lock(m_storage_mutex); + + pt::ptree xml; + std::istringstream ss{request}; + pt::xml_parser::read_xml(ss, xml); + + std::string command {xml.get("request.command")}; + + if (command == "modify") { + std::string id {xml.get("request.id")}; + validate_id(id); + + int baserev {xml.get("request.baserev")}; + if (baserev != m_storage.getRevision(id)) + return make_xml({{"type", "error"}, {"message", "Bad base revision ("s + std::to_string(baserev) + "). Current: "s + std::to_string(m_storage.getRevision(id)) }}); + + pt::ptree ptree; + ptree.put_child("diff", xml.get_child("request.diff")); + Diff d{ptree}; + if (!d.empty()) { + std::string data {m_storage.getDocument(id)}; + data = d.apply(data); + + m_storage.setDocument(id, data); + m_registry.setId(m_ws, id); + notify_other_connections_diff(id, d); + } + + int pos {xml.get("request.pos")}; + if (m_storage.getCursorPos(id) != pos) { + m_storage.setCursorPos(id, pos); + notify_other_connections_pos(id); + } + return make_xml({{"type", "modify"}, {"revision", std::to_string(m_storage.getRevision(id)) }}); + } else if (command == "cursorpos") { + std::string id {xml.get("request.id")}; + validate_id(id); + int pos {xml.get("request.pos")}; + if (m_storage.getCursorPos(id) != pos) { + m_storage.setCursorPos(id, pos); + notify_other_connections_pos(id); + } + return {}; + } else if (command == "getfile") { + std::string id {xml.get("request.id")}; + validate_id(id); + + std::string filedata; + try { + filedata = m_storage.getDocument(id); + } catch (const std::runtime_error&) { + m_storage.setDocument(id, filedata); + } + + if (filedata.size() > 30000000) + throw std::runtime_error("File too big"); + m_registry.setId(m_ws, id); + + return make_xml({ + {"type", "getfile"}, + {"data", filedata}, + {"revision", std::to_string(m_storage.getRevision(id)) }, + {"pos", std::to_string(m_storage.getCursorPos(id)) } + }); + } else if (command == "getpos") { + std::string id {xml.get("request.id")}; + validate_id(id); + + return make_xml({ + {"type", "getpos"}, + {"pos", std::to_string(m_storage.getCursorPos(id)) } + }); + } else if (command == "newid") { + return make_xml({{"type", "newid"}, {"id", m_storage.generate_id()}}); + } else if (command == "qrcode") { + std::string url{xml.get("request.url")}; + + if (url.size() > 1000) + throw std::runtime_error("URL too big"); + + std::string pngdata {QRCode::getQRCode(url)}; + + return make_xml({{"type", "qrcode"}, {"png", Reichwein::Base64::encode64(pngdata)}}); + } else if (command == "getversion") { + return make_xml({ + {"type", "version"}, + {"version", WHITEBOARD_VERSION } + }); + } else if (command == "getstats") { + setup_stats_timer(); + return stats_xml(); + } else if (command == "pdf") { + std::string id {xml.get("request.id")}; + validate_id(id); + Reichwein::Tempfile mdFilePath{".md"}; + Reichwein::File::setFile(mdFilePath.getPath(), m_storage.getDocument(id)); + Reichwein::Tempfile pdfFilePath{".pdf"}; + int system_result{bp::system("/usr/bin/pandoc", mdFilePath.getPath().generic_string(), "-o", pdfFilePath.getPath().generic_string())}; + if (system_result) + throw std::runtime_error("pandoc returned: "s + std::to_string(system_result)); + std::string pdfdata{Reichwein::File::getFile(pdfFilePath.getPath())}; + return make_xml({{"type", "pdf"}, {"pdf", Reichwein::Base64::encode64(pdfdata)}}); + } else { + throw std::runtime_error("Bad command: "s + command); + } + + } catch (const std::exception& ex) { + return make_xml({{"type", "error"}, {"message", "Message handling error: "s + ex.what()}}); + } + } +private: + ConnectionRegistry& m_registry; + Storage& m_storage; + std::mutex& m_storage_mutex; + connection m_ws; + ConnectionRegistry::RegistryGuard m_connection_guard; + + boost::beast::http::request_parser m_parser; + boost::beast::http::request m_req; + boost::beast::flat_buffer m_buffer; + + std::shared_ptr m_stats_timer{}; +}; + +void Whiteboard::do_accept() +{ + // The new connection gets its own strand + m_acceptor->async_accept(boost::asio::make_strand(*m_ioc), + std::bind(&Whiteboard::on_accept, this, _1, _2)); +} + +void Whiteboard::on_accept(boost::system::error_code ec, boost::asio::ip::tcp::socket socket) +{ + if (ec) { + std::cerr << "Error on accept: " << ec.message() << std::endl; + } else { + if (m_registry.number_of_connections() >= m_config->getMaxConnections()) { + // limit reached + socket.close(); + } else { + std::make_shared(m_registry, *m_storage, m_storage_mutex, std::move(socket))->run(); + } + } + + do_accept(); +} + +// for long running connections, don't timeout them but touch associated ids +// regularly, at cleanup time +void Whiteboard::touch_all_connections() +{ + std::for_each(m_registry.begin(), m_registry.end(), [&](const std::pair& i) + { + m_storage->touchDocument(i.second); + }); +} + +// the actual main() for testability +int Whiteboard::run(int argc, char* argv[]) +{ + try { + bool flag_cleanup{}; + fs::path configFile; + + if (argc == 2) { + if (argv[1] == "-h"s || argv[1] == "-?"s) { + usage(); + exit(0); + } else if (argv[1] == "-C"s) { + flag_cleanup = true; + } + } else if (argc == 3) { + if (argv[1] == "-c"s) { + configFile = argv[2]; + } + } + + if (configFile.empty()) + m_config = std::make_unique(); + else + m_config = std::make_unique(configFile); + + m_storage = std::make_unique(*m_config); + + if (flag_cleanup) { + m_storage->cleanup(); + exit(0); + } + + QRCode::init(); + + auto const address = boost::asio::ip::make_address(m_config->getListenAddress()); + auto const port = static_cast(m_config->getListenPort()); + + // The io_context is required for all I/O + m_ioc = std::make_unique(m_config->getThreads()); + + // for now, just terminate on SIGINT, SIGHUP and SIGTERM + boost::asio::signal_set signals(*m_ioc, SIGINT, SIGTERM, SIGHUP); + signals.async_wait([&](const boost::system::error_code& error, int signal_number){ + std::cout << "Terminating via signal " << signal_number << std::endl; + m_ioc->stop(); + }); + + // Storage cleanup once a day + boost::asio::steady_timer storage_cleanup_timer(*m_ioc, boost::asio::chrono::hours(24)); + std::function storage_cleanup_callback = + [&](const boost::system::error_code& error){ + std::lock_guard lock(m_storage_mutex); + if (!m_storage) + throw std::runtime_error("Storage not initialized"); + touch_all_connections(); + m_storage->cleanup(); + storage_cleanup_timer.expires_at(storage_cleanup_timer.expires_at() + boost::asio::chrono::hours(24)); + storage_cleanup_timer.async_wait(storage_cleanup_callback); + }; + storage_cleanup_timer.async_wait(storage_cleanup_callback); + + // The acceptor receives incoming connections + m_acceptor = std::make_unique(*m_ioc, boost::asio::ip::tcp::endpoint{address, port}); + + do_accept(); + + // Run the I/O service on the requested number of threads + std::vector v; + v.reserve(m_config->getThreads() - 1); + for (auto i = m_config->getThreads() - 1; i > 0; --i) { + v.emplace_back( + [&] + { + m_ioc->run(); + }); + } + m_ioc->run(); + + for (auto& t: v) { + t.join(); + } + + } catch (const std::exception& ex) { + std::cerr << "Error: " << ex.what() << std::endl; + } + + return 0; +} + diff --git a/whiteboard.h b/whiteboard.h new file mode 100644 index 0000000..53036ac --- /dev/null +++ b/whiteboard.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "diff.h" +#include "config.h" +#include "connectionregistry.h" +#include "storage.h" + +class Whiteboard +{ +public: + Whiteboard(); + int run(int argc, char* argv[]); + +private: + std::unique_ptr m_config; + std::unique_ptr m_storage; + std::mutex m_storage_mutex; + + ConnectionRegistry m_registry; + + std::unique_ptr m_ioc; + std::unique_ptr m_acceptor; + + void do_accept(); + void on_accept(boost::system::error_code ec, boost::asio::ip::tcp::socket socket); + void touch_all_connections(); +}; + -- cgit v1.2.3