# HG changeset patch # User MrJuneJune # Date 1771168070 28800 # Node ID 6cdee35a7ba949d50d27cfcea2c3f67ef0077db7 # Parent 90dfcef375fba3df37a1a4b2923da9509b1a5898 [MrJuneJune] notes diff -r 90dfcef375fb -r 6cdee35a7ba9 .hgignore --- a/.hgignore Sat Feb 14 16:32:24 2026 -0800 +++ b/.hgignore Sun Feb 15 07:07:50 2026 -0800 @@ -45,3 +45,5 @@ .env # Server config .config +# DB +mrjunejune.db diff -r 90dfcef375fb -r 6cdee35a7ba9 mrjunejune/BUILD --- a/mrjunejune/BUILD Sat Feb 14 16:32:24 2026 -0800 +++ b/mrjunejune/BUILD Sun Feb 15 07:07:50 2026 -0800 @@ -28,6 +28,14 @@ dest = "src/public/highlight", ) +move_files_into_dir( + name = "rich_editor_js", + srcs = [ + "//rich_editor:rich_editor", + ], + dest = "src/public/js", +) + filegroup( name = "public_files", srcs = glob(["src/public/*"]), @@ -42,7 +50,7 @@ filegroup( name = "src_files", - srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", "shared_js_file"], + srcs = glob(["src/**"]) + [":react_pages", ":shared_js_non_public", ":shared_js_file", ":rich_editor_js"], visibility = ["//mrjunejune/test:__pkg__"], ) @@ -54,7 +62,10 @@ "//seobeo:seobeo", "//markdown_converter:markdown_to_html_c", "//s3:s3", + "//deita:deita", ], + copts = ["-D_GNU_SOURCE"], + linkopts = ["-lpthread"], data = [ ":src_files", ":config_file", @@ -71,7 +82,10 @@ "//seobeo:seobeo_debug", "//markdown_converter:markdown_to_html_c", "//s3:s3", + "//deita:deita", ], + copts = ["-D_GNU_SOURCE"], + linkopts = ["-lpthread"], data = [ ":src_files", ":config_file", diff -r 90dfcef375fb -r 6cdee35a7ba9 mrjunejune/main.c --- a/mrjunejune/main.c Sat Feb 14 16:32:24 2026 -0800 +++ b/mrjunejune/main.c Sun Feb 15 07:07:50 2026 -0800 @@ -1,7 +1,11 @@ #include "seobeo/seobeo.h" #include "markdown_converter/markdown_to_html.h" #include "s3/s3_uploader.h" +#include "deita/deita.h" #include +#include +#include +#include // UUID + /tmp/ + format (max 4) #define TMP_FILE_LENGTH 47 @@ -10,12 +14,25 @@ volatile sig_atomic_t stop_server = 0; static _Atomic uint32_t counter = 0; +// Media Processing Context for background threads +typedef struct { + int64 media_id; + char s3_key_original[512]; + char s3_key_processed[512]; + char content_type[128]; + char access_token[256]; + S3_Config s3_config; +} Media_Processing_Context; + // Server configuration (loaded from .config) static char g_upload_auth_token[256] = {0}; static char g_s3_region[64] = "us-west-2"; static char g_s3_bucket[128] = "mrjunejune"; +static char g_s3_cloudfront_url[256] = {0}; +static char g_db_path[256] = "mrjunejune/data/mrjunejune.db"; static int g_s3_url_expires = 3600; static S3_Config g_s3_config = {0}; +static Deita_Connection *g_db_connection = NULL; static void load_config(const char *config_path) { @@ -60,12 +77,105 @@ { g_s3_url_expires = atoi(value); } + else if (strcmp(key, "S3_CLOUDFRONT_URL") == 0) + { + strncpy(g_s3_cloudfront_url, value, sizeof(g_s3_cloudfront_url) - 1); + } + else if (strcmp(key, "DB_PATH") == 0) + { + strncpy(g_db_path, value, sizeof(g_db_path) - 1); + } } fclose(f); - printf("[CONFIG] Loaded: token=%s..., region=%s, bucket=%s, expires=%d\n", + printf("[CONFIG] Loaded: token=%s..., region=%s, bucket=%s, expires=%d, cloudfront=%s, db=%s\n", g_upload_auth_token[0] ? "***" : "(empty)", - g_s3_region, g_s3_bucket, g_s3_url_expires); + g_s3_region, g_s3_bucket, g_s3_url_expires, + g_s3_cloudfront_url[0] ? g_s3_cloudfront_url : "(none)", + g_db_path); +} + +static void init_database(void) +{ + // Create data directory if needed + char *last_slash = strrchr(g_db_path, '/'); + if (last_slash) + { + char dir_path[256]; + size_t dir_len = last_slash - g_db_path; + strncpy(dir_path, g_db_path, dir_len); + dir_path[dir_len] = '\0'; + mkdir(dir_path, 0755); + } + + g_db_connection = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, g_db_path); + if (!g_db_connection || !Deita_Connection_Is_Open(g_db_connection)) + { + printf("[DB] ERROR: Failed to open database at %s\n", g_db_path); + return; + } + + // Create editor_content table + const char *create_table = + "CREATE TABLE IF NOT EXISTS editor_content (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " access_token TEXT NOT NULL," + " doc_id TEXT NOT NULL," + " content TEXT," + " created_at INTEGER DEFAULT (strftime('%s', 'now'))," + " updated_at INTEGER DEFAULT (strftime('%s', 'now'))," + " UNIQUE(access_token, doc_id)" + ")"; + + int32 result = Deita_Query_Execute_Update(g_db_connection, create_table); + if (result < 0) + { + printf("[DB] ERROR: Failed to create editor_content table\n"); + } + + // Create media_uploads table + const char *create_media_uploads = + "CREATE TABLE IF NOT EXISTS media_uploads (" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " access_token TEXT NOT NULL," + " original_filename TEXT NOT NULL," + " content_type TEXT NOT NULL," + " s3_key_original TEXT NOT NULL," + " s3_key_processed TEXT," + " file_size INTEGER," + " status TEXT NOT NULL DEFAULT 'pending'," + " error_message TEXT," + " created_at INTEGER DEFAULT (strftime('%s', 'now'))," + " updated_at INTEGER DEFAULT (strftime('%s', 'now'))" + ")"; + + result = Deita_Query_Execute_Update(g_db_connection, create_media_uploads); + if (result < 0) + { + printf("[DB] ERROR: Failed to create media_uploads table\n"); + } + + // Create indices for media_uploads + const char *create_status_idx = + "CREATE INDEX IF NOT EXISTS idx_media_uploads_status ON media_uploads(status)"; + result = Deita_Query_Execute_Update(g_db_connection, create_status_idx); + if (result < 0) + { + printf("[DB] ERROR: Failed to create status index\n"); + } + + const char *create_token_status_idx = + "CREATE INDEX IF NOT EXISTS idx_media_uploads_token_status " + "ON media_uploads(access_token, status)"; + result = Deita_Query_Execute_Update(g_db_connection, create_token_status_idx); + if (result < 0) + { + printf("[DB] ERROR: Failed to create token_status index\n"); + } + else + { + printf("[DB] Initialized: %s\n", g_db_path); + } } void handle_sigint(int sig) @@ -93,6 +203,8 @@ char *end_tag = strstr(start_tag, "}}"); if (!end_tag) break; + Seobeo_Log(SEOBEO_INFO, "[Curr] Life\n"); + size_t leading_len = start_tag - cursor; memcpy(final_body + current_offset, cursor, leading_len); current_offset += leading_len; @@ -104,7 +216,8 @@ size_t sub_file_size = 0; char *sub_content = Seobeo_Web_LoadFile(include_name, &sub_file_size); - Seobeo_Log(SEOBEO_DEBUG, "[Curr] Sub content: %s\n", sub_content); + Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading include: '%s' -> %s (size=%zu)\n", + include_name, sub_content ? "OK" : "FAILED", sub_file_size); if (sub_content) { memcpy(final_body + current_offset, sub_content, sub_file_size); @@ -117,15 +230,15 @@ strcpy(final_body + current_offset, cursor); } - void Seobeo_Render_Html_FilePath( char *final_body, char *path, Dowa_Arena *arena ) { - Seobeo_Log(SEOBEO_DEBUG, "[Curr] %s\n", path); + Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Loading main template: '%s'\n", path); size_t html_size = 0; char *template = Seobeo_Web_LoadFile(path, &html_size); + Seobeo_Log(SEOBEO_DEBUG, "[TEMPLATE] Main template loaded: %s (size=%zu)\n", template ? "OK" : "FAILED", html_size); if (!template) return; Seobeo_Render_Html(final_body, template, arena); } @@ -182,7 +295,7 @@ if (!req) { - printf("ERROR: Request is NULL\n"); + Seobeo_Log(SEOBEO_ERROR, "Request is NULL\n"); char *error_msg = "Internal error: no request data"; Dowa_HashMap_Push_Arena(resp, "status", "500", arena); Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena); @@ -195,7 +308,7 @@ for (size_t i = 0; i < req_length; i++) { - printf(" Key[%zu]: '%s'\n", i, req[i].key); + Seobeo_Log(SEOBEO_INFO, " Key[%zu]: '%s'\n", i, req[i].key); } void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); @@ -555,12 +668,50 @@ return resp; } +Seobeo_Request_Entry *GetEditor(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024); + Seobeo_Render_Html_FilePath(final_body, "/editor/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + +Seobeo_Request_Entry *GetNotesLogin(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024); + Seobeo_Render_Html_FilePath(final_body, "/notes/login.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + +Seobeo_Request_Entry *GetNotes(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024); + Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + +Seobeo_Request_Entry *GetNoteById(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + char *final_body = Dowa_Arena_Allocate(arena, 50 * 1024); + // Same template - JavaScript handles the note_id from URL + Seobeo_Render_Html_FilePath(final_body, "/notes/index.html", arena); + Dowa_HashMap_Push_Arena(resp, "body", final_body, arena); + return resp; +} + CREATE_REDIRECT_HANDLER(HomePage, "/") CREATE_REDIRECT_HANDLER(Resume, "/resume") CREATE_REDIRECT_HANDLER(Tools, "/tools") CREATE_REDIRECT_HANDLER(MarkDownToHtml, "/tools/markdown_to_html") CREATE_REDIRECT_HANDLER(FileConverter, "/tools/file_converter") CREATE_REDIRECT_HANDLER(Talk, "/talk") +CREATE_REDIRECT_HANDLER(Editor, "/editor") // S3 Upload URL API // POST /api/s3/upload-url @@ -681,11 +832,23 @@ return resp; } + // Build public URL using CloudFront + char public_url[512]; + if (g_s3_cloudfront_url[0]) + { + snprintf(public_url, sizeof(public_url), "%s/%s", g_s3_cloudfront_url, s3_key); + } + else + { + snprintf(public_url, sizeof(public_url), "https://%s.s3.%s.amazonaws.com/%s", + g_s3_bucket, g_s3_region, s3_key); + } + // Build response - char *response_body = Dowa_Arena_Allocate(arena, 2048 + strlen(presigned.url)); - snprintf(response_body, 2048 + strlen(presigned.url), - "{\"upload_url\":\"%s\",\"key\":\"%s\",\"expires\":%d}", - presigned.url, s3_key, g_s3_url_expires); + char *response_body = Dowa_Arena_Allocate(arena, 4096 + strlen(presigned.url)); + snprintf(response_body, 4096 + strlen(presigned.url), + "{\"upload_url\":\"%s\",\"public_url\":\"%s\",\"key\":\"%s\",\"expires\":%d}", + presigned.url, public_url, s3_key, g_s3_url_expires); S3_Presigned_URL_Destroy(&presigned); @@ -698,6 +861,816 @@ return resp; } +// Editor Content Save API +// POST /api/editor/save +// Headers: Authorization: Bearer +// Body: {"doc_id": "my-doc", "content": ""} +Seobeo_Request_Entry *EditorSave(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena); + return resp; + } + + // Parse request body + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + if (!body_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena); + return resp; + } + + const char *body = ((Seobeo_Request_Entry*)body_kv)->value; + + // Parse doc_id and content from JSON + char doc_id[256] = "default"; + char *content = NULL; + size_t content_len = 0; + + // Find "doc_id":"value" + const char *doc_key = strstr(body, "\"doc_id\""); + if (doc_key) + { + const char *doc_start = strchr(doc_key + 8, '"'); + if (doc_start) + { + doc_start++; + const char *doc_end = strchr(doc_start, '"'); + if (doc_end && (size_t)(doc_end - doc_start) < sizeof(doc_id)) + { + memcpy(doc_id, doc_start, doc_end - doc_start); + doc_id[doc_end - doc_start] = '\0'; + } + } + } + + // Find "content":"value" - content can be large and contain escaped characters + const char *content_key = strstr(body, "\"content\""); + if (content_key) + { + const char *content_start = strchr(content_key + 9, '"'); + if (content_start) + { + content_start++; + // Find closing quote (accounting for escaped quotes) + const char *p = content_start; + while (*p) + { + if (*p == '\\' && *(p+1)) + { + p += 2; + continue; + } + if (*p == '"') break; + p++; + } + content_len = p - content_start; + content = Dowa_Arena_Allocate(arena, content_len + 1); + memcpy(content, content_start, content_len); + content[content_len] = '\0'; + } + } + + if (!content) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing content\"}", arena); + return resp; + } + + // Upsert content + const char *upsert_query = + "INSERT INTO editor_content (access_token, doc_id, content, updated_at) " + "VALUES (?, ?, ?, strftime('%s', 'now')) " + "ON CONFLICT(access_token, doc_id) DO UPDATE SET " + "content = excluded.content, updated_at = strftime('%s', 'now')"; + + const char *params[] = { token, doc_id, content }; + int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, upsert_query, 3, params); + + if (result < 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to save\"}", arena); + return resp; + } + + printf("[EDITOR] Saved doc_id=%s, content_len=%zu\n", doc_id, content_len); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"success\":true}", arena); + return resp; +} + +// Editor Content Load API +// GET /api/editor/load/:doc_id +// Headers: Authorization: Bearer +Seobeo_Request_Entry *EditorLoad(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena); + return resp; + } + + // Get doc_id from URL parameter + void *doc_id_kv = Dowa_HashMap_Get_Ptr(req, ":doc_id"); + const char *doc_id = "default"; + if (doc_id_kv) + { + doc_id = ((Seobeo_Request_Entry*)doc_id_kv)->value; + } + + // Query content + const char *select_query = + "SELECT content, updated_at FROM editor_content WHERE access_token = ? AND doc_id = ?"; + const char *params[] = { token, doc_id }; + + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, params, arena); + + if (p_result && Deita_Result_Set_Next(p_result)) + { + const char *content = Deita_Result_Set_Get_Text(p_result, 0); + int64 updated_at = Deita_Result_Set_Get_Integer(p_result, 1); + + // Build JSON response - escape content + size_t content_len = content ? strlen(content) : 0; + char *response_body = Dowa_Arena_Allocate(arena, content_len + 256); + snprintf(response_body, content_len + 256, + "{\"doc_id\":\"%s\",\"content\":\"%s\",\"updated_at\":%lld}", + doc_id, content ? content : "", (long long)updated_at); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + printf("[EDITOR] Loaded doc_id=%s\n", doc_id); + } + else + { + // No content found, return empty + char *response_body = Dowa_Arena_Allocate(arena, 128); + snprintf(response_body, 128, "{\"doc_id\":\"%s\",\"content\":\"\",\"updated_at\":0}", doc_id); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + } + + if (p_result) Deita_Result_Set_Free(p_result); + return resp; +} + +// Media Upload API - Create media record +// POST /api/media/create +// Headers: Authorization: Bearer , Content-Type: application/json +// Body: {"filename": "photo.jpg", "content_type": "image/jpeg"} +// Returns: {"media_id": 123, "upload_url": "https://...", "expires": 3600} +Seobeo_Request_Entry *MediaCreate(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena); + return resp; + } + + // Parse request body + void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body"); + if (!body_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing request body\"}", arena); + return resp; + } + + const char *body = ((Seobeo_Request_Entry*)body_kv)->value; + + // Parse filename and content_type + char filename[256] = {0}; + char content_type[128] = "application/octet-stream"; + + // Find "filename":"value" + const char *fn_key = strstr(body, "\"filename\""); + if (fn_key) + { + const char *fn_start = strchr(fn_key + 10, '"'); + if (fn_start) + { + fn_start++; + const char *fn_end = strchr(fn_start, '"'); + if (fn_end && (size_t)(fn_end - fn_start) < sizeof(filename)) + { + memcpy(filename, fn_start, fn_end - fn_start); + filename[fn_end - fn_start] = '\0'; + } + } + } + + // Find "content_type":"value" + const char *ct_key = strstr(body, "\"content_type\""); + if (ct_key) + { + const char *ct_start = strchr(ct_key + 14, '"'); + if (ct_start) + { + ct_start++; + const char *ct_end = strchr(ct_start, '"'); + if (ct_end && (size_t)(ct_end - ct_start) < sizeof(content_type)) + { + memcpy(content_type, ct_start, ct_end - ct_start); + content_type[ct_end - ct_start] = '\0'; + } + } + } + + if (strlen(filename) == 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing filename\"}", arena); + return resp; + } + + // Generate UUID for this upload + char *uuid = Dowa_Arena_Allocate(arena, UUID_LEN); + uint32 seed = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + Dowa_String_UUID(seed, uuid); + + // Generate S3 keys + char s3_key_original[512]; + char s3_key_processed[512]; + snprintf(s3_key_original, sizeof(s3_key_original), "uploads/%s/%s", uuid, filename); + snprintf(s3_key_processed, sizeof(s3_key_processed), "uploads/%s/processed.webp", uuid); + + // Insert into database + const char *insert_query = + "INSERT INTO media_uploads (access_token, original_filename, content_type, s3_key_original, s3_key_processed, status) " + "VALUES (?, ?, ?, ?, ?, 'pending')"; + + const char *params[] = { token, filename, content_type, s3_key_original, s3_key_processed }; + int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, insert_query, 5, params); + + if (result < 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to create media record\"}", arena); + return resp; + } + + // Get the inserted media_id using last_insert_rowid() + const char *last_id_query = "SELECT last_insert_rowid()"; + Deita_Result_Set *id_result = Deita_Query_Execute(g_db_connection, last_id_query, arena); + int64 media_id = 0; + if (id_result && Deita_Result_Set_Next(id_result)) + { + media_id = Deita_Result_Set_Get_Integer(id_result, 0); + } + if (id_result) Deita_Result_Set_Free(id_result); + + // Generate presigned PUT URL + S3_Presigned_URL presigned = S3_Presign_Put(&g_s3_config, s3_key_original, content_type, g_s3_url_expires); + + if (!presigned.success) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + char *error_body = Dowa_Arena_Allocate(arena, 256); + snprintf(error_body, 256, "{\"error\":\"Failed to generate upload URL: %s\"}", + presigned.error_message ? presigned.error_message : "unknown"); + Dowa_HashMap_Push_Arena(resp, "body", error_body, arena); + S3_Presigned_URL_Destroy(&presigned); + return resp; + } + + // Build response + char *response_body = Dowa_Arena_Allocate(arena, 4096 + strlen(presigned.url)); + snprintf(response_body, 4096 + strlen(presigned.url), + "{\"media_id\":%lld,\"upload_url\":\"%s\",\"expires\":%d}", + (long long)media_id, presigned.url, g_s3_url_expires); + + S3_Presigned_URL_Destroy(&presigned); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + printf("[MEDIA] Created media_id=%lld, file=%s\n", (long long)media_id, filename); + + return resp; +} + +// Background thread function for media processing +void *Media_Process_Background(void *arg) +{ + Media_Processing_Context *ctx = (Media_Processing_Context *)arg; + + // Open thread-local DB connection + Deita_Connection *db_conn = Deita_Connection_Create(DEITA_DATABASE_TYPE_SQLITE3, g_db_path); + if (!db_conn || !Deita_Connection_Is_Open(db_conn)) + { + printf("[MEDIA] Thread ERROR: Failed to open database for media_id=%lld\n", (long long)ctx->media_id); + free(ctx); + return NULL; + } + + // Update status to 'processing' + const char *update_processing = + "UPDATE media_uploads SET status='processing', updated_at=strftime('%s','now') WHERE id=?"; + char media_id_str[32]; + snprintf(media_id_str, sizeof(media_id_str), "%lld", (long long)ctx->media_id); + const char *params[] = { media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_processing, 1, params); + + printf("[MEDIA] Processing media_id=%lld\n", (long long)ctx->media_id); + + // Generate presigned GET URL for download (10 min expiry) + S3_Presigned_URL download_url = S3_Presign_Get(&ctx->s3_config, ctx->s3_key_original, 600); + if (!download_url.success) + { + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { "Failed to generate download URL", media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + printf("[MEDIA] ERROR: Failed to generate download URL for media_id=%lld\n", (long long)ctx->media_id); + S3_Presigned_URL_Destroy(&download_url); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + + // Generate temp file paths + char tmp_input[256]; + char tmp_output[256]; + char *uuid_input = malloc(UUID_LEN); + char *uuid_output = malloc(UUID_LEN); + uint32 seed1 = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + uint32 seed2 = (uint32)time(NULL) ^ (uint32)pthread_self() ^ counter++; + Dowa_String_UUID(seed1, uuid_input); + Dowa_String_UUID(seed2, uuid_output); + snprintf(tmp_input, sizeof(tmp_input), "/tmp/%s", uuid_input); + snprintf(tmp_output, sizeof(tmp_output), "/tmp/%s.webp", uuid_output); + free(uuid_input); + free(uuid_output); + + // Download from S3 + Seobeo_Client_Request *download_req = Seobeo_Client_Request_Create(download_url.url); + Seobeo_Client_Request_Set_Download_Path(download_req, tmp_input); + Seobeo_Client_Response *download_resp = Seobeo_Client_Request_Execute(download_req); + + S3_Presigned_URL_Destroy(&download_url); + + if (!download_resp || download_resp->status_code != 200) + { + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { "Failed to download from S3", media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + printf("[MEDIA] ERROR: Failed to download from S3 for media_id=%lld\n", (long long)ctx->media_id); + if (download_req) Seobeo_Client_Request_Destroy(download_req); + if (download_resp) Seobeo_Client_Response_Destroy(download_resp); + unlink(tmp_input); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + + Seobeo_Client_Request_Destroy(download_req); + Seobeo_Client_Response_Destroy(download_resp); + + // Convert to webp using FFmpeg + char cmd[1024]; + char log_file[256]; + snprintf(log_file, sizeof(log_file), "/tmp/ffmpeg_%lld.log", (long long)ctx->media_id); + snprintf(cmd, sizeof(cmd), "ffmpeg -y -i %s -quality 80 %s 2>%s", + tmp_input, tmp_output, log_file); + + int ffmpeg_result = system(cmd); + if (ffmpeg_result != 0) + { + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { "Image conversion failed", media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + printf("[MEDIA] ERROR: FFmpeg conversion failed for media_id=%lld\n", (long long)ctx->media_id); + unlink(tmp_input); + unlink(tmp_output); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + + // Upload processed file to S3 + S3_Result upload_result = S3_Upload_File_With_Content_Type( + &ctx->s3_config, tmp_output, ctx->s3_key_processed, "image/webp"); + + if (!upload_result.success) + { + const char *update_error = + "UPDATE media_uploads SET status='error', error_message=?, updated_at=strftime('%s','now') WHERE id=?"; + const char *error_params[] = { "Failed to upload processed file", media_id_str }; + Deita_Query_Execute_Update_Prepared(db_conn, update_error, 2, error_params); + printf("[MEDIA] ERROR: Failed to upload processed file for media_id=%lld\n", (long long)ctx->media_id); + unlink(tmp_input); + unlink(tmp_output); + Deita_Connection_Close(db_conn); + free(ctx); + return NULL; + } + + // Update status to 'finished' + const char *update_finished = + "UPDATE media_uploads SET status='finished', updated_at=strftime('%s','now') WHERE id=?"; + Deita_Query_Execute_Update_Prepared(db_conn, update_finished, 1, params); + + printf("[MEDIA] Successfully processed media_id=%lld\n", (long long)ctx->media_id); + + // Cleanup + unlink(tmp_input); + unlink(tmp_output); + Deita_Connection_Close(db_conn); + free(ctx); + + return NULL; +} + +// Media Upload API - Mark uploaded +// POST /api/media/:id/uploaded +// Headers: Authorization: Bearer +Seobeo_Request_Entry *MediaUploaded(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena); + return resp; + } + + // Extract media_id from URL params + void *id_kv = Dowa_HashMap_Get_Ptr(req, ":id"); + if (!id_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing media ID\"}", arena); + return resp; + } + + const char *media_id_str = ((Seobeo_Request_Entry*)id_kv)->value; + int64 media_id = atoll(media_id_str); + + // Verify access_token matches and get content_type + const char *select_query = + "SELECT content_type, s3_key_original, s3_key_processed FROM media_uploads WHERE id = ? AND access_token = ?"; + const char *select_params[] = { media_id_str, token }; + + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, select_params, arena); + + if (!p_result || !Deita_Result_Set_Next(p_result)) + { + if (p_result) Deita_Result_Set_Free(p_result); + Dowa_HashMap_Push_Arena(resp, "status", "404", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Media not found or access denied\"}", arena); + return resp; + } + + const char *content_type = Deita_Result_Set_Get_Text(p_result, 0); + const char *s3_key_original = Deita_Result_Set_Get_Text(p_result, 1); + const char *s3_key_processed = Deita_Result_Set_Get_Text(p_result, 2); + + // Copy values before freeing result set + char content_type_copy[128]; + char s3_key_original_copy[512]; + char s3_key_processed_copy[512]; + strncpy(content_type_copy, content_type, sizeof(content_type_copy) - 1); + strncpy(s3_key_original_copy, s3_key_original, sizeof(s3_key_original_copy) - 1); + strncpy(s3_key_processed_copy, s3_key_processed, sizeof(s3_key_processed_copy) - 1); + content_type_copy[sizeof(content_type_copy) - 1] = '\0'; + s3_key_original_copy[sizeof(s3_key_original_copy) - 1] = '\0'; + s3_key_processed_copy[sizeof(s3_key_processed_copy) - 1] = '\0'; + + Deita_Result_Set_Free(p_result); + + // Update status to 'uploaded' + const char *update_query = + "UPDATE media_uploads SET status='uploaded', updated_at=strftime('%s','now') WHERE id=?"; + const char *update_params[] = { media_id_str }; + int32 result = Deita_Query_Execute_Update_Prepared(g_db_connection, update_query, 1, update_params); + + if (result < 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to update status\"}", arena); + return resp; + } + + // If content_type starts with "image/", spawn background processing thread + if (strncmp(content_type_copy, "image/", 6) == 0) + { + // Create context for background thread (heap allocated) + Media_Processing_Context *ctx = malloc(sizeof(Media_Processing_Context)); + ctx->media_id = media_id; + strncpy(ctx->s3_key_original, s3_key_original_copy, sizeof(ctx->s3_key_original) - 1); + strncpy(ctx->s3_key_processed, s3_key_processed_copy, sizeof(ctx->s3_key_processed) - 1); + strncpy(ctx->content_type, content_type_copy, sizeof(ctx->content_type) - 1); + strncpy(ctx->access_token, token, sizeof(ctx->access_token) - 1); + ctx->s3_key_original[sizeof(ctx->s3_key_original) - 1] = '\0'; + ctx->s3_key_processed[sizeof(ctx->s3_key_processed) - 1] = '\0'; + ctx->content_type[sizeof(ctx->content_type) - 1] = '\0'; + ctx->access_token[sizeof(ctx->access_token) - 1] = '\0'; + ctx->s3_config = g_s3_config; + + // Spawn detached thread + pthread_t thread_id; + int thread_result = pthread_create(&thread_id, NULL, Media_Process_Background, ctx); + + if (thread_result != 0) + { + Seobeo_Log(SEOBEO_ERROR, "[MEDIA] ERROR: Failed to spawn processing thread for media_id=%lld\n", (long long)media_id); + free(ctx); + } + else + { + // Detach thread so it cleans up automatically when done + pthread_detach(thread_id); + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Spawned processing thread for media_id=%lld\n", (long long)media_id); + } + } + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"success\":true,\"status\":\"uploaded\"}", arena); + + Seobeo_Log(SEOBEO_INFO, "[MEDIA] Marked uploaded media_id=%lld\n", (long long)media_id); + + return resp; +} + +// Media Upload API - Get status +// GET /api/media/:id/status +// Headers: Authorization: Bearer +// Returns: {"id": 123, "status": "finished", "processed_url": "https://...", "error_message": null} +Seobeo_Request_Entry *MediaStatus(Seobeo_Request_Entry *req, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *resp = NULL; + + // Check auth token + void *auth_kv = Dowa_HashMap_Get_Ptr(req, "Authorization"); + if (!auth_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing Authorization header\"}", arena); + return resp; + } + + const char *auth_header = ((Seobeo_Request_Entry*)auth_kv)->value; + if (strncmp(auth_header, "Bearer ", 7) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "401", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid Authorization format\"}", arena); + return resp; + } + + const char *token = auth_header + 7; + if (strlen(g_upload_auth_token) == 0 || strcmp(token, g_upload_auth_token) != 0) + { + Dowa_HashMap_Push_Arena(resp, "status", "403", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Invalid token\"}", arena); + return resp; + } + + if (!g_db_connection) + { + Dowa_HashMap_Push_Arena(resp, "status", "500", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Database not available\"}", arena); + return resp; + } + + // Extract media_id from URL params + void *id_kv = Dowa_HashMap_Get_Ptr(req, ":id"); + if (!id_kv) + { + Dowa_HashMap_Push_Arena(resp, "status", "400", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Missing media ID\"}", arena); + return resp; + } + + const char *media_id_str = ((Seobeo_Request_Entry*)id_kv)->value; + + // Query media status + const char *select_query = + "SELECT id, status, s3_key_processed, error_message FROM media_uploads WHERE id = ? AND access_token = ?"; + const char *select_params[] = { media_id_str, token }; + + Deita_Result_Set *p_result = Deita_Query_Execute_Prepared(g_db_connection, select_query, 2, select_params, arena); + + if (!p_result || !Deita_Result_Set_Next(p_result)) + { + if (p_result) Deita_Result_Set_Free(p_result); + Dowa_HashMap_Push_Arena(resp, "status", "404", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Media not found\"}", arena); + return resp; + } + + int64 id = Deita_Result_Set_Get_Integer(p_result, 0); + const char *status = Deita_Result_Set_Get_Text(p_result, 1); + const char *s3_key_processed = Deita_Result_Set_Get_Text(p_result, 2); + const char *error_message = Deita_Result_Set_Get_Text(p_result, 3); + + // Build CloudFront URL if status is 'finished' and s3_key_processed exists + char processed_url[1024] = {0}; + if (strcmp(status, "finished") == 0 && s3_key_processed && strlen(s3_key_processed) > 0) + { + if (g_s3_cloudfront_url[0]) + { + snprintf(processed_url, sizeof(processed_url), "%s/%s", g_s3_cloudfront_url, s3_key_processed); + } + else + { + snprintf(processed_url, sizeof(processed_url), "https://%s.s3.%s.amazonaws.com/%s", + g_s3_bucket, g_s3_region, s3_key_processed); + } + } + + // Build JSON response + char *response_body = Dowa_Arena_Allocate(arena, 2048); + if (strlen(processed_url) > 0) + { + snprintf(response_body, 2048, + "{\"id\":%lld,\"status\":\"%s\",\"processed_url\":\"%s\",\"error_message\":%s}", + (long long)id, status, processed_url, + error_message ? "\"" : "null"); + if (error_message) + { + // Append error message if exists + size_t len = strlen(response_body); + snprintf(response_body + len - 1, 2048 - len + 1, "%s\"}", error_message); + } + } + else + { + snprintf(response_body, 2048, + "{\"id\":%lld,\"status\":\"%s\",\"processed_url\":null,\"error_message\":%s}", + (long long)id, status, + error_message ? "\"" : "null"); + if (error_message) + { + size_t len = strlen(response_body); + snprintf(response_body + len - 1, 2048 - len + 1, "%s\"}", error_message); + } + } + + Deita_Result_Set_Free(p_result); + + Dowa_HashMap_Push_Arena(resp, "status", "200", arena); + Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(resp, "body", response_body, arena); + + return resp; +} + int main(void) { // Load server config @@ -742,6 +1715,9 @@ printf("[S3] Configured: region=%s, bucket=%s, key=%s...\n", g_s3_region, g_s3_bucket, s3_access_key[0] ? "***" : "(missing)"); + // Initialize database + init_database(); + Seobeo_Router_Init(); Seobeo_Router_Register("GET", "/", GetHomePage); @@ -767,6 +1743,15 @@ // -- S3 Upload --/ Seobeo_Router_Register("POST", "/api/s3/upload-url", GetS3UploadUrl); + // -- Media Upload --/ + Seobeo_Router_Register("POST", "/api/media/create", MediaCreate); + Seobeo_Router_Register("POST", "/api/media/:id/uploaded", MediaUploaded); + Seobeo_Router_Register("GET", "/api/media/:id/status", MediaStatus); + + // -- Editor --/ + Seobeo_Router_Register("POST", "/api/editor/save", EditorSave); + Seobeo_Router_Register("GET", "/api/editor/load/:doc_id", EditorLoad); + // -- Blog --/ Seobeo_Router_Register("GET", "/blog", RenderBlogList); Seobeo_Router_Register("GET", "/blog/:blog_id", RenderBlog); @@ -775,8 +1760,21 @@ Seobeo_Router_Register("GET", "/talk", GetTalk); Seobeo_Router_Register("GET", "/talk/index.html", GetRedirectTalk); + // -- Editor (legacy) --/ + Seobeo_Router_Register("GET", "/editor", GetEditor); + Seobeo_Router_Register("GET", "/editor/index.html", GetRedirectEditor); + + // -- Notes --/ + Seobeo_Router_Register("GET", "/notes", GetNotes); + Seobeo_Router_Register("GET", "/notes/", GetNotes); + Seobeo_Router_Register("GET", "/notes/index.html", GetNotes); + Seobeo_Router_Register("GET", "/notes/login", GetNotesLogin); + Seobeo_Router_Register("GET", "/notes/login/", GetNotesLogin); + Seobeo_Router_Register("GET", "/notes/:note_id", GetNoteById); + Seobeo_WebSocket_Server_Init(); Seobeo_WebSocket_Server_Register("/chat", Chat_Handler, NULL); - Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 3); + Seobeo_Log(SEOBEO_INFO, "WTF is going on\n"); + Seobeo_Web_Server_Start("mrjunejune/src", "6969", SEOBEO_MODE_EDGE, 1); } diff -r 90dfcef375fb -r 6cdee35a7ba9 mrjunejune/src/editor/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/editor/index.html Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,221 @@ + + + + {{/parts/base_head.html}} + Editor | MrJuneJune + + + + {{/parts/header.html}} + +
+
+

Rich Editor

+
+ + +
+
+ +
+ + +
+ +
+
+ + {{/parts/footer.html}} + + + + + diff -r 90dfcef375fb -r 6cdee35a7ba9 mrjunejune/src/notes/editor.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/notes/editor.js Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,204 @@ +console.log("june"); + +let editor = null; +let currentNoteId = 'index'; + +function getAuthToken() { + return localStorage.getItem('notes-auth-token'); +} + +function requireAuth() { + if (!getAuthToken()) { + const returnUrl = encodeURIComponent(window.location.pathname); + window.location.href = '/notes/login?return=' + returnUrl; + return false; + } + return true; +} + +function logout() { + localStorage.removeItem('notes-auth-token'); + window.location.href = '/notes/login'; +} + +function getNoteIdFromPath() { + const path = window.location.pathname; + const match = path.match(/^\/notes\/(.+)$/); + if (match && match[1] && match[1] !== 'login') { + return decodeURIComponent(match[1]); + } + return 'index'; +} + +function showNewNoteDialog() { + document.getElementById('new-note-dialog').classList.add('show'); + document.getElementById('new-note-id').focus(); +} + +function hideNewNoteDialog() { + document.getElementById('new-note-dialog').classList.remove('show'); + document.getElementById('new-note-id').value = ''; +} + +function createNewNote() { + let noteId = document.getElementById('new-note-id').value.trim(); + if (!noteId) return; + + // Sanitize note ID + noteId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); + + hideNewNoteDialog(); + window.location.href = '/notes/' + encodeURIComponent(noteId); +} + +// Handle Enter key in new note dialog +document.getElementById('new-note-id').addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + createNewNote(); + } + if (e.key === 'Escape') { + hideNewNoteDialog(); + } +}); + +// Close dialog on backdrop click +document.getElementById('new-note-dialog').addEventListener('click', function(e) { + if (e.target === this) { + hideNewNoteDialog(); + } +}); + +async function uploadFile(file) { + const token = getAuthToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + // 1. Create media record + const createResp = await fetch('/api/media/create', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + filename: file.name, + content_type: file.type + }) + }); + + if (!createResp.ok) { + const error = await createResp.json(); + throw new Error(error.error || 'Failed to create media record'); + } + + const { media_id, upload_url } = await createResp.json(); + + // 2. Upload to S3 + const uploadResp = await fetch(upload_url, { + method: 'PUT', + headers: { 'Content-Type': file.type }, + body: file + }); + + if (!uploadResp.ok) { + throw new Error('S3 upload failed'); + } + + // 3. Mark uploaded + await fetch(`/api/media/${media_id}/uploaded`, { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token } + }); + + // 4. Poll for images, immediate return for non-images + if (file.type.startsWith('image/')) { + return await pollForProcessedImage(media_id); + } else { + // For non-images, return the original S3 URL + const s3_url = upload_url.split('?')[0]; + return { url: s3_url }; + } +} + +async function pollForProcessedImage(mediaId) { + const token = getAuthToken(); + const maxAttempts = 60; // 2 minutes max + + for (let i = 0; i < maxAttempts; i++) { + await new Promise(r => setTimeout(r, 2000)); // 2 sec interval + + const resp = await fetch(`/api/media/${mediaId}/status`, { + headers: { 'Authorization': 'Bearer ' + token } + }); + + if (!resp.ok) continue; + + const { status, processed_url, error_message } = await resp.json(); + + if (status === 'finished') return { url: processed_url }; + if (status === 'error') throw new Error(error_message || 'Processing failed'); + } + + throw new Error('Processing timeout'); +} + +async function saveContent(content) { + const token = getAuthToken(); + if (!token) return; + + const response = await fetch('/api/editor/save', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + doc_id: currentNoteId, + content: content + }) + }); + + if (!response.ok) { + throw new Error('Failed to save'); + } +} + +async function loadNote(noteId) { + const token = getAuthToken(); + if (!token) return; + + try { + const response = await fetch('/api/editor/load/' + encodeURIComponent(noteId), { + headers: { 'Authorization': 'Bearer ' + token } + }); + + if (response.ok) { + const data = await response.json(); + editor.setContent(data.content || ''); + } + } catch (error) { + console.error('Failed to load note:', error); + } +} + +// Initialize +document.addEventListener('DOMContentLoaded', function() { + if (!requireAuth()) return; + + currentNoteId = getNoteIdFromPath(); + document.getElementById('note-id-display').textContent = currentNoteId; + + // Update page title + document.title = currentNoteId + ' | Notes'; + + editor = RichEditor.init('editor-container', { + uploadCallback: uploadFile, + saveCallback: saveContent, + debounceMs: 1500, + placeholder: 'Start writing... (paste images, drag files, or use /upload)\n\nTip: Click "+ New Note" to create linked notes.' + }); + + loadNote(currentNoteId); +}); diff -r 90dfcef375fb -r 6cdee35a7ba9 mrjunejune/src/notes/index.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/notes/index.html Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,331 @@ + + + + {{/parts/base_head.html}} + Notes + + + + {{/parts/header.html}} + +
+
+

+ Notes + index +

+
+ + Home + +
+
+ +
+
+ + +
+
+

Create New Note

+ +

Use lowercase letters, numbers, and hyphens only

+
+ + +
+
+
+ + {{/parts/footer.html}} + + + + + diff -r 90dfcef375fb -r 6cdee35a7ba9 mrjunejune/src/notes/login.html --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/notes/login.html Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,113 @@ + + + + {{/parts/base_head.html}} + Login | Notes + + + + {{/parts/header.html}} + +
+ +
+ + {{/parts/footer.html}} + + + + diff -r 90dfcef375fb -r 6cdee35a7ba9 rich_editor/BUILD --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rich_editor/BUILD Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,5 @@ +filegroup( + name = "rich_editor", + srcs = ["rich_editor.js"], + visibility = ["//visibility:public"], +) diff -r 90dfcef375fb -r 6cdee35a7ba9 rich_editor/rich_editor.js --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/rich_editor/rich_editor.js Sun Feb 15 07:07:50 2026 -0800 @@ -0,0 +1,602 @@ +/** + * Rich Editor + * ----------- + * A vanilla JavaScript rich text editor with: + * - Text formatting (h1-h6, paragraphs, lists) + * - Image/file upload via paste, drop, click, or /upload command + * - Debounced auto-save + * - Generic callback functions for uploads and saves + * + * Usage: + * const editor = RichEditor.init('editor-container', { + * uploadCallback: async (file) => { return { url: '...', key: '...' }; }, + * saveCallback: async (content) => { console.log('Saved:', content); }, + * debounceMs: 1000, + * placeholder: 'Start writing...' + * }); + */ + +const RichEditor = (function() { + 'use strict'; + + const DEFAULT_OPTIONS = { + uploadCallback: null, + saveCallback: null, + debounceMs: 1000, + placeholder: 'Start writing... (Use /upload to insert files)', + cloudFrontUrl: '' + }; + + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + function createToolbar(editor) { + const toolbar = document.createElement('div'); + toolbar.className = 'rich-editor-toolbar'; + + const buttons = [ + { cmd: 'h1', label: 'H1', title: 'Heading 1' }, + { cmd: 'h2', label: 'H2', title: 'Heading 2' }, + { cmd: 'h3', label: 'H3', title: 'Heading 3' }, + { cmd: 'p', label: 'ΒΆ', title: 'Paragraph' }, + { cmd: 'ul', label: 'β€’', title: 'Bullet List' }, + { cmd: 'ol', label: '1.', title: 'Numbered List' }, + { cmd: 'separator' }, + { cmd: 'bold', label: 'B', title: 'Bold' }, + { cmd: 'italic', label: 'I', title: 'Italic' }, + { cmd: 'separator' }, + { cmd: 'link', label: 'πŸ”—', title: 'Insert Link' }, + { cmd: 'notelink', label: 'πŸ“', title: 'Link to Note' }, + { cmd: 'upload', label: 'πŸ“Ž', title: 'Upload File' } + ]; + + buttons.forEach(btn => { + if (btn.cmd === 'separator') { + const sep = document.createElement('span'); + sep.className = 'rich-editor-separator'; + toolbar.appendChild(sep); + return; + } + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'rich-editor-btn'; + button.textContent = btn.label; + button.title = btn.title; + button.dataset.cmd = btn.cmd; + + button.addEventListener('click', (e) => { + e.preventDefault(); + editor.execCommand(btn.cmd); + }); + + toolbar.appendChild(button); + }); + + return toolbar; + } + + function createStyles() { + if (document.getElementById('rich-editor-styles')) return; + + const style = document.createElement('style'); + style.id = 'rich-editor-styles'; + style.textContent = ` + .rich-editor-container { + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; + } + + .rich-editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px; + background: #f5f5f5; + border-bottom: 1px solid #ccc; + } + + .rich-editor-btn { + padding: 6px 12px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + cursor: pointer; + font-size: 14px; + min-width: 32px; + } + + .rich-editor-btn:hover { + background: #e9e9e9; + } + + .rich-editor-btn:active { + background: #ddd; + } + + .rich-editor-separator { + width: 1px; + background: #ccc; + margin: 0 4px; + } + + .rich-editor-content { + min-height: 300px; + padding: 16px; + outline: none; + overflow-y: auto; + } + + .rich-editor-content:empty:before { + content: attr(data-placeholder); + color: #999; + pointer-events: none; + } + + .rich-editor-content img { + max-width: 100%; + height: auto; + border-radius: 4px; + margin: 8px 0; + } + + .rich-editor-content a { + color: #0078ff; + text-decoration: none; + } + + .rich-editor-content a:hover { + text-decoration: underline; + } + + .rich-editor-content a.note-link { + background: #e8f4ff; + padding: 2px 6px; + border-radius: 3px; + } + + .rich-editor-content a.note-link:hover { + background: #d0e8ff; + } + + .rich-editor-content .upload-placeholder { + display: inline-block; + padding: 8px 16px; + background: #f0f0f0; + border-radius: 4px; + color: #666; + font-style: italic; + } + + .rich-editor-content .upload-placeholder.loading { + padding: 8px 16px; + background: #fffacd; + border-radius: 4px; + animation: pulse 1.5s ease-in-out infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } + } + + .rich-editor-upload-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 120, 255, 0.1); + border: 2px dashed #0078ff; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #0078ff; + pointer-events: none; + z-index: 10; + } + + .rich-editor-status { + padding: 4px 8px; + font-size: 12px; + color: #666; + background: #f9f9f9; + border-top: 1px solid #eee; + } + + .rich-editor-file-input { + display: none; + } + `; + document.head.appendChild(style); + } + + class Editor { + constructor(elementId, options) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.container = document.getElementById(elementId); + + if (!this.container) { + throw new Error(`Element with id "${elementId}" not found`); + } + + this.init(); + } + + init() { + createStyles(); + + // Create wrapper + this.wrapper = document.createElement('div'); + this.wrapper.className = 'rich-editor-container'; + this.wrapper.style.position = 'relative'; + + // Create toolbar + this.toolbar = createToolbar(this); + this.wrapper.appendChild(this.toolbar); + + // Create content area + this.content = document.createElement('div'); + this.content.className = 'rich-editor-content'; + this.content.contentEditable = true; + this.content.dataset.placeholder = this.options.placeholder; + this.wrapper.appendChild(this.content); + + // Create status bar + this.status = document.createElement('div'); + this.status.className = 'rich-editor-status'; + this.status.textContent = 'Ready'; + this.wrapper.appendChild(this.status); + + // Create hidden file input + this.fileInput = document.createElement('input'); + this.fileInput.type = 'file'; + this.fileInput.className = 'rich-editor-file-input'; + this.fileInput.multiple = true; + this.fileInput.accept = 'image/*,application/pdf,.doc,.docx,.txt'; + this.wrapper.appendChild(this.fileInput); + + // Add to container + this.container.appendChild(this.wrapper); + + // Setup event listeners + this.setupEvents(); + + // Setup debounced save + if (this.options.saveCallback) { + this.debouncedSave = debounce(() => { + this.save(); + }, this.options.debounceMs); + } + } + + setupEvents() { + // Input events for auto-save + this.content.addEventListener('input', () => { + if (this.debouncedSave) { + this.setStatus('Editing...'); + this.debouncedSave(); + } + }); + + // Handle paste + this.content.addEventListener('paste', (e) => { + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of items) { + if (item.type.startsWith('image/') || item.kind === 'file') { + e.preventDefault(); + const file = item.getAsFile(); + if (file) this.uploadFile(file); + return; + } + } + }); + + // Handle drop + this.content.addEventListener('dragover', (e) => { + e.preventDefault(); + this.showDropOverlay(); + }); + + this.content.addEventListener('dragleave', (e) => { + e.preventDefault(); + this.hideDropOverlay(); + }); + + this.content.addEventListener('drop', (e) => { + e.preventDefault(); + this.hideDropOverlay(); + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + for (const file of files) { + this.uploadFile(file); + } + } + }); + + // Handle /upload command + this.content.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const selection = window.getSelection(); + const node = selection.anchorNode; + if (node && node.textContent) { + const text = node.textContent; + if (text.trim() === '/upload') { + e.preventDefault(); + node.textContent = ''; + this.triggerFileUpload(); + } + } + } + }); + + // File input change + this.fileInput.addEventListener('change', (e) => { + const files = e.target.files; + if (files && files.length > 0) { + for (const file of files) { + this.uploadFile(file); + } + } + this.fileInput.value = ''; + }); + + // Handle keyboard shortcuts + this.content.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault(); + this.execCommand('bold'); + break; + case 'i': + e.preventDefault(); + this.execCommand('italic'); + break; + case 's': + e.preventDefault(); + this.save(); + break; + } + } + }); + } + + showDropOverlay() { + if (this.dropOverlay) return; + + this.dropOverlay = document.createElement('div'); + this.dropOverlay.className = 'rich-editor-upload-overlay'; + this.dropOverlay.textContent = 'Drop files here to upload'; + this.wrapper.appendChild(this.dropOverlay); + } + + hideDropOverlay() { + if (this.dropOverlay) { + this.dropOverlay.remove(); + this.dropOverlay = null; + } + } + + triggerFileUpload() { + this.fileInput.click(); + } + + async uploadFile(file) { + if (!this.options.uploadCallback) { + console.warn('No upload callback configured'); + return; + } + + // Insert loading placeholder + const placeholder = document.createElement('div'); + placeholder.className = 'upload-placeholder loading'; + placeholder.textContent = file.type.startsWith('image/') + ? `Processing ${file.name}... ⏳` + : `Uploading ${file.name}...`; + + this.insertAtCursor(placeholder); + + this.setStatus(`Uploading ${file.name}...`); + + try { + const result = await this.options.uploadCallback(file); + + if (result && result.url) { + // Replace placeholder with actual content + if (file.type.startsWith('image/')) { + const img = document.createElement('img'); + img.src = result.url; + img.alt = file.name; + placeholder.replaceWith(img); + } else { + const link = document.createElement('a'); + link.href = result.url; + link.textContent = file.name; + link.target = '_blank'; + placeholder.replaceWith(link); + } + + this.setStatus(`Uploaded ${file.name}`); + if (this.debouncedSave) this.debouncedSave(); + } else { + placeholder.textContent = `Failed to upload ${file.name}`; + placeholder.className = 'upload-placeholder'; + this.setStatus('Upload failed'); + } + } catch (error) { + console.error('Upload error:', error); + placeholder.textContent = `Error: ${error.message}`; + placeholder.className = 'upload-placeholder'; + this.setStatus('Upload error'); + } + } + + insertAtCursor(element) { + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(element); + range.setStartAfter(element); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + this.content.appendChild(element); + } + this.content.focus(); + } + + execCommand(cmd) { + this.content.focus(); + + switch (cmd) { + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + document.execCommand('formatBlock', false, cmd); + break; + case 'p': + document.execCommand('formatBlock', false, 'p'); + break; + case 'ul': + document.execCommand('insertUnorderedList', false, null); + break; + case 'ol': + document.execCommand('insertOrderedList', false, null); + break; + case 'bold': + document.execCommand('bold', false, null); + break; + case 'italic': + document.execCommand('italic', false, null); + break; + case 'link': + this.insertLink(); + break; + case 'notelink': + this.insertNoteLink(); + break; + case 'upload': + this.triggerFileUpload(); + break; + } + + if (this.debouncedSave) this.debouncedSave(); + } + + insertLink() { + const url = prompt('Enter URL:'); + if (!url) return; + + const selection = window.getSelection(); + const selectedText = selection.toString() || url; + + const link = document.createElement('a'); + link.href = url; + link.textContent = selectedText; + link.target = '_blank'; + + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(link); + range.setStartAfter(link); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + insertNoteLink() { + const noteId = prompt('Enter note ID (e.g., my-ideas):'); + if (!noteId) return; + + const sanitizedId = noteId.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-'); + const selection = window.getSelection(); + const selectedText = selection.toString() || sanitizedId; + + const link = document.createElement('a'); + link.href = '/notes/' + encodeURIComponent(sanitizedId); + link.textContent = selectedText; + link.className = 'note-link'; + + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(link); + range.setStartAfter(link); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } + } + + setStatus(text) { + this.status.textContent = text; + } + + async save() { + if (!this.options.saveCallback) return; + + this.setStatus('Saving...'); + + try { + await this.options.saveCallback(this.getContent()); + this.setStatus('Saved'); + } catch (error) { + console.error('Save error:', error); + this.setStatus('Save failed'); + } + } + + getContent() { + return this.content.innerHTML; + } + + setContent(html) { + this.content.innerHTML = html; + } + + getText() { + return this.content.textContent; + } + + clear() { + this.content.innerHTML = ''; + } + + focus() { + this.content.focus(); + } + } + + return { + init: function(elementId, options) { + return new Editor(elementId, options); + } + }; +})(); + +// Export for module systems +if (typeof module !== 'undefined' && module.exports) { + module.exports = RichEditor; +} diff -r 90dfcef375fb -r 6cdee35a7ba9 s3/s3_uploader.c --- a/s3/s3_uploader.c Sat Feb 14 16:32:24 2026 -0800 +++ b/s3/s3_uploader.c Sun Feb 15 07:07:50 2026 -0800 @@ -388,40 +388,23 @@ char expires_str[16]; snprintf(expires_str, sizeof(expires_str), "%d", expires_seconds); + // URL-encode signed headers (semicolon becomes %3B) + char *encoded_signed_headers = s3__uri_encode_strict(signed_headers, p_arena); + size_t query_len = 1024 + strlen(encoded_credential); char *canonical_query = Dowa_Arena_Allocate(p_arena, query_len); - if (content_type && strcmp(method, "PUT") == 0) - { - char *encoded_content_type = s3__uri_encode_strict(content_type, p_arena); - snprintf(canonical_query, query_len, - "X-Amz-Algorithm=%s" - "&X-Amz-Credential=%s" - "&X-Amz-Date=%s" - "&X-Amz-Expires=%s" - "&X-Amz-SignedHeaders=%s" - "&x-amz-content-type=%s", - S3_ALGORITHM, - encoded_credential, - ts.datetime, - expires_str, - signed_headers, - encoded_content_type); - } - else - { - snprintf(canonical_query, query_len, - "X-Amz-Algorithm=%s" - "&X-Amz-Credential=%s" - "&X-Amz-Date=%s" - "&X-Amz-Expires=%s" - "&X-Amz-SignedHeaders=%s", - S3_ALGORITHM, - encoded_credential, - ts.datetime, - expires_str, - signed_headers); - } + snprintf(canonical_query, query_len, + "X-Amz-Algorithm=%s" + "&X-Amz-Credential=%s" + "&X-Amz-Date=%s" + "&X-Amz-Expires=%s" + "&X-Amz-SignedHeaders=%s", + S3_ALGORITHM, + encoded_credential, + ts.datetime, + expires_str, + encoded_signed_headers); // Build canonical headers size_t headers_len = 256 + strlen(host) + (content_type ? strlen(content_type) : 0);