diff --git a/Slon/Api/V1/Media.HC b/Slon/Api/V1/Media.HC new file mode 100644 index 0000000..6f07b47 --- /dev/null +++ b/Slon/Api/V1/Media.HC @@ -0,0 +1,21 @@ +U0 @slon_api_v1_media_put(SlonHttpSession* session) +{ + SLON_SCRATCH_BUFFER_AND_REQUEST_JSON + no_warn scratch_buffer; + + if (@slon_api_authorized(session)) { + if (session->path_count() < 4) { + session->status(400); + return; + } + U8* id = session->path(3); + if (db->o("media")->@(id)) { + db->o("media")->o(id)->set("description", request_json->@("description"), JSON_STRING); + session->send(db->o("media")->o(id)); + } else { + session->status(404); + } + } else { + session->status(401); + } +} diff --git a/Slon/Api/V1/Statuses.HC b/Slon/Api/V1/Statuses.HC index b3fbfd9..e190bfa 100644 --- a/Slon/Api/V1/Statuses.HC +++ b/Slon/Api/V1/Statuses.HC @@ -256,6 +256,7 @@ U0 @slon_api_v1_statuses_post(SlonHttpSession* session) // IceCubesApp lets us post with +: media_attachments, replies_count, spoiler_text, sensitive JsonObject* status = Json.CreateObject(); + JsonArray* media_attachments = NULL; status->set("id", id, JSON_STRING); status->set("created_at", created_at, JSON_STRING); status->set("content", request_json->@("status"), JSON_STRING); @@ -269,7 +270,19 @@ U0 @slon_api_v1_statuses_post(SlonHttpSession* session) status->set("emojis", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); status->set("tags", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); status->set("mentions", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); - status->set("media_attachments", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); + if (request_json->@("media_ids") && request_json->a("media_ids")->length) { + I64 i; + media_attachments = Json.CreateArray(); + for (i = 0; i < request_json->a("media_ids")->length; i++) { + U8* media_id = request_json->a("media_ids")->@(i); + if (media_id && db->o("media")->o(media_id)) { + media_attachments->append(Json.CreateItem(db->o("media")->o(media_id), JSON_OBJECT)); + } + } + status->set("media_attachments", media_attachments, JSON_ARRAY); + } else { + status->set("media_attachments", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); + } status->set("replies_count", 0, JSON_NUMBER); status->set("spoiler_text", "", JSON_STRING); status->set("sensitive", sensitive, JSON_BOOLEAN); @@ -292,6 +305,9 @@ U0 @slon_api_v1_statuses_post(SlonHttpSession* session) session->send(status); + if (media_attachments) { + Json.Delete(media_attachments); + } Json.Delete(status_app); Json.Delete(account_object); Json.Delete(app_object); diff --git a/Slon/Api/V2/Media.HC b/Slon/Api/V2/Media.HC new file mode 100644 index 0000000..26f54e6 --- /dev/null +++ b/Slon/Api/V2/Media.HC @@ -0,0 +1,176 @@ +U8* @slon_api_v2_media_upload(SlonHttpSession* session, U8* filepath) +{ + I64 data_size = 0; + U8* data = FileRead(filepath, &data_size); + + // build the multipart/form-data payload + + U8* payload = @slon_calloc(session, 4096 + data_size); + I64 payload_size = 0; + + U8* boundary = "----------SlonFormBoundary00"; + StrPrint(payload, "--%s\r\n", boundary); + String.Append(payload, "Content-Disposition: form-data; name=\"file\"; filename=\"file\"\r\n"); + String.Append(payload, "Content-Type: image/%s\r\n\r\n", StrFind(".", filepath) + 1); + payload_size = StrLen(payload); + + MemCpy(payload + payload_size, data, data_size); + payload_size += data_size; + StrPrint(payload + payload_size, "\r\n--%s--\r\n", boundary); + payload_size += 8; + payload_size += StrLen(boundary); + + // build the http headers + U8* headers = @slon_calloc(session, 4096); + String.Append(headers, "POST / HTTP/1.0\r\n"); + String.Append(headers, "Content-Type: multipart/form-data; boundary=%s\r\n", boundary); + String.Append(headers, "Content-Length: %d\r\n\r\n", payload_size); + + I64 send_buffer_size = StrLen(headers) + payload_size; + I64 response_buffer_size = 0; + + U8* send_buffer = @slon_calloc(session, send_buffer_size); + U8* response_buffer = @slon_calloc(session, 16384); + + MemCpy(send_buffer, headers, StrLen(headers)); + MemCpy(send_buffer + StrLen(headers), payload, payload_size); + + TlsSocket* s = @tcp_socket_create("10.20.0.254", 5558); + while (s->state != TCP_SOCKET_STATE_ESTABLISHED) + Sleep(1); + + s->send(send_buffer, send_buffer_size); + + I64 bytes_received = -1; + while (bytes_received != 0) { + bytes_received = s->receive(response_buffer + response_buffer_size, 16384); + response_buffer_size += bytes_received; + } + + response_buffer[response_buffer_size] = NULL; + + s->close(); + JsonObject* obj = Json.Parse(StrFind("\r\n\r\n", response_buffer) + 4); + + @slon_free(session, response_buffer); + @slon_free(session, send_buffer); + @slon_free(session, headers); + @slon_free(session, payload); + Free(data); + + return obj->@("url"); +} + +U0 @slon_api_v2_media_post(SlonHttpSession* session) +{ + // NOTE: We only support images at the moment + + SLON_SCRATCH_BUFFER_AND_REQUEST_JSON + no_warn request_json; + + if (@slon_api_authorized(session)) { + U8* data = session->request->data; + + // Advance to Content-Disposition for file attachment + data = StrFind("filename=", data); + + if (!data) { + session->status(400); + return; + } + + if (!StrFind("\r\n\r\n", data)) { + session->status(400); + return; + } + + // Mark beginning of file data + U8* file_ptr = StrFind("\r\n\r\n", data) + 4; + + // NULL terminate Content-Type + StrFind("\r\n\r\n", data)[0] = NULL; + + U8* mime_type = StrFind("Content-Type: ", data); + if (!mime_type) { + session->status(400); + return; + } + mime_type += 14; // StrLen("Content-Type: ") + + if (!String.BeginsWith("image/", mime_type)) { + session->status(400); + return; + } + + U8* boundary = StrFind("boundary=", session->header("content-type")) + 9; + I64 content_length = Str2I64(session->header("content-length")); + // Strip begin double-quotes and ending CRLF, double-quotes + while (boundary[0] == '"') + boundary++; + // Rstrip EOL + while (boundary[StrLen(boundary) - 1] == '\"' || boundary[StrLen(boundary) - 1] == ' ' || boundary[StrLen(boundary) - 1] == '\r' || boundary[StrLen(boundary) - 1] == '\n') + boundary[StrLen(boundary) - 1] = NULL; + + // Get file size + StrPrint(scratch_buffer, "\r\n--%s", boundary); + I64 file_size = 0; + I64 scratch_buffer_len = StrLen(scratch_buffer); + while (file_size < content_length && MemCmp(file_ptr + file_size, scratch_buffer, scratch_buffer_len)) { + ++file_size; + } + + // File size is non-zero and within bounds + if (!file_size || file_size >= content_length) { + session->status(400); + return; + } + + I32 width = 0; + I32 height = 0; + I32 comp = 0; + I32 code = @stbi_info_from_memory(file_ptr, file_size, &width, &height, &comp); + + // Buffer contains a valid image file + if (code != 1) { + session->status(400); + return; + } + + U8* media_id = @slon_api_generate_unique_id(session); + U8* media_file_ext = StrFind("/", mime_type) + 1; + + // Write image file to RAM disk + StrPrint(scratch_buffer, "%s/%s.%s", SLON_MEDIA_PATH, media_id, media_file_ext); + FileWrite(scratch_buffer, file_ptr, file_size); + + // Then, upload to image host + // NOTE: Replace @slon_api_v2_media_upload(session, filepath) with a function that uploads to your desired image host. + // An example upload function is provided. + U8* media_url = @slon_api_v2_media_upload(session, scratch_buffer); + if (media_url) { + JsonObject* media_object = Json.CreateObject(); + media_object->set("id", media_id, JSON_STRING); + media_object->set("type", "image", JSON_STRING); + media_object->set("url", media_url, JSON_STRING); + media_object->set("preview_url", NULL, JSON_NULL); + media_object->set("remote_url", NULL, JSON_NULL); + media_object->set("meta", Json.CreateObject(), JSON_OBJECT); + media_object->o("meta")->set("original", Json.CreateObject(), JSON_OBJECT); + media_object->o("meta")->o("original")->set("width", width, JSON_NUMBER); + media_object->o("meta")->o("original")->set("height", height, JSON_NUMBER); + media_object->set("description", NULL, JSON_NULL); + media_object->set("blurhash", NULL, JSON_NULL); + db->o("media")->set(media_id, media_object, JSON_OBJECT); + session->send(media_object); + } else { + session->status(400); + } + + // Delete image from RAM disk + Del(scratch_buffer); + + @slon_free(session, media_id); + } else { + session->status(401); + } +} diff --git a/Slon/Endpoints/Post/Media.HC b/Slon/Endpoints/Post/Media.HC new file mode 100644 index 0000000..1a5177e --- /dev/null +++ b/Slon/Endpoints/Post/Media.HC @@ -0,0 +1,4 @@ +if (!StrICmp("/api/v2/media", session->path())) { + @slon_api_v2_media_post(session); + return; +} diff --git a/Slon/Endpoints/Put/Media.HC b/Slon/Endpoints/Put/Media.HC new file mode 100644 index 0000000..3846fda --- /dev/null +++ b/Slon/Endpoints/Put/Media.HC @@ -0,0 +1,4 @@ +if (String.BeginsWith("/api/v1/media", session->path())) { + @slon_api_v1_media_put(session); + return; +} diff --git a/Slon/Http/Server.HC b/Slon/Http/Server.HC index 0cc4003..4d8906b 100644 --- a/Slon/Http/Server.HC +++ b/Slon/Http/Server.HC @@ -482,6 +482,11 @@ U0 @slon_http_parse_request_as_form_urlencoded(SlonHttpSession* session) U0 @slon_http_parse_request_as_multipart_form_data(SlonHttpSession* session) { + if (StrFind("; filename=", session->request->data)) { + // Skip parsing - this is a media upload + session->request->json = Json.Parse("{}"); + return; + } U8* json_string = @slon_http_json_string_from_multipart_form_data(session, session->request->data); session->request->json = Json.Parse(json_string); @slon_free(session, json_string); @@ -589,6 +594,7 @@ U0 @slon_http_handle_post_request(SlonHttpSession* session) #include "Endpoints/Post/ActivityPub"; #include "Endpoints/Post/Apps"; #include "Endpoints/Post/Markers"; + #include "Endpoints/Post/Media"; #include "Endpoints/Post/OAuth"; #include "Endpoints/Post/Statuses"; @@ -597,6 +603,29 @@ U0 @slon_http_handle_post_request(SlonHttpSession* session) session->status(404); } +U0 @slon_http_handle_put_request(SlonHttpSession* session) +{ + if (StrFind("json", session->header("content-type")) > 0) { + @slon_http_parse_request_as_json(session); + } + if (String.BeginsWith("application/x-www-form-urlencoded", session->header("content-type"))) { + @slon_http_parse_request_as_form_urlencoded(session); + } + if (String.BeginsWith("multipart/form-data", session->header("content-type"))) { + @slon_http_parse_request_as_multipart_form_data(session); + } + + SLON_DEBUG_PRINT_REQUEST_JSON + + /* clang-format off */ + + #include "Endpoints/Put/Media"; + + /* clang-format on */ + + session->status(404); +} + U0 @slon_http_handle_request(SlonHttpSession* session) { @@ -618,6 +647,9 @@ U0 @slon_http_handle_request(SlonHttpSession* session) case SLON_HTTP_VERB_POST: @slon_http_handle_post_request(session); break; + case SLON_HTTP_VERB_PUT: + @slon_http_handle_put_request(session); + break; default: session->status(405); } diff --git a/Slon/MakeSlon.HC b/Slon/MakeSlon.HC index 98d0f4a..0fd72f5 100644 --- a/Slon/MakeSlon.HC +++ b/Slon/MakeSlon.HC @@ -19,12 +19,14 @@ WinMax(Fs); #include "Api/V1/FollowRequests"; #include "Api/V1/FollowedTags"; #include "Api/V1/Markers"; +#include "Api/V1/Media"; #include "Api/V1/Notifications"; #include "Api/V1/Statuses"; #include "Api/V1/Timelines"; #include "Api/V2/Filters"; #include "Api/V2/Instance"; +#include "Api/V2/Media"; #include "Api/V2/Search"; #include "Api/V2/Suggestions"; diff --git a/Slon/Modules/ActivityPub.HC b/Slon/Modules/ActivityPub.HC index 9e25a9b..90d7020 100644 --- a/Slon/Modules/ActivityPub.HC +++ b/Slon/Modules/ActivityPub.HC @@ -433,6 +433,8 @@ U0 @slon_activitypub_async_accept_request(JsonObject* o) U0 @slon_activitypub_async_create_status_to(JsonObject* status, U8* dest) { Sleep(1000); + I64 i; + U8 scratch_buffer[2048]; U8* this_actor = StrNew(status->@("uri"), adam_task); @@ -466,7 +468,6 @@ U0 @slon_activitypub_async_create_status_to(JsonObject* status, U8* dest) // lookup status uri in user's home timeline JsonArray* lookup_array = db->o("timelines")->o("home")->a(status->o("account")->@("id")); if (lookup_array) { - I64 i; for (i = 0; i < lookup_array->length; i++) { if (!StrICmp(status->@("in_reply_to_id"), lookup_array->o(i)->@("id"))) { note_object->set("inReplyTo", lookup_array->o(i)->@("uri"), JSON_STRING); @@ -483,7 +484,26 @@ U0 @slon_activitypub_async_create_status_to(JsonObject* status, U8* dest) JsonObject* content_map = Json.CreateObject(); content_map->set("en", status->@("content"), JSON_STRING); note_object->set("contentMap", content_map, JSON_OBJECT); - note_object->set("attachment", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); + JsonArray* attachment_array = NULL; + if (status->@("media_attachments") && status->a("media_attachments")->length) { + attachment_array = Json.CreateArray(); + JsonArray* media_attachments = status->a("media_attachments"); + JsonObject* masto_attachment = NULL; + JsonObject* ap_attachment = NULL; + for (i = 0; i < media_attachments->length; i++) { + masto_attachment = media_attachments->@(i); + ap_attachment = Json.CreateObject(); + StrPrint(scratch_buffer, "image/%s", StrFind(".", StrFind("/images/", masto_attachment->@("url")) + 8) + 1); + ap_attachment->set("mediaType", scratch_buffer, JSON_STRING); + ap_attachment->set("url", masto_attachment->@("url"), JSON_STRING); + ap_attachment->set("width", masto_attachment->o("meta")->o("original")->@("width"), JSON_NUMBER); + ap_attachment->set("height", masto_attachment->o("meta")->o("original")->@("height"), JSON_NUMBER); + attachment_array->append(Json.CreateItem(ap_attachment, JSON_OBJECT)); + } + note_object->set("attachment", attachment_array, JSON_ARRAY); + } else { + note_object->set("attachment", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); + } note_object->set("tag", SLON_EMPTY_JSON_ARRAY, JSON_ARRAY); note_object->set("replies", SLON_EMPTY_JSON_OBJECT, JSON_OBJECT); note_object->set("likes", SLON_EMPTY_JSON_OBJECT, JSON_OBJECT); @@ -494,6 +514,9 @@ U0 @slon_activitypub_async_create_status_to(JsonObject* status, U8* dest) StrPrint(scratch_buffer, "%s/inbox", dest); @slon_activitypub_signed_request(scratch_buffer, fetch_buffer, create_object); Free(fetch_buffer); + if (attachment_array) { + Json.Delete(attachment_array); + } Json.Delete(create_object); } diff --git a/Slon/Modules/Db.HC b/Slon/Modules/Db.HC index 210c686..bde07e2 100644 --- a/Slon/Modules/Db.HC +++ b/Slon/Modules/Db.HC @@ -1,6 +1,7 @@ #define SLON_MISSING_ACCOUNT_AVATAR "https://slon-project.org/images/avatar-missing.png" #define SLON_DB_PATH "A:/db" +#define SLON_MEDIA_PATH "B:/media" JsonObject* db = Json.CreateObject(); @@ -279,6 +280,7 @@ U0 @slon_db_load_from_defaults() db->set("following", Json.CreateObject(), JSON_OBJECT); db->set("instance", Json.ParseFile("M:/Slon/Static/defaults/instance.json"), JSON_OBJECT); db->set("markers", Json.CreateObject(), JSON_OBJECT); + db->set("media", Json.CreateObject(), JSON_OBJECT); db->set("statuses", Json.CreateObject(), JSON_OBJECT); db->set("timelines", Json.CreateObject(), JSON_OBJECT); db->o("timelines")->set("home", Json.CreateObject(), JSON_OBJECT); @@ -305,6 +307,7 @@ U0 @slon_db_load_from_disk() @slon_db_load_following_from_disk(); @slon_db_load_instance_from_disk(); @slon_db_load_markers_from_disk(); + db->set("media", Json.CreateObject(), JSON_OBJECT); @slon_db_load_oauth_from_disk(); @slon_db_load_statuses_from_disk(); @slon_db_load_timelines_from_disk();