// http parsing and dispatch (websocket upgrade) // ----- http parsing constants #define CR kstr("\r") #define LF kstr("\n") #define CRLF "\r\n" #define HTTP_VERSION "HTTP/1.1" #define HTTP_CONTENT_LENGTH_PREFIX kstr("content-length: ") #define HTTP_AUTHORIZATION_PREFIX kstr("authorization: ") #define HTTP_WEBSOCKET_REQUEST_TOKEN_PREFIX kstr("sec-websocket-key: ") #define HTTP_CONTENT_TYPE_BLISP "application/x-blisp" // ----- Endpoints/Urls #define API_PATH_PREFIX kstr("/api") #define API_PATH_AUTH_LOGIN kstr("/auth/login") #define API_AUTH_CHANGE_PASSWORD kstr("/auth/change-password") #define API_PATH_ROOMS kstr("/rooms") #define API_PATH_USERS kstr("/users") #define API_PATH_ROOMS_PLUS kstr("/rooms/") #define API_PATH_MESSAGES kstr("/messages") #define API_PATH_UPLOADS kstr("/uploads") #define API_PATH_STORE kstr("/store/") #define API_PATH_AVATAR_UPLOAD kstr("/upload-avatar") #define API_PATH_AVATAR_GET kstr("/avatar/") #define API_WEBSOCKET_UPGRADE kstr("/ws") #define API_WEBSOCKET_UPGRADE_QUERY_PARAM kstr("token=") #define API_MSG_TYPE_SEND_MESSAGE kstr("send_message") #define API_MSG_TYPE_TYPING_START kstr("typing.start") #define API_MSG_TYPE_TYPING_STOP kstr("typing.stop") #define API_MSG_TYPE_MESSAGE_READ kstr("message.read") #define API_MSG_TYPE_PING kstr("ping") // ----- blisp parsing constants #define KEY_ID kstr("id") #define KEY_USER_ID kstr("user_id") #define KEY_USERNAME kstr("username") // ---- http header parser typedef struct headerator Headerator; struct headerator{ str line; str rest; bool err; }; static Headerator headerator_init(str const header){ return (Headerator){.rest=header}; } static bool headerator_next(Headerator *it){ if(it->err) return false; unless(it->rest.len) return false; str_pair const x = str_split_at(it->rest, '\n'); if(str_eq(it->rest,x.a)){ it->err=true; return false; } it->line = x.a; if(str_ends_with(it->line, CR)) it->line=str_drop_last(it->line, 1); it->rest = x.b; return true; } typedef struct request Request; struct request{ str raw; str first_line; str method, path, version; str query; str body; str sec_websocket_key; str authorization; int fd; bool err; bool header_complete; bool keepalive; int64_t content_length; }; // for simple http responses without custom headers static void http_response(Request *rq, str const status, str const body){ MAKE_ARENA(a, 512); str header = str_printf(&a, HTTP_VERSION " %.*s" CRLF "content-type: " HTTP_CONTENT_TYPE_BLISP CRLF "content-length: %ld" CRLF CRLF , pstr(status), body.len ); unless(write_header_body(rq->fd, header, body)) warnsys("write_header_body http_response"); } static void http_error(Request *rq, str const status, str const msg){ MAKE_ARENA(a, 512); str const body = str_printf(&a, "#(:type 'error' #%d'%.*s')", (int)msg.len, pstr(msg)); str header = str_printf(&a, HTTP_VERSION " %.*s" CRLF "content-type: " HTTP_CONTENT_TYPE_BLISP CRLF "content-length: %ld" CRLF CRLF , pstr(status), body.len ); unless(write_header_body(rq->fd, header, body)) warnsys("write_header_body http_error"); } #include "ws.c" //Example: POST /auth/login HTTP/1.1 static bool http_split_first_line(Request *rq){ str_pair x = str_split_at(rq->first_line, ' '); rq->method = x.a; x = str_split_at(x.b, ' '); rq->path = x.a; rq->version = x.b; bool const split_ok = (rq->method.len && rq->path.len && rq->version.len); unless(split_ok) return false; x = str_split_at(rq->path, '?'); rq->path = x.a; rq->query = x.b; return (rq->path.len > 0); } // authorization header contains the BWT access token static str extract_bearer_token(arena *A, Request *rq) { debug("extract_bearer_token: rq->authorization [%.*s]\n", pstr(rq->authorization)); str const bearer_prefix = kstr("Bearer "); if(str_starts_with(rq->authorization, bearer_prefix)) { str token = str_drop(rq->authorization, bearer_prefix.len); token = str_trim(token); return str_new_from_buf(A, token.data, token.len); } return (str){0}; } // --- Session Credentials (BWT) // extract and store client credentials from BWT static bool store_bwt_credentials_from_bwt(Client *cli, Request *rq, str const token){ MAKE_ARENA(token_arena, 512); int64_t expiry; time_t const now = time(NULL); str user_id = verify_bwt(&token_arena, token, secret_key, &expiry, now); if (user_id.len == 0) { http_error(rq, kstr("401 Unauthorized"), kstr("Unauthorized")); return false; } str username = db_get_username(&cli->perm, user_id); if (username.len == 0) { http_error(rq, kstr("500 Internal Server Error"), kstr("Failed to get user info")); return false; } // Store in client (must be persistent - copy to client's permanent arena) cli->user_id = str_new_from_buf(&cli->perm, user_id.data, user_id.len); cli->username = username; // already in cli->perm arena cli->expiry_monotonic_ms = epoch_to_monotonic_ms(expiry); return true; } static bool store_bwt_credentials(Client *cli, Request *rq){ MAKE_ARENA(token_arena, 512); str token = extract_bearer_token(&token_arena, rq); return store_bwt_credentials_from_bwt(cli, rq, token); } // POST /auth/login static void handle_login(Client* cli, Request *rq) { // Parse BLISP body bl_result username = bl_find_map_string(rq->body, kstr("username")); bl_result password = bl_find_map_string(rq->body, kstr("password")); unless(username.ok && password.ok){ http_error(rq, kstr("400 Bad Request"), kstr("Missing username or password")); return; } MAKE_STD_ARENA(perm); MAKE_STD_ARENA(scratch); str user_id; str user_blisp = db_authenticate_user(&perm, &scratch, username.s, password.s, &user_id); if (user_blisp.len == 0 || user_id.len == 0) { http_error(rq, kstr("401 Unauthorized"), kstr("Invalid credentials")); return; } // Create secure token using the user_id time_t const now = time(NULL); str const token = create_bwt(&perm, secret_key, user_id, bwt_expiry_time_secs, now); str response = str_printf(cli->A, "#(:token #%u'%.*s' :user %.*s)\n", token.len, pstr(token), pstr(user_blisp)); http_response(rq, kstr("200 OK"), response); } // GET /rooms static void handle_rooms(Client* cli, Request *rq) { MAKE_STD_ARENA(perm); MAKE_STD_ARENA(scratch); str rooms_blisp = db_get_user_rooms(&perm, &scratch, cli->user_id); if (rooms_blisp.len == 0) { http_error(rq, kstr("500 Internal Server Error"), kstr("Internal server error")); return; } http_response(rq, kstr("200 OK"), rooms_blisp); } // POST /users/query - Get user info for multiple IDs static void handle_users(Client *cli, Request *rq) { str user_id_list = rq->body; str const user_info_list = db_get_user_info(cli->A, NULL, user_id_list); http_response(rq, kstr("200 OK"), user_info_list); } // GET /rooms/:roomId/messages static void handle_messages(Client *cli, Request *rq) { assert(str_starts_with(rq->path, API_PATH_ROOMS_PLUS) && str_ends_with(rq->path, kstr("/messages"))); MAKE_ARENA(perm, big_arena_size); MAKE_ARENA(scratch, big_arena_size); // Parse room_id from path (rq->path is like "/rooms/general/messages") str path = rq->path; // Remove "/rooms/" path = str_drop(path, KSTR_LEN("/rooms/")); str_pair split = str_split_at(path, '/'); str room_id = split.a; // split.b is "/messages" - ignore // Parse query parameters str before_ulid = (str){0}; int limit = 50; // TODO: parse rq->query for "before" and "limit" str blisp_msgs = db_get_messages(&perm, &scratch, room_id, before_ulid, limit); if (blisp_msgs.len == 0) { http_error(rq, kstr("500 Internal Server Error"), kstr("Failed to fetch messages")); return; } http_response(rq, kstr("200 OK"), blisp_msgs); } // POST /auth/change-password static void handle_change_password(Client* cli, Request *rq) { MAKE_STD_ARENA(perm); MAKE_STD_ARENA(scratch); bl_result type = bl_find_map_string(rq->body, kstr("type")); bl_result old = bl_find_map_string(rq->body, kstr("old")); bl_result new = bl_find_map_string(rq->body, kstr("new")); if (!type.ok || !type.s.len || !str_eq(type.s, kstr("password.change"))) { http_error(rq, kstr("400 Bad Request"), kstr("Invalid request type")); return; } if (!old.ok || !new.ok) { http_error(rq, kstr("400 Bad Request"), kstr("Missing old or new password")); return; } if (new.s.len < 4) { http_error(rq, kstr("400 Bad Request"), kstr("New password must be at least 4 characters")); return; } if (!db_change_user_password(&perm, &scratch, cli->user_id, old.s, new.s)) { http_error(rq, kstr("400 Bad Request"), kstr("Password change failed")); return; } str response = str_printf(cli->A, "#(:type 'password.updated')\n"); http_response(rq, kstr("200 OK"), response); } // GET /store/:id - Retrieve uploaded file data static void handle_get_store_file(Client* cli, Request *rq) { assert(str_starts_with(rq->path, API_PATH_STORE)); // Parse attachment_id from path (rq->path is like "/store/06ERTR7BZ7527YG8QBFM8Y2BWR") str path = rq->path; str attachment_id = str_drop(path, KSTR_LEN("/store/")); if (attachment_id.len == 0) { http_error(rq, kstr("400 Bad Request"), kstr("Missing attachment id")); return; } // Query database for attachment const char *sql = "SELECT data, mime_type FROM attachments WHERE id = ?;"; sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { warn("handle_store_file: prepare failed: %s\n", sqlite3_errmsg(g_db)); http_error(rq, kstr("500 Internal Server Error"), kstr("Database error")); return; } sqlite3_bind_text(stmt, 1, (const char*)attachment_id.data, attachment_id.len, SQLITE_STATIC); if (sqlite3_step(stmt) != SQLITE_ROW) { sqlite3_finalize(stmt); http_error(rq, kstr("404 Not Found"), kstr("File not found")); return; } // Get BLOB data str const data = str_from_buf(sqlite3_column_blob(stmt, 0), sqlite3_column_bytes(stmt, 0)); str const mime_type = str_from_buf(sqlite3_column_text(stmt, 1), sqlite3_column_bytes(stmt, 1)); unless(data.len) { sqlite3_finalize(stmt); http_error(rq, kstr("404 Not Found"), kstr("empty file found")); return; } // Build HTTP response with binary body (no BLISP wrapper) str reply; if (mime_type.len){ reply = str_printf(cli->A, HTTP_VERSION " 200 OK" CRLF "content-type: %.*s" CRLF "content-length: %ld" CRLF CRLF , pstr(mime_type), data.len); } else { reply = str_printf(cli->A, HTTP_VERSION " 200 OK" CRLF "content-type: application/octet-stream" CRLF "content-length: %ld" CRLF CRLF , data.len); } unless(write_header_body(rq->fd, reply, data)){ warnsys("write_header_body"); sqlite3_finalize(stmt); return; } sqlite3_finalize(stmt); } static void handle_websocket_upgrade(Client *cli, Request *rq){ MAKE_ARENA(token_arena, 4*1024); unless(str_starts_with(rq->query, API_WEBSOCKET_UPGRADE_QUERY_PARAM)){ http_error(rq, kstr("400 Bad Request"), kstr("missing token")); return; } str const token = str_drop(rq->query, (API_WEBSOCKET_UPGRADE_QUERY_PARAM).len); unless(store_bwt_credentials_from_bwt(cli, rq, token)){ http_error(rq, kstr("401 Unauthorized"), kstr("Invalid token")); return; } unless(rq->sec_websocket_key.len){ http_error(rq, kstr("400 Bad Request"), kstr("missing websocket key")); return; } str const reply_token = compute_websocket_accept(cli->A, rq->sec_websocket_key); debug("ws in: [%.*s] out(len:%ld): [%.*s]\n", pstr(rq->sec_websocket_key), reply_token.len, pstr(reply_token)); str reply = str_printf(cli->A, HTTP_VERSION " 101 Switching Protocols" CRLF "Upgrade: websocket" CRLF "Connection: Upgrade" CRLF "Sec-WebSocket-Accept: %.*s" CRLF CRLF , pstr(reply_token)); unless(write_all(rq->fd, reply.data, reply.len)){ warnsys("write_all handle_websocket_upgrade"); return; } cli->is_websocket = true; } // --- file uploads typedef struct { arena *A; str response; } handle_file_upload_closure; static void handle_file_upload_cb(void *opaque, int index, str name, str mime_type, str filename, str databytes){ handle_file_upload_closure *c = opaque; debug("name: [%.*s]\n", pstr(name)); debug("mime_type: [%.*s]\n", pstr(mime_type)); debug("filename: [%.*s]\n", pstr(filename)); debug("databytes.len: %ld\n", databytes.len); unless(mime_type.len && filename.len && databytes.len){ debug("upload missing some parameters (mime_type: [%.*s], filename: [%.*s], databytes.len: [%ld]])\n", pstr(mime_type), pstr(filename), databytes.len); return; } MAKE_ARENA(scratch, 128); // create the upload id and return the store url str const upload_id = generate_ulid(&scratch); if (0 == upload_id.len){ debug("file upload generate ulid failed message not stored in db\n"); return; } str const url = str_printf(&scratch, "/store/%.*s", pstr(upload_id)); if(!db_insert_attachment(upload_id, mime_type, filename, url, databytes)) { debug("db_insert_attachment failed [err: %s]\n", db_errmsg()); return; } if(index>0) c->response = str_printf_append(c->A, c->response, " "); c->response = str_printf_append(c->A, c->response, "#(:type 'uploaded' :id '%.*s' :url '%.*s')", pstr(upload_id), pstr(url)); } static void handle_file_upload(Client *cli, Request *rq){ MAKE_STD_ARENA(perm); handle_file_upload_closure closure = { .A=&perm, .response = str_printf(&perm, "("), }; int const nparts = http_parse_multipart_formdata(rq->body, handle_file_upload_cb, &closure); if(0 == nparts){ http_error(rq, kstr("400 Bad Request"), kstr("failed upload")); return; } closure.response = str_printf_append(&perm, closure.response, ")\n"); http_response(rq, kstr("200 OK"), closure.response); } // --- avatar uploads enum{max_avatar_image_size = 32*1024}; typedef struct { arena *A; str user_id; str mime_type; str avatar_id; str url; str err; } handle_avatar_upload_closure; static void handle_avatar_upload_cb(void *opaque, int index, str name, str mime_type, str filename, str databytes){ handle_avatar_upload_closure *c = opaque; if(c->err.len) return; if(index>0){ warn("handle_avatar_upload_cb[%d] - there should only be one avatar in upload\n", index); c->err = kstr("too many images"); return; } debug("name: [%.*s]\n", pstr(name)); debug("mime_type: [%.*s]\n", pstr(mime_type)); debug("filename: [%.*s]\n", pstr(filename)); debug("databytes.len: %ld\n", databytes.len); unless(mime_type.len && filename.len && databytes.len){ debug("avatar upload missing some parameters (mime_type: [%.*s], filename: [%.*s], databytes.len: [%ld]])\n", pstr(mime_type), pstr(filename), databytes.len); c->err = kstr("missing parameters"); return; } if(databytes.len > max_avatar_image_size){ debug("avatar image too large (max: %d)\n", max_avatar_image_size); c->err = kstr("too large"); return; } c->mime_type = str_new_from_buf(c->A, mime_type.data, mime_type.len); // create the upload id and return the store url c->avatar_id = generate_ulid(c->A); if (0 == c->avatar_id.len){ debug("avatar upload generate ulid failed message not stored in db\n"); c->err = kstr("genulid failed"); return; } c->url = str_printf(c->A, "/avatar/%.*s", pstr(c->avatar_id)); if(!db_update_avatar(c->avatar_id, c->user_id, mime_type, c->url, databytes)) { debug("db_update_avatar failed [err: %s]\n", db_errmsg()); c->err = kstr("database error"); return; } } static void handle_avatar_upload(Client *cli, Request *rq){ MAKE_STD_ARENA(perm); handle_avatar_upload_closure closure = { .A=&perm, .user_id = cli->user_id, }; int const nparts = http_parse_multipart_formdata(rq->body, handle_avatar_upload_cb, &closure); if(1 != nparts || closure.err.len){ http_error(rq, kstr("400 Bad Request"), closure.err.len? closure.err : kstr("failed upload")); return; } str const response = str_printf(&perm, "#(:type 'avatar_uploaded' :user_id '%.*s' :mime_type #%d'%.*s' :avatar_url #%d'%.*s')" , pstr(closure.user_id) , closure.mime_type.len, pstr(closure.mime_type) , closure.url.len, pstr(closure.url) ); http_response(rq, kstr("200 OK"), response); } // GET /avatar/:id - Retrieve avatar image static void handle_get_avatar(Client *cli, Request *rq) { (void)cli; // Parse avatar_id from path (rq->path is like "/avatar/06ERTR7BZ7527YG8QBFM8Y2BWR") str path = rq->path; unless(str_starts_with(path, API_PATH_AVATAR_GET)) { http_error(rq, kstr("400 Bad Request"), kstr("Invalid avatar path")); return; } str avatar_id = str_drop(path, API_PATH_AVATAR_GET.len); if (avatar_id.len == 0) { http_error(rq, kstr("400 Bad Request"), kstr("Missing avatar id")); return; } // Query database for avatar by URL const char *sql = "SELECT data, mime_type FROM avatars WHERE id = ?;"; sqlite3_stmt *stmt = NULL; int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL); if (rc != SQLITE_OK) { warn("handle_get_avatar: prepare failed: %s\n", sqlite3_errmsg(g_db)); http_error(rq, kstr("500 Internal Server Error"), kstr("Database error")); return; } sqlite3_bind_text(stmt, 1, (const char*)avatar_id.data, avatar_id.len, SQLITE_STATIC); debug("handle_get_avatar url [%.*s] avatar_id [%.*s]\n", pstr(path), pstr(avatar_id)); if (sqlite3_step(stmt) != SQLITE_ROW) { sqlite3_finalize(stmt); http_error(rq, kstr("404 Not Found"), kstr("Avatar not found")); return; } // Get BLOB data - these pointers are valid until sqlite3_finalize str const data = str_from_buf(sqlite3_column_blob(stmt, 0), sqlite3_column_bytes(stmt, 0)); str const mime_type = str_from_buf(sqlite3_column_text(stmt, 1), sqlite3_column_bytes(stmt, 1)); unless(data.len) { sqlite3_finalize(stmt); http_error(rq, kstr("404 Not Found"), kstr("empty avatar data")); return; } // Build HTTP response with binary body (no BLISP wrapper) str reply; if (mime_type.len){ reply = str_printf(cli->A, HTTP_VERSION " 200 OK" CRLF "content-type: %.*s" CRLF "content-length: %ld" CRLF CRLF , pstr(mime_type), data.len); } else { reply = str_printf(cli->A, HTTP_VERSION " 200 OK" CRLF "content-type: image/png" CRLF // fallback "content-length: %ld" CRLF CRLF , data.len); } // Write headers then body unless(write_header_body(rq->fd, reply, data)){ warnsys("write_header_body"); sqlite3_finalize(stmt); return; } sqlite3_finalize(stmt); } // main http request dispatch static void http_dispatch(Client *cli, str const rqbuf) { (void)db_get_unread_count; (void)db_mark_message_read; Request rq[1] = {{.fd=cli->fd, .raw=rqbuf}}; Headerator headerator[1] = {headerator_init(rqbuf)}; unless(headerator_next(headerator)) { http_error(rq, kstr("400 Bad Request"), kstr("header missing first line")); return; } rq->first_line = headerator->line; unless(http_split_first_line(rq)) { http_error(rq, kstr("400 Bad Request"), kstr("invalid http first line")); return; } if(str_starts_with(rq->path, API_PATH_PREFIX)) rq->path = str_drop(rq->path, (API_PATH_PREFIX).len); while(headerator_next(headerator)){ str const line = headerator->line; if(0==line.len) break; if(str_starts_with_ci(line, HTTP_CONTENT_LENGTH_PREFIX)){ str const v = str_drop(line, (HTTP_CONTENT_LENGTH_PREFIX).len); str const x = bl_parse_integer_(v, 10, &rq->content_length); if(str_eq(v, x)){ http_error(rq, kstr("400 Bad Request"), kstr("bad content-length")); return; } rq->header_complete = true; } else if(str_starts_with_ci(line, HTTP_WEBSOCKET_REQUEST_TOKEN_PREFIX)){ rq->sec_websocket_key = str_drop(line, (HTTP_WEBSOCKET_REQUEST_TOKEN_PREFIX).len); } else if(str_starts_with_ci(line, HTTP_AUTHORIZATION_PREFIX)){ rq->authorization = str_drop(line, (HTTP_AUTHORIZATION_PREFIX).len); } } rq->body = headerator->rest; log("(fd:%d) method: %.*s path: %.*s content_length: %ld body.len: %ld\n", cli->fd, pstr(rq->method), pstr(rq->path), rq->content_length, rq->body.len); unless(rq->content_length == rq->body.len){ // request was too large, or maybe we received multiple requests in on read? TODO http_error(rq, kstr("413 Payload Too Large"), kstr("request too large")); // TODO should read the entire body and discard, but closing connection so it doesn't matter return; } // --- Authentication check (except for /auth/login, ws upgrade, href/img.src=/store/...) --- bool const auth_exempt = ( str_eq(rq->path, API_PATH_AUTH_LOGIN) ||str_eq(rq->path, API_WEBSOCKET_UPGRADE) ||str_starts_with(rq->path, API_PATH_STORE) ||str_starts_with(rq->path, API_PATH_AVATAR_GET) ); bool const auth_required = !auth_exempt; if(auth_required && !store_bwt_credentials(cli, rq)){ log("auth failed\n"); return; } // --- Dispatch REST endpoints --- if (str_eq(rq->method, kstr("POST")) && str_eq(rq->path, API_PATH_AUTH_LOGIN)){ handle_login(cli, rq); return; } if (str_eq(rq->method, kstr("GET")) && str_eq(rq->path, API_PATH_ROOMS)){ handle_rooms(cli, rq); return; } if (str_eq(rq->method, kstr("POST")) && str_eq(rq->path, API_PATH_USERS)){ handle_users(cli, rq); return; } if (str_eq(rq->method, kstr("GET")) && str_starts_with(rq->path, API_PATH_ROOMS_PLUS) && str_ends_with(rq->path, kstr("/messages"))) { handle_messages(cli, rq); return; } if (str_eq(rq->method, kstr("POST")) && str_eq(rq->path, API_AUTH_CHANGE_PASSWORD)){ handle_change_password(cli, rq); return; } if (str_eq(rq->method, kstr("GET")) && str_eq(rq->path, API_WEBSOCKET_UPGRADE)){ handle_websocket_upgrade(cli, rq); return; } // POST /uploads if (str_eq(rq->method, kstr("POST")) && str_eq(rq->path, API_PATH_UPLOADS)){ handle_file_upload(cli, rq); return; } if (str_eq(rq->method, kstr("GET")) && str_starts_with(rq->path, API_PATH_STORE)){ handle_get_store_file(cli, rq); return; } if (str_eq(rq->method, kstr("POST")) && str_eq(rq->path, API_PATH_AVATAR_UPLOAD)){ handle_avatar_upload(cli, rq); return; } if (str_eq(rq->method, kstr("GET")) && str_starts_with(rq->path, API_PATH_AVATAR_GET)){ handle_get_avatar(cli, rq); return; } http_error(rq, kstr("404 Not Found"), kstr("not found")); }