#include "http_api.h" #include #include #include #include #include "state_presenter.h" enum { kErrorMessageBytes = 80, kErrorResponseBytes = 160 }; typedef struct { const char *text; size_t length; } json_reader_t; static void skip_space(json_reader_t *reader) { while (reader->length > 0U && (*reader->text == ' ' || *reader->text == '\n' || *reader->text == '\r' || *reader->text == '\t')) { ++reader->text; --reader->length; } } static bool take_character(json_reader_t *reader, char expected) { skip_space(reader); if (reader->length == 0U || *reader->text != expected) return false; ++reader->text; --reader->length; return true; } static bool parse_string(json_reader_t *reader, char *output, size_t output_size) { if (!take_character(reader, '"') || output_size == 0U) return false; size_t written = 0; while (reader->length > 0U && *reader->text != '"') { unsigned char character = (unsigned char)*reader->text++; --reader->length; if (character < 0x20U) return false; if (character == '\\') { if (reader->length == 0U) return false; const char escaped = *reader->text++; --reader->length; if (escaped == '"' || escaped == '\\' || escaped == '/') character = (unsigned char)escaped; else if (escaped == 'b') character = '\b'; else if (escaped == 'f') character = '\f'; else if (escaped == 'n') character = '\n'; else if (escaped == 'r') character = '\r'; else if (escaped == 't') character = '\t'; else return false; } if (written + 1U >= output_size) return false; output[written++] = (char)character; } if (reader->length == 0U) return false; ++reader->text; --reader->length; output[written] = '\0'; return true; } static bool parse_u32(json_reader_t *reader, uint32_t *value) { skip_space(reader); if (reader->length == 0U || *reader->text < '0' || *reader->text > '9') return false; uint32_t parsed = 0; do { const uint8_t digit = (uint8_t)(*reader->text - '0'); if (parsed > (UINT32_MAX - digit) / 10U) return false; parsed = parsed * 10U + digit; ++reader->text; --reader->length; } while (reader->length > 0U && *reader->text >= '0' && *reader->text <= '9'); *value = parsed; return true; } static bool parse_object_start(json_reader_t *reader) { return take_character(reader, '{'); } static bool parse_next_key(json_reader_t *reader, bool *first, char *key, size_t key_size) { skip_space(reader); if (!*first && !take_character(reader, ',')) return false; *first = false; if (!parse_string(reader, key, key_size)) return false; return take_character(reader, ':'); } static bool parse_object_end(json_reader_t *reader) { if (!take_character(reader, '}')) return false; skip_space(reader); return reader->length == 0U; } static bool parse_token(const char *text, uint8_t token[kSessionTokenBytes]) { if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false; for (uint8_t index = 0; index < kSessionTokenBytes; ++index) { const char high = text[index * 2U]; const char low = text[index * 2U + 1U]; if (high < '0' || (high > '9' && high < 'a') || high > 'f' || low < '0' || (low > '9' && low < 'a') || low > 'f') return false; const uint8_t high_value = (uint8_t)(high <= '9' ? high - '0' : high - 'a' + 10); const uint8_t low_value = (uint8_t)(low <= '9' ? low - '0' : low - 'a' + 10); token[index] = (uint8_t)((high_value << 4U) | low_value); } return true; } static void token_text(const uint8_t token[kSessionTokenBytes], char output[kSessionTokenBytes * 2U + 1U]) { static const char hex[] = "0123456789abcdef"; for (uint8_t index = 0; index < kSessionTokenBytes; ++index) { output[index * 2U] = hex[token[index] >> 4U]; output[index * 2U + 1U] = hex[token[index] & 0x0fU]; } output[kSessionTokenBytes * 2U] = '\0'; } static const char *role_text(role_t role) { return role == ROLE_PLAYER_1 ? "player1" : role == ROLE_PLAYER_2 ? "player2" : "spectator"; } static const char *phase_text(phase_t phase) { static const char *const values[] = {"lobby", "preparing", "in_progress", "finished", "rematch_wait"}; return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby"; } static const char *recovery_reason_text(recovery_reason_t reason) { switch (reason) { case RECOVERY_REASON_SESSION_LEFT: return "session_left"; case RECOVERY_REASON_PROFILE_RESET: return "profile_reset"; case RECOVERY_REASON_GAME_RESET: return "game_reset"; default: return "session_invalidated"; } } static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) { va_list arguments; va_start(arguments, format); const int length = vsnprintf(response->body, sizeof(response->body), format, arguments); va_end(arguments); response->status = length >= 0 && (size_t)length < sizeof(response->body) ? status : 500U; response->body_length = response->status == status ? (size_t)length : 0U; if (response->status != status) response->body[0] = '\0'; } static void response_error(http_api_response_t *response, uint16_t status, const char *code, const char *message, uint32_t version) { response_write(response, status, "{\"ok\":false,\"code\":\"%s\",\"message\":\"%s\",\"version\":%" PRIu32 "}", code, message, version); if (response->body_length >= kErrorResponseBytes) response_write(response, 500U, "{\"ok\":false,\"code\":\"SERVER_BUSY\",\"message\":\"Ошибка сервера\",\"version\":0}"); } static void response_lifecycle_error(http_api_response_t *response, lifecycle_result_t result, uint32_t version) { switch (result) { case LIFECYCLE_RESULT_INVALID_NAME: response_error(response, 400U, "INVALID_NAME", "Некорректное имя", version); break; case LIFECYCLE_RESULT_INVALID_ROLE: response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); break; case LIFECYCLE_RESULT_INVALID_MODE: response_error(response, 400U, "INVALID_MODE", "Некорректный режим", version); break; case LIFECYCLE_RESULT_INVALID_COORDINATE: response_error(response, 400U, "INVALID_COORDINATE", "Некорректные координаты", version); break; case LIFECYCLE_RESULT_NO_PLAYER_SLOT: response_error(response, 409U, "NO_PLAYER_SLOT", "Нет места игрока", version); break; case LIFECYCLE_RESULT_NO_SPECTATOR_SLOT: response_error(response, 409U, "NO_SPECTATOR_SLOT", "Нет места зрителя", version); break; case LIFECYCLE_RESULT_FORBIDDEN_ROLE: response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); break; case LIFECYCLE_RESULT_WRONG_PHASE: response_error(response, 409U, "WRONG_PHASE", "Действие недоступно сейчас", version); break; case LIFECYCLE_RESULT_NOT_YOUR_TURN: response_error(response, 409U, "NOT_YOUR_TURN", "Сейчас ход соперника", version); break; case LIFECYCLE_RESULT_CELL_ALREADY_SHOT: response_error(response, 409U, "CELL_ALREADY_SHOT", "Клетка уже обстреляна", version); break; case LIFECYCLE_RESULT_STALE_GAME: response_error(response, 409U, "STALE_GAME", "Партия уже изменилась", version); break; case LIFECYCLE_RESULT_UNAUTHORIZED: response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); break; default: response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version); break; } } static void response_recovery(http_api_response_t *response, const game_lifecycle_t *lifecycle) { response_write(response, 200U, "{\"ok\":true,\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}", recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation); } static void response_invalidated(http_api_response_t *response, const game_lifecycle_t *lifecycle) { response_write(response, 401U, "{\"ok\":false,\"code\":\"SESSION_INVALIDATED\",\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}", recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation); } static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response, uint32_t version) { if (request->method != HTTP_API_POST || !request->content_type_json) { response_error(response, 400U, "MALFORMED_JSON", "Ожидается JSON запрос", version); return false; } if (request->body == NULL || request->body_length > maximum) { response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version); return false; } return true; } static bool parse_join(const http_api_request_t *request, char name[kDisplayNameBytes + 1U], role_t *role) { json_reader_t reader = {.text = request->body, .length = request->body_length}; char key[16]; char role_value[16]; bool first = true; uint8_t fields = 0; if (!parse_object_start(&reader)) return false; while (reader.length > 0U && *reader.text != '}') { if (!parse_next_key(&reader, &first, key, sizeof(key))) return false; if (strcmp(key, "name") == 0 && (fields & 1U) == 0U) { if (!parse_string(&reader, name, kDisplayNameBytes + 1U)) return false; fields |= 1U; } else if (strcmp(key, "requestedRole") == 0 && (fields & 2U) == 0U) { if (!parse_string(&reader, role_value, sizeof(role_value))) return false; fields |= 2U; } else return false; } if (!parse_object_end(&reader) || fields != 3U) return false; if (strcmp(role_value, "player1") == 0) *role = ROLE_PLAYER_1; else if (strcmp(role_value, "player2") == 0) *role = ROLE_PLAYER_2; else if (strcmp(role_value, "spectator") == 0) *role = ROLE_SPECTATOR; else if (strcmp(role_value, "player") == 0) *role = ROLE_AUTO_PLAYER; else *role = (role_t)UINT8_MAX; return true; } static bool parse_token_only(const http_api_request_t *request, char token[kSessionTokenBytes * 2U + 1U]) { json_reader_t reader = {.text = request->body, .length = request->body_length}; char key[16]; bool first = true; if (!parse_object_start(&reader) || !parse_next_key(&reader, &first, key, sizeof(key)) || strcmp(key, "token") != 0 || !parse_string(&reader, token, kSessionTokenBytes * 2U + 1U)) return false; return parse_object_end(&reader); } static bool parse_command_with_token(const http_api_request_t *request, app_command_t *command, bool needs_mode, bool needs_coordinate, uint8_t token[kSessionTokenBytes]) { char body_token[kSessionTokenBytes * 2U + 1U] = {0}; /* Parse into a temporary command first; token bytes never occupy command storage. */ json_reader_t reader = {.text = request->body, .length = request->body_length}; char key[16]; char mode[8] = {0}; bool first = true; uint8_t fields = 0; uint32_t x = 0; uint32_t y = 0; if (!parse_object_start(&reader)) return false; while (reader.length > 0U && *reader.text != '}') { if (!parse_next_key(&reader, &first, key, sizeof(key))) return false; if (strcmp(key, "token") == 0 && (fields & 1U) == 0U) { if (!parse_string(&reader, body_token, sizeof(body_token))) return false; fields |= 1U; } else if (strcmp(key, "gameId") == 0 && (fields & 2U) == 0U) { if (!parse_u32(&reader, &command->game_id)) return false; fields |= 2U; } else if (needs_mode && strcmp(key, "mode") == 0 && (fields & 4U) == 0U) { if (!parse_string(&reader, mode, sizeof(mode))) return false; fields |= 4U; } else if (needs_coordinate && strcmp(key, "x") == 0 && (fields & 4U) == 0U) { if (!parse_u32(&reader, &x)) return false; fields |= 4U; } else if (needs_coordinate && strcmp(key, "y") == 0 && (fields & 8U) == 0U) { if (!parse_u32(&reader, &y)) return false; fields |= 8U; } else return false; } const uint8_t expected = needs_coordinate ? 15U : needs_mode ? 7U : 3U; if (!parse_object_end(&reader) || fields != expected || !parse_token(body_token, token)) return false; if (needs_mode) { if (strcmp(mode, "human") == 0) command->mode = MODE_HUMAN; else if (strcmp(mode, "bot") == 0) command->mode = MODE_BOT; else command->mode = (game_mode_t)UINT8_MAX; } if (needs_coordinate) { command->coordinate = x >= kBoardWidth || y >= kBoardHeight ? (coordinate_t){.x = kBoardWidth, .y = 0U} : (coordinate_t){.x = (uint8_t)x, .y = (uint8_t)y}; } return true; } void http_api_init(http_api_t *api, application_t *application) { if (api == NULL) return; *api = (http_api_t){.application = application, .health = {.wifi_state = "connecting"}}; } void http_api_set_health(http_api_t *api, const http_api_health_t *health) { if (api != NULL && health != NULL) api->health = *health; } bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_api_response_t *response) { if (api == NULL || api->application == NULL || request == NULL || response == NULL) return false; *response = (http_api_response_t){0}; game_lifecycle_t *lifecycle = &api->application->lifecycle; const uint32_t version = lifecycle->game.state.version; if (request->target_too_large) { response_error(response, 413U, "PAYLOAD_TOO_LARGE", "Слишком большой запрос", version); return true; } if (request->route == HTTP_API_ROUTE_INFO && request->method == HTTP_API_GET) { const session_manager_t *sessions = &lifecycle->sessions; uint8_t spectators = 0; for (uint8_t index = kPlayerCapacity; index < kSessionCapacity; ++index) spectators += sessions->entries[index].occupied; response_write(response, 200U, "{\"ok\":true,\"phase\":\"%s\",\"gameId\":%" PRIu32 ",\"version\":%" PRIu32 ",\"player1Available\":%s,\"player2Available\":%s,\"player1Name\":\"%s\",\"player2Name\":\"%s\",\"spectatorsAvailable\":%u}", phase_text(lifecycle->game.state.phase), lifecycle->game.state.game_id, version, sessions->entries[0].occupied ? "false" : "true", sessions->entries[1].occupied ? "false" : "true", sessions->entries[0].occupied ? sessions->entries[0].name : "", sessions->entries[1].occupied ? sessions->entries[1].name : "", (unsigned int)(kSpectatorCapacity - spectators)); return response->body_length <= 384U; } if (request->route == HTTP_API_ROUTE_HEALTH && request->method == HTTP_API_GET) { response_write(response, 200U, "{\"ok\":true,\"uptimeMs\":%" PRIu32 ",\"wifiState\":\"%s\",\"freeHeapBytes\":%" PRIu32 ",\"minimumFreeHeapBytes\":%" PRIu32 ",\"largestFreeBlockBytes\":%" PRIu32 ",\"connectedClients\":%u,\"rejectedInput\":%u,\"resetReason\":%d}", api->health.uptime_ms, api->health.wifi_state == NULL ? "unknown" : api->health.wifi_state, api->health.free_heap_bytes, api->health.minimum_free_heap_bytes, api->health.largest_free_block_bytes, api->health.connected_clients, api->health.rejected_input, api->health.reset_reason); return response->body_length <= 320U; } if (request->route == HTTP_API_ROUTE_STATE && request->method == HTTP_API_GET) { role_t viewer = ROLE_SPECTATOR; uint8_t token[kSessionTokenBytes]; uint8_t session_index = 0; if (request->session_token != NULL && request->session_token[0] != '\0') { if (!parse_token(request->session_token, token)) { response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); return true; } if (!application_session_for_token(api->application, token, &session_index)) { response_invalidated(response, lifecycle); return true; } viewer = lifecycle->sessions.entries[session_index].role; } if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) { response_error(response, 503U, "SERVER_BUSY", "Сервер занят", version); } else response->status = 200U; return true; } if (request->route == HTTP_API_ROUTE_STATISTICS && request->method == HTTP_API_GET) { role_t viewer = ROLE_SPECTATOR; uint8_t token[kSessionTokenBytes]; uint8_t session_index = 0; if (request->session_token != NULL && request->session_token[0] != '\0') { if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) { response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); return true; } viewer = lifecycle->sessions.entries[session_index].role; } const match_statistics_t *match = lifecycle->game.state.statistics; const cumulative_statistics_t *total = lifecycle->cumulative; response_write(response, 200U, "{\"ok\":true,\"viewer\":\"%s\",\"gameId\":%" PRIu32 ",\"match\":[[%u,%u,%u,%u],[%u,%u,%u,%u]],\"cumulative\":[[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32 "],[%u,%u,%u,%u,%" PRIu32 ",%" PRIu32 ",%" PRIu32 "]]}", role_text(viewer), lifecycle->game.state.game_id, match[0].shots, match[0].hits, match[0].misses, match[0].ships_sunk, match[1].shots, match[1].hits, match[1].misses, match[1].ships_sunk, total[0].games, total[0].wins, total[0].losses, total[0].ships_sunk, total[0].shots, total[0].hits, total[0].misses, total[1].games, total[1].wins, total[1].losses, total[1].ships_sunk, total[1].shots, total[1].hits, total[1].misses); return response->body_length < sizeof(response->body); } if (request->route == HTTP_API_ROUTE_JOIN) { char name[kDisplayNameBytes + 1U] = {0}; role_t role = ROLE_SPECTATOR; if (!expect_post_body(request, 192U, response, version)) return true; if (!parse_join(request, name, &role)) { response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version); return true; } if (role > ROLE_AUTO_PLAYER) { response_error(response, 400U, "INVALID_ROLE", "Некорректная роль", version); return true; } uint8_t session_index = 0; const lifecycle_result_t result = application_join(api->application, role, name, &session_index); if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version); else { char token[kSessionTokenBytes * 2U + 1U]; token_text(lifecycle->sessions.entries[session_index].token, token); response_write(response, 200U, "{\"ok\":true,\"token\":\"%s\",\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}", token, role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version, lifecycle->game.state.game_id); } return true; } if (request->route == HTTP_API_ROUTE_RESUME) { char token_text_value[kSessionTokenBytes * 2U + 1U] = {0}; uint8_t token[kSessionTokenBytes]; if (!expect_post_body(request, 96U, response, version)) return true; if (!parse_token_only(request, token_text_value) || !parse_token(token_text_value, token)) { response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version); return true; } uint8_t session_index = 0; const lifecycle_result_t result = application_resume(api->application, token, &session_index); if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version); else response_write(response, 200U, "{\"ok\":true,\"role\":\"%s\",\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}", role_text(lifecycle->sessions.entries[session_index].role), lifecycle->game.state.version, lifecycle->game.state.game_id); return true; } app_command_t command = {0}; uint8_t token[kSessionTokenBytes]; bool needs_mode = request->route == HTTP_API_ROUTE_CONFIG; bool needs_coordinate = request->route == HTTP_API_ROUTE_SHOT; size_t maximum = needs_coordinate || needs_mode ? 96U : 80U; if (!expect_post_body(request, maximum, response, version)) return true; if (!parse_command_with_token(request, &command, needs_mode, needs_coordinate, token)) { response_error(response, 400U, "MALFORMED_JSON", "Некорректный JSON", version); return true; } if (!application_session_for_token(api->application, token, &command.session_index)) { if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET) { response_recovery(response, lifecycle); return true; } if (request->route == HTTP_API_ROUTE_RESET && lifecycle->recovery_reason == RECOVERY_REASON_GAME_RESET) { response_recovery(response, lifecycle); return true; } if (request->route == HTTP_API_ROUTE_RESET) { response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version); return true; } response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version); return true; } switch (request->route) { case HTTP_API_ROUTE_CONFIG: command.type = COMMAND_CONFIG; break; case HTTP_API_ROUTE_START: command.type = COMMAND_START; break; case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break; case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break; case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break; case HTTP_API_ROUTE_LEAVE: command.type = COMMAND_LEAVE; break; case HTTP_API_ROUTE_PROFILE_RESET: command.type = COMMAND_PROFILE_RESET; break; case HTTP_API_ROUTE_RESET: command.type = COMMAND_RESET; break; default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true; } command.version = version; const lifecycle_result_t result = application_submit(api->application, &command); if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version); else if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET || request->route == HTTP_API_ROUTE_RESET) response_recovery(response, lifecycle); else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}", lifecycle->game.state.version, lifecycle->game.state.game_id); return true; }