diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1871115..56dbd6f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,10 @@ jobs: build: runs-on: ubuntu-latest + strategy: + matrix: + arch: [amd64, arm64, armhf] + steps: - uses: actions/checkout@v3 with: @@ -26,7 +30,9 @@ jobs: run: pip3 install --user --upgrade clickable-ut - name: Build - run: clickable build --output . + env: + ARCH: ${{ matrix.arch }} + run: clickable build --arch $ARCH --output . - name: Generate variables id: filename @@ -35,7 +41,7 @@ jobs: run: | GIT_VERSION="$(git describe --tags --always)" echo "version=$GIT_VERSION" >> $GITHUB_OUTPUT - echo "zipname=annotate-$GIT_VERSION" >> $GITHUB_OUTPUT + echo "zipname=annotate-$ARCH-$GIT_VERSION" >> $GITHUB_OUTPUT - name: Upload package uses: actions/upload-artifact@v2 diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..923cf5a --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,19 @@ +project(annotate) +cmake_minimum_required(VERSION 3.0) + +set(CMAKE_CXX_STANDARD 20) + +add_executable(webserver webserver/webserver.cpp) +add_executable(genkey webserver/genkey.cpp) + +install(TARGETS webserver genkey DESTINATION bin) +install(DIRECTORY www assets DESTINATION .) +install(FILES annotate.apparmor + annotate.desktop + annotate-contenthub.json + LICENSE + manifest.json + README.md + DESTINATION .) +install(PROGRAMS run.sh DESTINATION .) + diff --git a/annotate.apparmor b/annotate.apparmor index c701d32..03bd984 100644 --- a/annotate.apparmor +++ b/annotate.apparmor @@ -1,9 +1,10 @@ { - "template": "unconfined", + "template": "ubuntu-webapp", "policy_groups": [ "webview", "networking", - "content_exchange" + "content_exchange", + "content_exchange_source" ], "policy_version": 20.04 } diff --git a/clickable.yaml b/clickable.yaml index dc50109..a4e3bce 100644 --- a/clickable.yaml +++ b/clickable.yaml @@ -1,5 +1,4 @@ -clickable_minimum_required: 7.1.2 -builder: pure +clickable_minimum_required: 8.0.0 +builder: cmake kill: webapp-container* framework: ubuntu-sdk-20.04 -skip_review: true diff --git a/manifest.json b/manifest.json index c1a9fc5..c0804d9 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "annotate.semphris", "description": "View and annotate your PDF files", - "architecture": "all", + "architecture": "@CLICK_ARCH@", "title": "Annotate", "hooks": { "annotate": { @@ -10,7 +10,7 @@ "content-hub": "annotate-contenthub.json" } }, - "version": "0.1.0", + "version": "0.1.1", "maintainer": "Semphris ", "framework" : "ubuntu-sdk-20.04" } diff --git a/run.sh b/run.sh index e40f489..bae37cc 100755 --- a/run.sh +++ b/run.sh @@ -1,9 +1,7 @@ #!/bin/bash -cd www - # The key only provides weak security. The key can be obtained with `ps -aux`. It's mostly meant to avoid mistakes. -export ANNOTATE_KEY="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 64)" +export ANNOTATE_KEY="$(./bin/genkey)" -python3 -m http.server --cgi --bind 127.0.0.1 9283 & -webapp-container --app-id="annotate.semphris" http://localhost:9283/?key=$ANNOTATE_KEY +./bin/webserver & +webapp-container --app-id="annotate.semphris" http://localhost:9283/index.html?key=$ANNOTATE_KEY diff --git a/webserver/genkey.cpp b/webserver/genkey.cpp new file mode 100644 index 0000000..3672e35 --- /dev/null +++ b/webserver/genkey.cpp @@ -0,0 +1,20 @@ +#include +#include +#include + +int main() { + char buffer[65]; + char *ptr = buffer; + + while (ptr != buffer + 64) { + getrandom(ptr, 1, 0); + char c = *ptr; + if (c >= 'A' && c <= 'Z' || c >= 'a' && c <= 'z' || c >= '0' && c <= '9') { + ptr++; + } + } + *ptr = '\0'; + + std::cout << buffer << std::flush; + return 0; +} diff --git a/webserver/webserver.cpp b/webserver/webserver.cpp new file mode 100644 index 0000000..ea9fa87 --- /dev/null +++ b/webserver/webserver.cpp @@ -0,0 +1,436 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#define HUB_PATH "/.cache/annotate.semphris/HubIncoming/" +#define LOCAL_PATH "/.local/share/annotate.semphris/pdf_documents/" + +typedef enum { + REQ_METHOD, + REQ_URL, + REQ_HTTPVER, + REQ_HEADER, + REQ_BODY, + REQ_DONE +} ReqParseStep; + +struct HTTPRequest +{ + std::string method; + std::string url; + std::string http_version; + std::unordered_map headers; + std::string body; + size_t total_body_length = 0; + + std::unordered_map query; + + std::string buf; + size_t pos = 0; + ReqParseStep step = REQ_METHOD; +}; + +static const char *b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +std::string SCRIPT_base64(std::string in) +{ + std::string out; + for (size_t i = 0; i < in.size(); i += 3) { + char in_1 = in[i], + in_2 = (i + 1 < in.size()) ? in[i + 1] : '\0', + in_3 = (i + 2 < in.size()) ? in[i + 2] : '\0'; + char out_1 = (in_1 & 07770) >> 2, + out_2 = (in_1 & 00007) << 4 | (in_2 & 07700) >> 4, + out_3 = (in_2 & 00077) << 2 | (in_3 & 07000) >> 6, + out_4 = (in_3 & 00777); + + out += std::string(1, b64[out_1]); + out += std::string(1, b64[out_2]); + out += std::string(1, (i + 1 < in.size()) ? b64[out_3] : '='); + out += std::string(1, (i + 2 < in.size()) ? b64[out_4] : '='); + } + return out; +} + +std::string HOME(const std::string& s) +{ + std::string home = getenv("HOME"); + return home + s; +} +/* +void SCRIPT_clear_incoming() +{ + const std::string path = HOME(HUB_PATH); + for (const auto& dir : std::filesystem::recursive_directory_iterator(path)) { + if (std::filesystem::is_regular_file(dir)) { + std::string filename = dir.path().filename(); + std::regex reg{"[^a-zA-Z0-9_-]+"}; + std::string cleanname = std::regex_replace(filename, reg, "_"); + + size_t n = -1; + while (std::filesystem::exists({ HOME(LOCAL_PATH) + ((n < 0) ? "" : std::to_string(n)) })) { + n++; + } + + if (n >= 0) { + cleanname += std::to_string(n); + } + + std::ifstream in{dir.path().string()}; + std::ofstream out{HOME(LOCAL_PATH) + cleanname}; + std::string in_contents(std::istreambuf_iterator(in), {}); + + out << SCRIPT_base64(in_contents) << std::flush; + std::filesystem::remove(dir.path()); + } + } +} +*/ +static std::unordered_map g_static_files; +static std::unordered_map g_scripts = { + { "/cgi-bin/list.sh", [] (const HTTPRequest& req) -> std::string { + if (!req.query.contains("key") || req.query.at("key") != getenv("ANNOTATE_KEY")) { + return "HTTP/1.1 403 Forbidden\r\n\r\n"; + } + + // SCRIPT_clear_incoming(); + + std::string list; + + const std::string path = HOME(LOCAL_PATH); + for (const auto& dir : std::filesystem::recursive_directory_iterator(path)) { + if (std::filesystem::is_regular_file(dir)) { + list += dir.path().filename(); + list += "\n"; + } + } + + std::stringstream res; + res << "HTTP/1.1 200 OK\r\n" + << "Content-Type: text/plain\r\n" + << "Content-Length: " << list.size() << "\r\n" + << "\r\n" << list; + return res.str(); + }}, + { "/cgi-bin/delete.sh", [] (const HTTPRequest& req) -> std::string { + if (!req.query.contains("key") || req.query.at("key") != getenv("ANNOTATE_KEY")) { + return "HTTP/1.1 403 Forbidden\r\n\r\n"; + } + + if (!req.query.contains("file")) { + return "HTTP/1.1 400 Bad request\r\n\r\n"; + } + + const std::string path = HOME(LOCAL_PATH) + req.query.at("file"); + std::filesystem::remove({path}); + + return "HTTP/1.1 200 OK\r\n\r\n"; + }}, + { "/cgi-bin/get.sh", [] (const HTTPRequest& req) -> std::string { + if (!req.query.contains("key") || req.query.at("key") != getenv("ANNOTATE_KEY")) { + return "HTTP/1.1 403 Forbidden\r\n\r\n"; + } + + if (!req.query.contains("file")) { + return "HTTP/1.1 400 Bad request\r\n\r\n"; + } + + const std::string path = HOME(LOCAL_PATH) + req.query.at("file"); + + std::ifstream in{path}; + std::string in_contents(std::istreambuf_iterator(in), {}); + + std::stringstream res; + res << "HTTP/1.1 200 OK\r\n" + << "Content-Type: text/plain\r\n" + << "Content-Length: " << in_contents.size() << "\r\n" + << "\r\n" << in_contents; + return res.str(); + }}, + { "/cgi-bin/rename.sh", [] (const HTTPRequest& req) -> std::string { + if (!req.query.contains("key") || req.query.at("key") != getenv("ANNOTATE_KEY")) { + return "HTTP/1.1 403 Forbidden\r\n\r\n"; + } + + if (!req.query.contains("file") || !req.query.contains("newname")) { + return "HTTP/1.1 400 Bad request\r\n\r\n"; + } + + const std::string path = HOME(LOCAL_PATH) + req.query.at("file"); + const std::string newpath = HOME(LOCAL_PATH) + req.query.at("newname"); + std::filesystem::rename({path}, {newpath}); + + return "HTTP/1.1 200 OK\r\n\r\n"; + }}, + { "/cgi-bin/save.sh", [] (const HTTPRequest& req) -> std::string { + if (!req.query.contains("key") || req.query.at("key") != getenv("ANNOTATE_KEY")) { + return "HTTP/1.1 403 Forbidden\r\n\r\n"; + } + + if (!req.query.contains("file")) { + return "HTTP/1.1 400 Bad request\r\n\r\n"; + } + + const std::string path = HOME(LOCAL_PATH) + req.query.at("file"); + std::ofstream out{path}; + out << req.body << std::flush; + + return "HTTP/1.1 200 OK\r\n\r\n"; + }}, +}; + +void init_files() +{ + static const std::string path = "./www/"; + + for (const auto& dir : std::filesystem::recursive_directory_iterator(path)) { + if (std::filesystem::is_regular_file(dir)) { + std::cout << "Serving file: " << dir << "\n"; + + std::ifstream ifs{dir.path().string()}; + std::string contents(std::istreambuf_iterator(ifs), {}); + + g_static_files[dir.path().string().substr(5)] = contents; + } + } +} + +bool resume_parse(HTTPRequest& req, char *buf, size_t len) +{ + req.buf += std::string{buf, len}; + bool done = false; + + while (!done) { + switch(req.step) { + case REQ_METHOD: + { + size_t pos; + if ((pos = req.buf.find(" ")) != std::string::npos) { + req.method = req.buf.substr(0, pos); + req.buf = req.buf.substr(pos + 1); + req.step = REQ_URL; + } else { + done = true; + } + } + break; + + case REQ_URL: + { + size_t pos; + if ((pos = req.buf.find(" ")) != std::string::npos) { + req.url = req.buf.substr(0, pos); + req.buf = req.buf.substr(pos + 1); + req.step = REQ_HTTPVER; + } else { + done = true; + } + } + break; + + case REQ_HTTPVER: + { + size_t pos; + if ((pos = req.buf.find("\r\n")) != std::string::npos) { + req.http_version = req.buf.substr(0, pos); + req.buf = req.buf.substr(pos + 2); + req.step = REQ_HEADER; + } else { + done = true; + } + } + break; + + case REQ_HEADER: + { + size_t pos; + if ((pos = req.buf.find("\r\n")) != std::string::npos) { + if (pos == 0) { + try { + std::string content_length = req.headers.at("Content-Length"); + req.total_body_length = std::stoi(content_length); + req.step = REQ_BODY; + req.buf = req.buf.substr(2); + } catch(const std::out_of_range&) { + req.step = REQ_DONE; + req.buf = req.buf.substr(2); + return true; + } + } else { + std::string header = req.buf.substr(0, pos); + size_t pos2 = header.find(": "); + req.headers[header.substr(0, pos2)] = header.substr(pos2 + 2); + req.buf = req.buf.substr(pos + 2); + } + } else { + done = true; + } + } + break; + + case REQ_BODY: + { + if (req.buf.size() >= req.total_body_length) { + req.body = req.buf.substr(0, req.total_body_length); + req.buf = req.buf.substr(req.total_body_length); + req.step = REQ_DONE; + return true; + } else { + done = true; + } + } + break; + + case REQ_DONE: + return true; + } + } + + return false; +} + +std::unordered_map parse_query(std::string s) { + std::unordered_map query; + std::string delimiter = "&"; + + size_t pos = 0; + std::string param; + while ((pos = s.find("&")) != std::string::npos) { + param = s.substr(0, pos); + + size_t pos2 = param.find("="); + std::string param_name = param.substr(0, pos2); + std::string param_val = param.substr(pos2 + 1); + query[param_name] = param_val; + + s = s.substr(pos + 1); + } + + param = s.substr(0, pos); + size_t pos2 = param.find("="); + std::string param_name = param.substr(0, pos2); + std::string param_val = param.substr(pos2 + 1); + query[param_name] = param_val; + + return query; +} + +std::string get_mime(std::string filename) +{ + if (filename.ends_with(".html")) { + return "text/html"; + } else if (filename.ends_with(".js") || filename.ends_with(".mjs")) { + return "application/javascript"; + } else if (filename.ends_with(".svg")) { + return "image/svg+xml"; + } else { + return "text/plain"; + } +} + +#define FAIL(msg) { \ + std::cerr << msg << ": " << strerror(errno) << "\n"; \ + return 1; \ +} + +int main() +{ + init_files(); + std::filesystem::create_directories(HOME(LOCAL_PATH)); + std::filesystem::create_directories(HOME(HUB_PATH)); + + int sockfd, connfd; + socklen_t len; + struct sockaddr_in servaddr, cli; + + sockfd = socket(AF_INET, SOCK_STREAM, 0); + if (sockfd < 0) { + FAIL("Couldn't create socket"); + } + + bzero(&servaddr, sizeof(servaddr)); + + int yes = 1; + if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(int)) == -1) { + FAIL("Couldn't set socket SO_REUSEADDR"); + } + + servaddr.sin_family = AF_INET; + servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); + servaddr.sin_port = htons(9283); + + if (bind(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr))) { + FAIL("Couldn't bind socket"); + } + + // Now server is ready to listen and verification + if ((listen(sockfd, 5)) != 0) { + FAIL("Couldn't listen"); + } + + len = sizeof(cli); + + for (;;) { + char buffer[16384]; + ssize_t read_len; + HTTPRequest req; + + connfd = accept(sockfd, (struct sockaddr*)&cli, &len); + if (connfd < 0) { + FAIL("Couldn't accept"); + } + + do { + read_len = read(connfd, buffer, sizeof(buffer)); + if (read_len < 0) { + FAIL("Couldn't read"); + } + } while (!resume_parse(req, buffer, read_len)); + + size_t qpos = req.url.find("?"); + std::string realname = req.url.substr(0, qpos); + + std::cout << req.method << " " << realname << std::endl; + + if (g_scripts.contains(realname)) { + req.query = parse_query(req.url.substr(qpos + 1)); + const auto res = g_scripts.at(realname)(req); + write(connfd, res.data(), res.size()); + } else if (g_static_files.contains(realname)) { + std::string contents = g_static_files.at(realname); + std::stringstream res; + res << "HTTP/1.1 200 OK\r\n" + << "Content-Type: " << get_mime(realname) << "\r\n" + << "Content-Length: " << contents.size() << "\r\n" + << "\r\n" + << contents; + std::string response = res.str(); + + write(connfd, response.data(), response.size()); + } else { + std::stringstream res; + res << "HTTP/1.1 404 Not found\r\n\r\n"; + std::string response = res.str(); + write(connfd, response.data(), response.size()); + } + + close(connfd); + } + + close(sockfd); + + return 0; +} diff --git a/www/cgi-bin/ack.sh b/www/cgi-bin/ack.sh deleted file mode 100755 index 4d02a06..0000000 --- a/www/cgi-bin/ack.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - - -KEY="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^key=.*" | head -1 | cut -d = -f 2- | tr -d '\n')" -if [ "$KEY" != "$ANNOTATE_KEY" ]; then - exit 1 -fi - -echo "Content-type: text/plain" -echo - -rm -r ~/.cache/annotate.semphris/HubIncoming/* - -echo "Acked" diff --git a/www/cgi-bin/delete.sh b/www/cgi-bin/delete.sh deleted file mode 100755 index b4d2f77..0000000 --- a/www/cgi-bin/delete.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - - -KEY="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^key=.*" | head -1 | cut -d = -f 2- | tr -d '\n')" -if [ "$KEY" != "$ANNOTATE_KEY" ]; then - exit 1 -fi - -echo "Content-type: text/plain" -echo - -FILENAME="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^file=.*" | head -1 | cut -d = -f 2- | tr -d '\n' | tr -cs 'a-zA-Z0-9_-' '_')" - -if [ "${REQUEST_METHOD^^}" = "GET" ] && [ -f "$HOME/.local/share/annotate/$FILENAME" ]; then - rm $HOME/.local/share/annotate/$FILENAME -fi diff --git a/www/cgi-bin/get.sh b/www/cgi-bin/get.sh deleted file mode 100755 index 564239f..0000000 --- a/www/cgi-bin/get.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash - - -KEY="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^key=.*" | head -1 | cut -d = -f 2- | tr -d '\n')" -if [ "$KEY" != "$ANNOTATE_KEY" ]; then - exit 1 -fi - -echo "Content-type: text/plain" -echo - -FILENAME="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^file=.*" | head -1 | cut -d = -f 2- | tr -d '\n' | tr -cs 'a-zA-Z0-9_-' '_')" - -if [ "${REQUEST_METHOD^^}" = "GET" ] && [ ! "$FILENAME" = "" ] && [ ! "$FILENAME" = "cgi-bin" ]; then - cat $HOME/.local/share/annotate/$FILENAME -fi diff --git a/www/cgi-bin/list.sh b/www/cgi-bin/list.sh deleted file mode 100755 index 6617e3f..0000000 --- a/www/cgi-bin/list.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash - - -KEY="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^key=.*" | head -1 | cut -d = -f 2- | tr -d '\n')" -if [ "$KEY" != "$ANNOTATE_KEY" ]; then - exit 1 -fi - -echo "Content-type: text/plain" -echo - -# Move files from the Content Hub to local files -find $HOME/.cache/annotate.semphris/HubIncoming/* -type f | while read i; do - cleanname="$(basename "$i" | tr -cs '\n[a-zA-Z0-9]_-' '_' | tr -d '\n')" - - n= - while [ -f $HOME/.local/share/annotate/"${cleanname}$n" ]; do - n=$(($n + 1)); - done - cleanname="${cleanname}$n" - - base64 -w 0 "$i" > $HOME/.local/share/annotate/$cleanname -done - -rm -r ~/.cache/annotate.semphris/HubIncoming/* - -# Return the list of files -if [ "${REQUEST_METHOD^^}" = "GET" ]; then - mkdir -p $HOME/.local/share/annotate/ - cd $HOME/.local/share/annotate/ - ls -t -fi diff --git a/www/cgi-bin/rename.sh b/www/cgi-bin/rename.sh deleted file mode 100755 index 28df6cb..0000000 --- a/www/cgi-bin/rename.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - - -KEY="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^key=.*" | head -1 | cut -d = -f 2- | tr -d '\n')" -if [ "$KEY" != "$ANNOTATE_KEY" ]; then - exit 1 -fi - -echo "Content-type: text/plain" -echo - -FILENAME="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^file=.*" | head -1 | cut -d = -f 2- | tr -d '\n' | tr -cs 'a-zA-Z0-9_-' '_')" -NEWFILENAME="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^newname=.*" | head -1 | cut -d = -f 2- | tr -d '\n' | tr -cs 'a-zA-Z0-9_-' '_')" - -if [ "${REQUEST_METHOD^^}" = "GET" ] && [ ! "$FILENAME" = "" ] && [ ! "$FILENAME" = "cgi-bin" ] && [ ! "$NEWFILENAME" = "" ] && [ ! "$NEWFILENAME" = "cgi-bin" ] && [ -f "$HOME/.local/share/annotate/$FILENAME" ] && [ ! -f "$HOME/.local/share/annotate/$NEWFILENAME" ]; then - mv $HOME/.local/share/annotate/$FILENAME $HOME/.local/share/annotate/$NEWFILENAME -fi diff --git a/www/cgi-bin/save.sh b/www/cgi-bin/save.sh deleted file mode 100755 index b71ab02..0000000 --- a/www/cgi-bin/save.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - - -KEY="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^key=.*" | head -1 | cut -d = -f 2- | tr -d '\n')" -if [ "$KEY" != "$ANNOTATE_KEY" ]; then - exit 1 -fi - -echo "Content-type: text/plain" -echo "Content-Length: 5" -echo - -FILENAME="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^file=.*" | head -1 | cut -d = -f 2- | tr -d '\n' | tr -cs 'a-zA-Z0-9_-' '_')" -LENGTH="$(echo "$QUERY_STRING" | tr '&' '\n' | grep -E "^length=.*" | head -1 | cut -d = -f 2- | tr -dc '0-9')" - -if [ "${REQUEST_METHOD^^}" = "POST" ] && [ ! "$FILENAME" = "" ] && [ ! "$FILENAME" = "cgi-bin" ]; then - mkdir -p $HOME/.local/share/annotate/ - # FIXME: `cat` never quits, probably stdin isn't closed by python after the body is received - head -c "$LENGTH" > $HOME/.local/share/annotate/$FILENAME -fi - -echo "Saved"