commit f3df9dadc10585628c95d6b072eae9f61299cc75 Author: blank X Date: Mon Jan 17 15:52:25 2022 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..76fad35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.o +.flavor +.objs +imgurxp.so +assets.h +cert +dh2048.pem +config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..d36d3c3 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# ImgurXP + +Alternative Imgur frontend written in C++. It is recommended to have a reverse +proxy point to the server with these headers: +``` +Strict-Transport-Security: max-age=31536000; includeSubDomains; preload +Content-Security-Policy: default-src 'none'; img-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; sandbox +X-Content-Type-Options: nosniff +``` diff --git a/assets/404.html b/assets/404.html new file mode 100644 index 0000000..836936e --- /dev/null +++ b/assets/404.html @@ -0,0 +1,12 @@ + + + + + + ImgurXP + + + +
404: page not found
+ + diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..be4207b --- /dev/null +++ b/assets/index.html @@ -0,0 +1,17 @@ + + + + + + ImgurXP + + + + +

To view an image or album, simply replace imgur.com with imgurxp.zangestsu.animekaizoku.com. For example: https://imgur.com/a/jBrIW gets turned into https://imgurxp.zangetsu.animekaizoku.com/a/jBrIW

+ + diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..0e8a725 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,23 @@ +body { + background-color: black; + color: white; + text-align: center; +} + +div { + border: 1px solid #fcc; + background: #fee; + padding: 0.5em 1em 0.5em 1em; + color: black; +} + +div#header { + border: 1px solid #008; + background: #eef; +} + +img, video { + max-width: 100%; + height: auto; + object-fit: contain; +} diff --git a/conf/build.conf b/conf/build.conf new file mode 100644 index 0000000..2d8b88c --- /dev/null +++ b/conf/build.conf @@ -0,0 +1,40 @@ +# imgurxp build config +# You can switch flavors using: kodev flavor [newflavor] + +# Set to yes if you wish to produce a single binary instead +# of a dynamic library. If you set this to yes you must also +# set kore_source together with kore_flavor. +#single_binary=yes +#kore_source=/usr/share/kore +#kore_flavor=TASKS=1 CURL=1 DEBUG=1 + +# The flags below are shared between flavors +cflags=-Wall -Wmissing-declarations -Wshadow +cflags=-Wstrict-prototypes -Wmissing-prototypes +cflags=-Wpointer-arith -Wcast-qual -Wsign-compare +#cflags=-fsanitize=address + +cxxflags=-Wall -Wmissing-declarations -Wshadow +cxxflags=-Wpointer-arith -Wcast-qual -Wsign-compare +#cxxflags=-fsanitize=address + +ldflags=-lhiredis +#ldflags=-fsanitize=address + +# Mime types for assets served via the builtin asset_serve_* +#mime_add=txt:text/plain; charset=utf-8 +#mime_add=png:image/png +mime_add=css:text/css; charset=utf-8 +mime_add=html:text/html; charset=utf-8 + +dev { + # These flags are added to the shared ones when + # you build the "dev" flavor. + cflags=-g + cxxflags=-g +} + +#prod { +# You can specify additional flags here which are only +# included if you build with the "prod" flavor. +#} diff --git a/conf/imgurxp.conf b/conf/imgurxp.conf new file mode 100644 index 0000000..b316178 --- /dev/null +++ b/conf/imgurxp.conf @@ -0,0 +1,21 @@ +# imgurxp configuration + +server http { + bind 127.0.0.1 1112 + tls no +} + +load ./imgurxp.so + +domain * { + attach http + + route / asset_serve_index_html + route /style.css asset_serve_style_css + route ^/a/[a-zA-Z0-9]{1,40}$ album_or_image + route ^/album/[a-zA-Z0-9]{1,40}$ album_or_image + route ^/gallery/[a-zA-Z0-9]{1,40}$ album_or_image + route ^/[a-zA-Z0-9]{1,40}$ album_or_image + route ^/proxy/[a-zA-Z0-9]{1,40}\.[a-z]{3,4}$ proxy_image + route ^.*$ not_found +} diff --git a/example_config.json b/example_config.json new file mode 100644 index 0000000..53816ea --- /dev/null +++ b/example_config.json @@ -0,0 +1,5 @@ +{ + "redis_host": "127.0.0.1", + "redis_port": 6379, + "redis_password": "gay" +} diff --git a/src/404.cpp b/src/404.cpp new file mode 100644 index 0000000..00da2b6 --- /dev/null +++ b/src/404.cpp @@ -0,0 +1,9 @@ +#include +#include +#include "assets.h" +#include "404.hpp" + +int not_found(struct http_request* req) { + http_response(req, 404, asset_404_html, asset_len_404_html); + return KORE_RESULT_OK; +} diff --git a/src/404.hpp b/src/404.hpp new file mode 100644 index 0000000..c0f395d --- /dev/null +++ b/src/404.hpp @@ -0,0 +1,6 @@ +#include +#include + +extern "C" { + int not_found(struct http_request*); +} diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..196e8fd --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,81 @@ +#include +#include +#include +#include "config.hpp" + +extern "C" { + void kore_parent_configure(int argc, char* argv[]); + void kore_worker_configure(); + void kore_worker_teardown(); +} + +void kore_parent_configure(int argc, char* argv[]) { + std::ifstream config_fstream("config.json"); + std::string config_str; + // definitely could be better but i'm not spending more hours on this + char c; + while (config_fstream.get(c)) { + config_str.push_back(c); + } + config_fstream.close(); + if (config_fstream.fail() && !config_fstream.eof()) { + kore_log(LOG_NOTICE, "fail bit set when reading config"); + exit(1); + } + kore_json json; + kore_json_init(&json, config_str.c_str(), config_str.length()); + if (kore_json_parse(&json) != KORE_RESULT_OK) { + kore_log(LOG_NOTICE, "failed to parse json: %s", kore_json_strerror(&json)); + exit(1); + } + kore_json_item* redis_host_json = kore_json_find_string(json.root, "redis_host"); + kore_json_item* redis_port_json = kore_json_find_integer_u64(json.root, "redis_port"); + kore_json_item* redis_password_json = kore_json_find_string(json.root, "redis_password"); + if (redis_host_json && redis_port_json && *redis_host_json->data.string && redis_port_json->data.s64) { + config.redis_host = redis_host_json->data.string; + config.redis_port = redis_port_json->data.s64; + } + if (redis_password_json && *redis_password_json->data.string) { + config.redis_password = redis_password_json->data.string; + } +} + +void kore_worker_configure() { + if (config.redis_host && config.redis_port) { + kore_log(LOG_NOTICE, "connecting to redis instance at %s:%d", (*config.redis_host).c_str(), (*config.redis_port)); + struct timeval timeout = {1, 0}; + redis = redisConnectWithTimeout((*config.redis_host).c_str(), (*config.redis_port), timeout); + if (!redis || redis->err) { + if (redis) { + kore_log(LOG_NOTICE, "failed to connect to redis: %s", redis->errstr); + redisFree(redis); + redis = nullptr; + } else { + kore_log(LOG_NOTICE, "failed to connect to redis"); + } + kore_shutdown(); + exit(1); + } + if (config.redis_password) { + redisReply* reply = (redisReply*)redisCommand(redis, "AUTH %s", (*config.redis_password).c_str()); + if (!reply || reply->type == REDIS_REPLY_ERROR) { + if (!reply) { + kore_log(LOG_NOTICE, "received nullptr while authenticating to redis"); + } else { + kore_log(LOG_NOTICE, "received error while authenticating to redis: %s", reply->str); + freeReplyObject(reply); + } + redisFree(redis); + redis = nullptr; + kore_shutdown(); + exit(1); + } + } + } +} + +void kore_worker_teardown() { + if (redis) { + redisFree(redis); + } +} diff --git a/src/config.hpp b/src/config.hpp new file mode 100644 index 0000000..2c210e5 --- /dev/null +++ b/src/config.hpp @@ -0,0 +1,14 @@ +#include +#include +#include +#include + +typedef struct { + std::optional redis_host; + std::optional redis_port; + std::optional redis_password; +} Config; + +inline Config config; +inline redisContext* redis = nullptr; +inline std::mutex redis_mutex; diff --git a/src/imgurxp.cpp b/src/imgurxp.cpp new file mode 100644 index 0000000..f109def --- /dev/null +++ b/src/imgurxp.cpp @@ -0,0 +1,231 @@ +#include +#include +#include +#include +#include +#include +#include "utils.hpp" +#include "config.hpp" + +extern "C" { + int album_or_image(struct http_request*); + static int album_or_image_start(struct http_request*); + static int album_or_image_end(struct http_request*); +} + +static struct http_state states[] = { + KORE_HTTP_STATE(album_or_image_start), + KORE_HTTP_STATE(album_or_image_end) +}; + +int album_or_image(struct http_request* req) { + return http_state_run(states, 2, req); +} + +static bool send_hget_and_http_resp(struct http_request* req, const char* id, bool is_album) { + DeterminedKeys keys = determine_keys(id, is_album); + redis_mutex.lock(); + redisReply* reply = (redisReply*)redisCommand(redis, "HGET %s %s", keys.key.c_str(), keys.field.c_str()); + if (!reply) { + redis_mutex.unlock(); + kore_log(LOG_NOTICE, "received nullptr while sending HGET to redis"); + return false; + } + if (reply->type == REDIS_REPLY_ERROR) { + kore_log(LOG_NOTICE, "received error while sending HGET to redis: %s", reply->str); + freeReplyObject(reply); + redis_mutex.unlock(); + return false; + } + if (reply->type == REDIS_REPLY_NIL) { + freeReplyObject(reply); + redis_mutex.unlock(); + return false; + } + if (!reply->len) { + freeReplyObject(reply); + redis_mutex.unlock(); + char* error = "404: media not found"; + if (is_album) { + error = "404: album not found"; + } + std::string error_page = build_error_page(error); + http_response(req, 404, error_page.c_str(), error_page.length()); + return true; + } + Album album = {false, std::nullopt, {}}; + std::optional error = unpack_album_from_string(&album, reply->str); + freeReplyObject(reply); + redis_mutex.unlock(); + if (error) { + kore_log(LOG_NOTICE, "received error while parsing packed album: %s", (*error).c_str()); + return false; + } + send_album_page(req, std::move(album)); + return true; +} + +static int album_or_image_start(struct http_request* req) { + const char* id = &(req->path[1]); + bool is_album = false; + const char* slash_in_path = strchr(id, '/'); + if (slash_in_path) { + id = &(slash_in_path[1]); + is_album = true; + } + if (redis) { + if (send_hget_and_http_resp(req, id, is_album)) { + return HTTP_STATE_COMPLETE; + } + } + std::string api_url = "https://api.imgur.com/post/v1/"; + if (is_album) { + api_url.append("albums/"); + } else { + api_url.append("media/"); + } + api_url.append(id); + api_url.append("?client_id=546c25a59c58ad7&include=media"); + struct kore_curl* client = (kore_curl*)http_state_create(req, sizeof(*client), NULL); + if (!kore_curl_init(client, api_url.c_str(), KORE_CURL_ASYNC)) { + http_state_cleanup(req); + kore_log(LOG_NOTICE, "failed to initialize curl client"); + std::string error_page = build_error_page("Failed to initialize curl client"); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + // handle compressed responses automatically + curl_easy_setopt(client->handle, CURLOPT_ACCEPT_ENCODING, ""); + kore_curl_http_setup(client, HTTP_METHOD_GET, NULL, 0); + kore_curl_bind_request(client, req); + kore_curl_run(client); + req->fsm_state = 1; + return HTTP_STATE_RETRY; +} + +static void send_hset(const char* id, bool is_album, const char* packed) { + DeterminedKeys keys = determine_keys(id, is_album); + redis_mutex.lock(); + redisReply* reply = (redisReply*)redisCommand(redis, "HSET %s %s %s", keys.key.c_str(), keys.field.c_str(), packed); + if (!reply) { + redis_mutex.unlock(); + kore_log(LOG_NOTICE, "received nullptr while sending HSET to redis"); + return; + } + if (reply->type == REDIS_REPLY_ERROR) { + kore_log(LOG_NOTICE, "received error while sending HSET to redis: %s", reply->str); + freeReplyObject(reply); + redis_mutex.unlock(); + return; + } + freeReplyObject(reply); + redis_mutex.unlock(); + return; +} + +static int album_or_image_end(struct http_request* req) { + const char* id = &(req->path[1]); + bool is_album = false; + const char* slash_in_path = strchr(id, '/'); + if (slash_in_path) { + id = &(slash_in_path[1]); + is_album = true; + } + struct kore_curl* client = (kore_curl*)http_state_get(req); + if (!kore_curl_success(client)) { + kore_curl_logerror(client); + std::string error = "Failed to contact API: "; + error.append(kore_curl_strerror(client)); + kore_curl_cleanup(client); + http_state_cleanup(req); + std::string error_page = build_error_page(error.c_str()); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + char* body = kore_curl_response_as_string(client); + kore_json json; + kore_json_init(&json, body, strlen(body)); + if (kore_json_parse(&json) != KORE_RESULT_OK) { + kore_log(LOG_NOTICE, "failed to parse json: %s", kore_json_strerror(&json)); + std::string error = "Failed to parse response: "; + error.append(kore_json_strerror(&json)); + kore_json_cleanup(&json); + kore_curl_cleanup(client); + http_state_cleanup(req); + std::string error_page = build_error_page(error.c_str()); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + kore_json_item* errors = kore_json_find_array(json.root, "errors"); + if (errors) { + std::string error = "Received error(s) from API:
";
+        struct kore_json_item* error_json;
+        if (!TAILQ_EMPTY(&errors->data.items)) {
+            error_json = TAILQ_FIRST(&errors->data.items);
+            kore_json_item* error_code_json = kore_json_find_string(error_json, "code");
+            kore_json_item* error_detail_json = kore_json_find_string(error_json, "detail");
+            if (error_code_json && error_detail_json && !strcmp(error_code_json->data.string, "404")) {
+                error = "404: ";
+                error.append(error_detail_json->data.string);
+                kore_json_cleanup(&json);
+                kore_curl_cleanup(client);
+                http_state_cleanup(req);
+                if (redis) {
+                    send_hset(id, is_album, "");
+                }
+                std::string error_page = build_error_page(error.c_str());
+                http_response(req, 404, error_page.c_str(), error_page.length());
+                return HTTP_STATE_COMPLETE;
+            }
+        }
+        bool needs_newline = false;
+        TAILQ_FOREACH(error_json, &errors->data.items, list) {
+            // istg if string.append mutates strings passed
+            char* error_code = "unknown error code";
+            char* error_detail = "unknown error detail";
+            kore_json_item* error_code_json = kore_json_find_string(error_json, "code");
+            kore_json_item* error_detail_json = kore_json_find_string(error_json, "detail");
+            if (error_code_json) {
+                error_code = error_code_json->data.string;
+            }
+            if (error_detail_json) {
+                error_detail = error_detail_json->data.string;
+            }
+            kore_log(LOG_NOTICE, "received error from api: %s: %s", error_code, error_detail);
+            std::string to_append;
+            if (needs_newline) {
+                to_append.append("\n");
+            }
+            to_append.append(error_code);
+            to_append.append(": ");
+            to_append.append(error_detail);
+            escape_xml(&to_append);
+            error.append(std::move(to_append));
+            needs_newline = true;
+        }
+        error.append("
"); + kore_json_cleanup(&json); + kore_curl_cleanup(client); + http_state_cleanup(req); + std::string error_page = build_error_page(error.c_str(), true); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + Album album; + std::optional error = deserialize_album(json, &album); + kore_json_cleanup(&json); + kore_curl_cleanup(client); + http_state_cleanup(req); + if (error) { + kore_log(LOG_NOTICE, "%s", (*error).c_str()); + std::string error_page = build_error_page((*error).c_str()); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + if (redis) { + std::string packed = pack_album_into_string(album); + send_hset(id, is_album, packed.c_str()); + } + send_album_page(req, std::move(album)); + return HTTP_STATE_COMPLETE; +} diff --git a/src/proxy_image.cpp b/src/proxy_image.cpp new file mode 100644 index 0000000..5d62aa5 --- /dev/null +++ b/src/proxy_image.cpp @@ -0,0 +1,105 @@ +#include +#include +#include +#include +#include "utils.hpp" +#include "404.hpp" + +extern "C" { + int proxy_image(struct http_request*); + static int proxy_start(struct http_request*); + static int proxy_end(struct http_request*); +} + +static struct http_state proxy_states[] = { + KORE_HTTP_STATE(proxy_start), + KORE_HTTP_STATE(proxy_end) +}; + +int proxy_image(struct http_request* req) { + return http_state_run(proxy_states, 2, req); +} + +static int proxy_start(struct http_request* req) { + // /proxy/ + const char* filename = &(req->path[7]); + std::string url = "https://i.imgur.com/"; + url.append(filename); + struct kore_curl* client = (kore_curl*)http_state_create(req, sizeof(*client), NULL); + if (!kore_curl_init(client, url.c_str(), KORE_CURL_ASYNC)) { + http_state_cleanup(req); + kore_log(LOG_NOTICE, "failed to initialize curl client"); + std::string error_page = build_error_page("Failed to initialize curl client"); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + const char* header_value; + if (http_request_header(req, "Content-Range", &header_value) == KORE_RESULT_OK) { + kore_curl_http_set_header(client, "Content-Range", header_value); + } + if (http_request_header(req, "If-None-Match", &header_value) == KORE_RESULT_OK) { + kore_curl_http_set_header(client, "If-None-Match", header_value); + } + if (http_request_header(req, "If-Match", &header_value) == KORE_RESULT_OK) { + kore_curl_http_set_header(client, "If-Match", header_value); + } + if (http_request_header(req, "If-Modified-Since", &header_value) == KORE_RESULT_OK) { + kore_curl_http_set_header(client, "If-Modified-Since", header_value); + } + if (http_request_header(req, "If-Unmodified-Since", &header_value) == KORE_RESULT_OK) { + kore_curl_http_set_header(client, "If-Unmodified-Since", header_value); + } + // hmm + if (http_request_header(req, "Cache-Control", &header_value) == KORE_RESULT_OK) { + kore_curl_http_set_header(client, "Cache-Control", header_value); + } + kore_curl_http_setup(client, HTTP_METHOD_GET, NULL, 0); + kore_curl_bind_request(client, req); + kore_curl_run(client); + req->fsm_state = 1; + return HTTP_STATE_RETRY; +} + +static int proxy_end(struct http_request* req) { + struct kore_curl* client = (kore_curl*)http_state_get(req); + if (!kore_curl_success(client)) { + kore_curl_logerror(client); + std::string error = "Failed to contact Imgur: "; + error.append(kore_curl_strerror(client)); + kore_curl_cleanup(client); + http_state_cleanup(req); + std::string error_page = build_error_page(error.c_str()); + http_response(req, 500, error_page.c_str(), error_page.length()); + return HTTP_STATE_COMPLETE; + } + // imgur redirects to https://imgur.com/ when image 404s + if (client->http.status == 302) { + kore_curl_cleanup(client); + http_state_cleanup(req); + not_found(req); + return HTTP_STATE_COMPLETE; + } + const char* header_value; + if (kore_curl_http_get_header(client, "Range", &header_value) == KORE_RESULT_OK) { + http_response_header(req, "Range", header_value); + } + if (kore_curl_http_get_header(client, "Accept-Ranges", &header_value) == KORE_RESULT_OK) { + http_response_header(req, "Accept-Ranges", header_value); + } + if (kore_curl_http_get_header(client, "ETag", &header_value) == KORE_RESULT_OK) { + http_response_header(req, "ETag", header_value); + } + if (kore_curl_http_get_header(client, "Content-Type", &header_value) == KORE_RESULT_OK) { + http_response_header(req, "Content-Type", header_value); + } + if (kore_curl_http_get_header(client, "Cache-Control", &header_value) == KORE_RESULT_OK) { + http_response_header(req, "Cache-Control", header_value); + } + const u_int8_t* body; + size_t len; + kore_curl_response_as_bytes(client, &body, &len); + http_response(req, client->http.status, body, len); + kore_curl_cleanup(client); + http_state_cleanup(req); + return HTTP_STATE_COMPLETE; +} diff --git a/src/utils.cpp b/src/utils.cpp new file mode 100644 index 0000000..8be3545 --- /dev/null +++ b/src/utils.cpp @@ -0,0 +1,401 @@ +#include +#include +#include +#include +#include +#include +#include +#include "utils.hpp" + +#define ERROR_PAGE_START "\n" \ + "\n" \ + " \n" \ + " \n" \ + " \n" \ + " ImgurXP\n" \ + " \n" \ + " \n" \ + " \n" \ + "
" + +#define ERROR_PAGE_END "
\n \n" + +#define ALBUM_PAGE_START "\n" \ + "\n" \ + " \n" \ + " \n" \ + " \n" \ + " " + +#define ALBUM_PAGE_START_2 "\n" \ + " \n" \ + " \n" \ + " " + +#define ALBUM_PAGE_END "\n \n" + +std::string build_error_page(const char* error, bool already_escaped) { + std::string output = ERROR_PAGE_START; + if (already_escaped) { + output.append(error); + } else { + std::string error_string = error; + escape_xml(&error_string); + output.append(std::move(error_string)); + } + output.append(ERROR_PAGE_END); + return output; +} + +// behold: the why +// oh and this is stolen from https://stackoverflow.com/a/3418285 +void escape_xml(std::string* str) { + if (str->empty()) { + return; + } + size_t start = 0; + while ((start = str->find("&", start)) != std::string::npos) { + str->replace(start, 1, "&"); + start += 5; + } + start = 0; + while ((start = str->find("<", start)) != std::string::npos) { + str->replace(start, 1, "<"); + start += 4; + } + start = 0; + while ((start = str->find(">", start)) != std::string::npos) { + str->replace(start, 1, ">"); + start += 4; + } + start = 0; + while ((start = str->find("\"", start)) != std::string::npos) { + str->replace(start, 1, """); + start += 6; + } + start = 0; + while ((start = str->find("'", start)) != std::string::npos) { + str->replace(start, 1, "'"); + start += 6; + } +} + +std::optional deserialize_album(kore_json json, Album* album) { + *album = {false, std::nullopt, {}}; + kore_json_item* title_json = kore_json_find_string(json.root, "title"); + if (title_json && *title_json->data.string) { + album->title = title_json->data.string; + } + kore_json_item* is_mature_json = kore_json_find_literal(json.root, "is_mature"); + if (is_mature_json) { + album->is_mature = is_mature_json->data.literal == KORE_JSON_TRUE; + } + kore_json_item* medias_json = kore_json_find_array(json.root, "media"); + if (!medias_json) { + return "missing media key in response"; + } + kore_json_item* media_json; + TAILQ_FOREACH(media_json, &medias_json->data.items, list) { + kore_json_item* media_type_json = kore_json_find_string(media_json, "type"); + kore_json_item* id_json = kore_json_find_string(media_json, "id"); + kore_json_item* ext_json = kore_json_find_string(media_json, "ext"); + kore_json_item* width_json = kore_json_find_integer_u64(media_json, "width"); + kore_json_item* height_json = kore_json_find_integer_u64(media_json, "height"); + if (!id_json) { + return "missing id key from response"; + } + if (!media_type_json) { + std::string error = "missing type key from response (id: "; + error.append(id_json->data.string); + error.append(")"); + return error; + } + if (!ext_json) { + std::string error = "missing ext key from response (id: "; + error.append(id_json->data.string); + error.append(")"); + return error; + } + if (!width_json) { + std::string error = "missing width key from response (id: "; + error.append(id_json->data.string); + error.append(")"); + return error; + } + if (!height_json) { + std::string error = "missing height key from response (id: "; + error.append(id_json->data.string); + error.append(")"); + return error; + } + MediaType media_type = MediaType::Image; + if (!strcmp(media_type_json->data.string, "image")) { + // already set as image + } else if (!strcmp(media_type_json->data.string, "video")) { + media_type = MediaType::Video; + } else { + std::string error = "unknown media type (id: "; + error.append(id_json->data.string); + error.append(", type: "); + error.append(media_type_json->data.string); + error.append(")"); + return error; + } + Media media = {std::nullopt, media_type, id_json->data.string, ext_json->data.string, width_json->data.u64, height_json->data.u64}; + kore_json_item* metadata_json = kore_json_find_object(media_json, "metadata"); + if (metadata_json) { + kore_json_item* description_json = kore_json_find_string(metadata_json, "description"); + if (description_json && *description_json->data.string) { + media.description = description_json->data.string; + } + } + album->media.push_back(std::move(media)); + } + return std::nullopt; +} + +DeterminedKeys determine_keys(const char* id, bool album) { + std::string id_str; + if (album) { + id_str.push_back('+'); + id_str.append(id); + } else { + id_str = id; + } + if (id_str.length() <= 2) { + return {"imgurxp:", std::move(id_str)}; + } + char s1 = id_str.back(); + id_str.pop_back(); + char s0 = id_str.back(); + id_str.pop_back(); + id_str.insert(0, "imgurxp:"); + return {std::move(id_str), {s0, s1}}; +} + +void escape_newline(std::string* str) { + if (str->empty()) { + return; + } + size_t start = 0; + while ((start = str->find("\\", start)) != std::string::npos) { + str->replace(start, 1, "\\\\"); + start += 2; + } + start = 0; + while ((start = str->find("\n", start)) != std::string::npos) { + str->replace(start, 1, "\\n"); + start += 2; + } +} + +void unescape_newline(std::string* str) { + if (str->empty()) { + return; + } + size_t start = 0; + while ((start = str->find('\\', start)) != std::string::npos) { + if (str->length() - 1 >= start + 1) { + char c = str->at(start + 1); + if (c == 'n') { + c = '\n'; + } + str->replace(start, 2, &c, 1); + } + start++; + } +} + +std::string pack_album_into_string(Album album) { + std::string packed; + if (album.is_mature) { + packed.push_back('1'); + } else { + packed.push_back('0'); + } + if (album.title) { + std::string title = (*album.title).substr(); + escape_newline(&title); + packed.append(std::move(title)); + } + for (size_t i=0; i < album.media.size(); i++) { + packed.push_back('\n'); + Media media = album.media[i]; + switch (media.type) { + case MediaType::Image: + packed.push_back('0'); + break; + case MediaType::Video: + packed.push_back('1'); + break; + } + packed.push_back(' '); + packed.append(media.id); + packed.push_back(' '); + packed.append(media.ext); + packed.push_back(' '); + packed.append(std::to_string(media.width)); + packed.push_back(' '); + packed.append(std::to_string(media.height)); + if (media.description) { + packed.push_back(' '); + std::string description = (*media.description).substr(); + escape_newline(&description); + packed.append(std::move(description)); + } + } + return packed; +} + +static std::optional parse_packed_media_line(char* line, Media* media) { + switch (line[0]) { + case '0': + media->type = MediaType::Image; + break; + case '1': + media->type = MediaType::Video; + break; + default: + std::string error = "unknown media type ("; + error.push_back(line[0]); + error.push_back(')'); + return error; + } + media->id = std::string(&(line[2]), strchr(&(line[2]), ' ') - line - 2); + size_t offset = 2 + media->id.length() + 1; + media->ext = std::string(&(line[offset]), strchr(&(line[offset]), ' ') - line - offset); + offset += media->ext.length() + 1; + size_t width_offset = strchr(&(line[offset]), ' ') - line; + std::from_chars_result char_res = std::from_chars(&(line[offset]), &(line[width_offset]), media->width); + if (char_res.ec != std::errc()) { + std::string error = "failed to parse width: "; + error.append(std::make_error_code(char_res.ec).message()); + return error; + } + offset = width_offset + 1; + size_t height_offset = strchr(&(line[offset]), ' ') - line; + if (!height_offset) { + height_offset = strlen(line); + } + char_res = std::from_chars(&(line[offset]), &(line[height_offset]), media->height); + if (char_res.ec != std::errc()) { + std::string error = "failed to parse height: "; + error.append(std::make_error_code(char_res.ec).message()); + return error; + } + offset = height_offset + 1; + char* line_end = strchr(line, '\n'); + if (!line_end) { + line_end = &(line[strlen(line)]); + } + if ((size_t)(line_end - line) > offset) { + offset++; + std::string description = std::string(&(line[offset]), line_end - line - offset); + unescape_newline(&description); + media->description = std::move(description); + } + return std::nullopt; +} + +std::optional unpack_album_from_string(Album* album, char* str) { + album->is_mature = str[0] != '0'; + std::string title; + char* title_end = strchr(&(str[1]), '\n'); + if (title_end) { + title = std::string(&(str[1]), title_end - str - 1); + } else { + title = &(str[1]); + } + if (!title.empty()) { + unescape_newline(&title); + album->title = std::move(title); + } + if (!title_end) { + return std::nullopt; + } + str = &(title_end[1]); + while (true) { + char spaces = 0; + for (size_t i=0; str[i] && str[i] != '\n'; i++) { + if (str[i] == ' ') { + spaces++; + if (spaces == 4) { + break; + } + } + } + if (spaces < 4) { + return "media line missing required spaces"; + } + Media media = {std::nullopt, MediaType::Image, "", "", 0, 0}; + std::optional error = parse_packed_media_line(str, &media); + if (error) { + return error; + } + album->media.push_back(std::move(media)); + char* line_end = strchr(str, '\n'); + if (!line_end) { + break; + } + str = &(line_end[1]); + } + return std::nullopt; +} + +int send_album_page(struct http_request* req, Album album) { + std::string response = ALBUM_PAGE_START; + std::optional escaped_title; + if (album.title) { + escaped_title = album.title; + escape_xml(&(*escaped_title)); + response.append((*escaped_title)); + } else { + response.append("ImgurX"); + } + response.append(ALBUM_PAGE_START_2); + if (album.is_mature) { + response.append("\n
Marked as Mature
"); + } + if (escaped_title) { + response.append("\n

"); + response.append(std::move((*escaped_title))); + response.append("

"); + } + for (size_t i=0; i < album.media.size(); i++ ) { + Media* media = &(album.media[i]); + switch (media->type) { + case MediaType::Image: + response.append("\n id); + response.append("."); + response.append(media->ext); + response.append("\" width=\""); + response.append(std::to_string(media->width)); + response.append("\" height=\""); + response.append(std::to_string(media->height)); + response.append("\" />"); + break; + case MediaType::Video: + response.append("\n