trainlog/tui/src/web_server.c
2026-09-16 14:19:23 +02:00

519 lines
22 KiB
C

#include "trainlog/web_server.h"
#include <arpa/inet.h>
#include <errno.h>
#include <microhttpd.h>
#include <netinet/in.h>
#include <sys/random.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.h>
#include <time.h>
#include "trainlog/web_dashboard.h"
#include "trainlog/dashboard_layout.h"
#include "web_assets.h"
#define TRAINLOG_HTTP_CONNECTION_LIMIT 32U
#define TRAINLOG_HTTP_PER_IP_LIMIT 32U
#define TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT (16U * 1024U)
#define TRAINLOG_HTTP_HEADER_LIMIT (8U * 1024U)
#define TRAINLOG_HTTP_BODY_LIMIT (4U * 1024U)
#define TRAINLOG_HTTP_TIMEOUT_SECONDS 10U
#define TRAINLOG_HTTP_BACKLOG 32U
#define TRAINLOG_HTTP_POLL_MAX_MS 250ULL
typedef struct TrainlogWebContext {
uint16_t port;
TrainlogDatabase *database;
char csrf_token[65];
bool invalid_layout_reported;
} TrainlogWebContext;
typedef struct TrainlogHttpRequestState {
size_t body_size;
bool body_too_large;
char body[TRAINLOG_HTTP_BODY_LIMIT + 1U];
} TrainlogHttpRequestState;
static void web_diagnostic(char *output, size_t capacity, const char *message)
{
if (output != NULL && capacity > 0U)
(void)snprintf(output, capacity, "%s", message);
}
static enum MHD_Result queue_layout_json(struct MHD_Connection *connection,
unsigned int status, const char *body, uint64_t revision,
const char *csrf_token)
{
struct MHD_Response *response;
enum MHD_Result result;
char etag[32];
response = MHD_create_response_from_buffer(strlen(body), (void *)body,
MHD_RESPMEM_MUST_COPY);
if (response == NULL) return MHD_NO;
(void)snprintf(etag, sizeof(etag), "\"%llu\"",
(unsigned long long)revision);
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
"application/json; charset=utf-8") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
"no-store") != MHD_YES ||
MHD_add_response_header(response,
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
MHD_add_response_header(response, "Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG, etag) != MHD_YES ||
MHD_add_response_header(response, "X-Trainlog-CSRF-Token", csrf_token) != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION, "close") != MHD_YES) {
MHD_destroy_response(response); return MHD_NO;
}
result = MHD_queue_response(connection, status, response);
MHD_destroy_response(response); return result;
}
static enum MHD_Result queue_json(struct MHD_Connection *connection,
unsigned int status, const char *body, const char *allow)
{
struct MHD_Response *response;
enum MHD_Result result;
response = MHD_create_response_from_buffer(strlen(body), (void *)body,
MHD_RESPMEM_MUST_COPY);
if (response == NULL) return MHD_NO;
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
"application/json; charset=utf-8") != MHD_YES ||
MHD_add_response_header(response,
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
"no-store") != MHD_YES ||
MHD_add_response_header(response, "Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'") !=
MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION,
"close") != MHD_YES) {
MHD_destroy_response(response);
return MHD_NO;
}
if (allow != NULL && MHD_add_response_header(response,
MHD_HTTP_HEADER_ALLOW, allow) != MHD_YES) {
MHD_destroy_response(response);
return MHD_NO;
}
result = MHD_queue_response(connection, status, response);
MHD_destroy_response(response);
return result;
}
static enum MHD_Result queue_asset(struct MHD_Connection *connection,
const TrainlogWebAsset *asset)
{
static const char CSP[] = "default-src 'self'; script-src 'self'; "
"style-src 'self'; connect-src 'self'; img-src 'self'; "
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'";
const char *cache = asset->immutable
? "public, max-age=31536000, immutable" : "no-cache";
struct MHD_Response *response = MHD_create_response_from_buffer(asset->size,
(void *)asset->bytes, MHD_RESPMEM_PERSISTENT);
enum MHD_Result result;
if (response == NULL) return MHD_NO;
if (MHD_add_response_header(response, MHD_HTTP_HEADER_CONTENT_TYPE,
asset->mime) != MHD_YES ||
MHD_add_response_header(response,
MHD_HTTP_HEADER_X_CONTENT_TYPE_OPTIONS, "nosniff") != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CACHE_CONTROL,
cache) != MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_ETAG,
asset->etag) != MHD_YES ||
MHD_add_response_header(response, "Content-Security-Policy", CSP) !=
MHD_YES ||
MHD_add_response_header(response, MHD_HTTP_HEADER_CONNECTION,
"close") != MHD_YES) {
MHD_destroy_response(response);
return MHD_NO;
}
result = MHD_queue_response(connection, MHD_HTTP_OK, response);
MHD_destroy_response(response);
return result;
}
static bool is_ui_route(const char *url)
{
static const char *const routes[] = {
"/", "/analyse", "/programmes", "/seances", "/exercices"
};
size_t index;
for (index = 0U; index < sizeof(routes) / sizeof(routes[0]); ++index)
if (strcmp(url, routes[index]) == 0) return true;
return false;
}
static bool valid_host(const TrainlogWebContext *context, const char *host)
{
char expected[32];
int written;
if (host == NULL) return false;
if (strcmp(host, "127.0.0.1") == 0) return true;
written = snprintf(expected, sizeof(expected), "127.0.0.1:%u",
(unsigned int)context->port);
return written > 0 && (size_t)written < sizeof(expected) &&
strcmp(host, expected) == 0;
}
static bool declared_body_too_large(struct MHD_Connection *connection)
{
const char *value = MHD_lookup_connection_value(connection,
MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_LENGTH);
char *end = NULL;
unsigned long long size;
if (value == NULL) return false;
errno = 0;
size = strtoull(value, &end, 10);
return errno != 0 || end == value || *end != '\0' ||
size > TRAINLOG_HTTP_BODY_LIMIT;
}
static bool valid_origin(const TrainlogWebContext *context, const char *origin)
{
char direct[48];
if (origin == NULL) return false;
(void)snprintf(direct, sizeof(direct), "http://127.0.0.1:%u",
(unsigned int)context->port);
return strcmp(origin, direct) == 0 || strcmp(origin, "http://trainlog.perf") == 0;
}
static bool valid_csrf(const TrainlogWebContext *context, const char *provided)
{
size_t index; unsigned char difference = 0U;
if (provided == NULL || strlen(provided) != 64U) return false;
for (index = 0U; index < 64U; ++index)
difference |= (unsigned char)(provided[index] ^ context->csrf_token[index]);
return difference == 0U;
}
static bool generate_csrf_token(char output[65])
{
unsigned char random_bytes[32];
size_t used = 0U;
size_t index;
while (used < sizeof(random_bytes)) {
ssize_t count = getrandom(random_bytes + used, sizeof(random_bytes) - used, 0);
if (count < 0 && errno == EINTR) continue;
if (count <= 0) return false;
used += (size_t)count;
}
for (index = 0U; index < sizeof(random_bytes); ++index)
(void)snprintf(output + index * 2U, 3U, "%02x", random_bytes[index]);
return true;
}
static bool parse_if_match(const char *value, uint64_t *revision)
{
char *end = NULL; unsigned long long parsed;
if (value == NULL || value[0] != '"') return false;
errno = 0; parsed = strtoull(value + 1, &end, 10);
if (errno != 0 || end == value + 1 || end[0] != '"' || end[1] != '\0' ||
parsed > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) return false;
*revision = (uint64_t)parsed; return true;
}
static enum MHD_Result handle_request(void *closure,
struct MHD_Connection *connection, const char *url, const char *method,
const char *version, const char *upload_data, size_t *upload_data_size,
void **request_closure)
{
static const char HEALTH[] =
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"" TRAINLOG_VERSION "\"}\n";
TrainlogWebContext *context = closure;
TrainlogHttpRequestState *state = *request_closure;
const union MHD_ConnectionInfo *header_info;
const TrainlogWebAsset *asset;
const char *host;
bool is_get;
bool is_head;
(void)version;
if (state == NULL) {
state = calloc(1U, sizeof(*state));
if (state == NULL) return MHD_NO;
state->body_too_large = declared_body_too_large(connection);
*request_closure = state;
return MHD_YES;
}
if (*upload_data_size > 0U) {
if (*upload_data_size > TRAINLOG_HTTP_BODY_LIMIT -
(state->body_size > TRAINLOG_HTTP_BODY_LIMIT
? TRAINLOG_HTTP_BODY_LIMIT : state->body_size))
state->body_too_large = true;
else {
(void)memcpy(state->body + state->body_size, upload_data, *upload_data_size);
state->body_size += *upload_data_size;
state->body[state->body_size] = '\0';
}
*upload_data_size = 0U;
return MHD_YES;
}
header_info = MHD_get_connection_info(connection,
MHD_CONNECTION_INFO_REQUEST_HEADER_SIZE);
if (header_info == NULL || header_info->header_size >
TRAINLOG_HTTP_HEADER_LIMIT)
return queue_json(connection, MHD_HTTP_REQUEST_HEADER_FIELDS_TOO_LARGE,
"{\"error\":\"headers_too_large\"}\n", NULL);
if (state->body_too_large)
return queue_json(connection, MHD_HTTP_CONTENT_TOO_LARGE,
"{\"error\":\"body_too_large\"}\n", NULL);
host = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
MHD_HTTP_HEADER_HOST);
if (!valid_host(context, host))
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_host\"}\n", NULL);
is_get = strcmp(method, MHD_HTTP_METHOD_GET) == 0;
is_head = strcmp(method, MHD_HTTP_METHOD_HEAD) == 0;
if (strcmp(url, "/api/v1/health") == 0) {
if (!is_get)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET");
return queue_json(connection, MHD_HTTP_OK, HEALTH, NULL);
}
if (strcmp(url, "/api/v1/dashboard") == 0) {
TrainlogWebDashboardQuery query;
TrainlogWebDashboardSnapshot snapshot;
char json[TRAINLOG_WEB_JSON_CAPACITY];
size_t json_size;
time_t now;
if (!is_get)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET");
now = time(NULL);
if (now == (time_t)-1 || (int64_t)now != (int64_t)(time_t)now)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"clock_unavailable\"}\n", NULL);
query.reference_unix_second = (int64_t)now;
if (trainlog_web_dashboard_load(context->database, &query,
&snapshot) != TRAINLOG_STATUS_OK ||
trainlog_web_dashboard_serialize(&snapshot, json, sizeof(json),
&json_size) != TRAINLOG_STATUS_OK || json_size == 0U)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"dashboard_unavailable\"}\n", NULL);
return queue_json(connection, MHD_HTTP_OK, json, NULL);
}
if (strcmp(url, "/api/v1/dashboard-layout") == 0) {
TrainlogDashboardLayout layout;
TrainlogDashboardLayout saved;
TrainlogDashboardLayoutSource source;
TrainlogDashboardLayoutResult layout_result;
char json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY];
size_t json_size;
uint64_t expected;
const char *origin;
const char *csrf;
const char *if_match;
/* WHY: loopback alone does not stop a hostile webpage from submitting
* requests to a local service. CONTRACT: every mutation needs exact
* trusted Origin, the startup-random header token and an If-Match ETag.
* INVARIANT: proxy Host rewriting remains strict and no CORS is added. */
if (is_get) {
layout_result = trainlog_dashboard_layout_load(&layout, &source);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
!trainlog_dashboard_layout_serialize(&layout, source, true,
json, sizeof(json), &json_size) || json_size == 0U)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_unavailable\"}\n", NULL);
if (source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED &&
!context->invalid_layout_reported) {
(void)fprintf(stderr, "Trainlog Web : agencement persisté invalide, défaut utilisé\n");
context->invalid_layout_reported = true;
}
return queue_layout_json(connection, MHD_HTTP_OK, json,
layout.revision, context->csrf_token);
}
if (strcmp(method, MHD_HTTP_METHOD_PUT) != 0 &&
strcmp(method, MHD_HTTP_METHOD_DELETE) != 0)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET, PUT, DELETE");
origin = MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "Origin");
csrf = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
"X-Trainlog-CSRF-Token");
if (!valid_origin(context, origin) || !valid_csrf(context, csrf))
return queue_json(connection, MHD_HTTP_FORBIDDEN,
"{\"error\":\"mutation_forbidden\"}\n", NULL);
if_match = MHD_lookup_connection_value(connection, MHD_HEADER_KIND,
MHD_HTTP_HEADER_IF_MATCH);
if (!parse_if_match(if_match, &expected))
return queue_json(connection, MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n", NULL);
if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0) {
if (state->body_size != 0U)
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"unexpected_body\"}\n", NULL);
layout_result = trainlog_dashboard_layout_delete(expected);
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT)
return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED,
"{\"error\":\"revision_conflict\"}\n", NULL);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK)
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_delete_failed\"}\n", NULL);
trainlog_dashboard_layout_default(&layout);
if (!trainlog_dashboard_layout_serialize(&layout,
TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, true, json, sizeof(json), &json_size))
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_unavailable\"}\n", NULL);
return queue_layout_json(connection, MHD_HTTP_OK, json, 0U,
context->csrf_token);
}
{
const char *content_type = MHD_lookup_connection_value(connection,
MHD_HEADER_KIND, MHD_HTTP_HEADER_CONTENT_TYPE);
if (content_type == NULL || strcmp(content_type, "application/json") != 0)
return queue_json(connection, MHD_HTTP_UNSUPPORTED_MEDIA_TYPE,
"{\"error\":\"unsupported_media_type\"}\n", NULL);
}
layout_result = trainlog_dashboard_layout_parse(state->body,
state->body_size, &layout);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK || layout.revision != expected)
return queue_json(connection, MHD_HTTP_BAD_REQUEST,
"{\"error\":\"invalid_layout\"}\n", NULL);
layout_result = trainlog_dashboard_layout_save(&layout, expected, &saved);
if (layout_result == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT)
return queue_json(connection, MHD_HTTP_PRECONDITION_FAILED,
"{\"error\":\"revision_conflict\"}\n", NULL);
if (layout_result != TRAINLOG_DASHBOARD_LAYOUT_OK ||
!trainlog_dashboard_layout_serialize(&saved,
TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, true, json, sizeof(json), &json_size))
return queue_json(connection, MHD_HTTP_INTERNAL_SERVER_ERROR,
"{\"error\":\"layout_save_failed\"}\n", NULL);
return queue_layout_json(connection, MHD_HTTP_OK, json, saved.revision,
context->csrf_token);
}
/* INVARIANT: API paths never fall through to the SPA index. */
if (strncmp(url, "/api/", 5U) == 0)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n", NULL);
if (strstr(url, "..") != NULL)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n", NULL);
asset = is_ui_route(url) ? trainlog_web_index_asset()
: trainlog_web_asset_find(url);
if (asset == NULL)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n", NULL);
if (!is_get && !is_head)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n", "GET, HEAD");
return queue_asset(connection, asset);
}
static void request_completed(void *closure, struct MHD_Connection *connection,
void **request_closure, enum MHD_RequestTerminationCode termination)
{
(void)closure;
(void)connection;
(void)termination;
free(*request_closure);
*request_closure = NULL;
}
static enum MHD_Result accept_loopback_only(void *closure,
const struct sockaddr *address, socklen_t address_length)
{
const struct sockaddr_in *ipv4;
(void)closure;
if (address == NULL || address_length < (socklen_t)sizeof(*ipv4) ||
address->sa_family != AF_INET) return MHD_NO;
ipv4 = (const struct sockaddr_in *)address;
return ipv4->sin_addr.s_addr == htonl(INADDR_LOOPBACK) ? MHD_YES : MHD_NO;
}
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
const volatile sig_atomic_t *stop_requested,
char *diagnostic, size_t diagnostic_capacity)
{
struct sockaddr_in bind_address;
struct MHD_Daemon *daemon;
TrainlogWebContext context;
int result = 0;
if (database == NULL || stop_requested == NULL) return -1;
web_diagnostic(diagnostic, diagnostic_capacity, "");
if (!trainlog_web_assets_available()) {
web_diagnostic(diagnostic, diagnostic_capacity,
"support frontend absent de ce binaire");
return -1;
}
(void)memset(&bind_address, 0, sizeof(bind_address));
bind_address.sin_family = AF_INET;
bind_address.sin_port = htons(port);
bind_address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
context.port = port;
context.database = database;
context.invalid_layout_reported = false;
if (!generate_csrf_token(context.csrf_token)) {
web_diagnostic(diagnostic, diagnostic_capacity,
"impossible de générer la protection CSRF");
return -1;
}
errno = 0;
daemon = MHD_start_daemon(MHD_USE_ERROR_LOG | MHD_USE_NO_THREAD_SAFETY,
0U, accept_loopback_only, NULL, handle_request, &context,
MHD_OPTION_SOCK_ADDR_LEN, (socklen_t)sizeof(bind_address), &bind_address,
MHD_OPTION_CONNECTION_LIMIT, TRAINLOG_HTTP_CONNECTION_LIMIT,
MHD_OPTION_PER_IP_CONNECTION_LIMIT, TRAINLOG_HTTP_PER_IP_LIMIT,
MHD_OPTION_CONNECTION_MEMORY_LIMIT,
(size_t)TRAINLOG_HTTP_CONNECTION_MEMORY_LIMIT,
MHD_OPTION_CONNECTION_TIMEOUT, TRAINLOG_HTTP_TIMEOUT_SECONDS,
MHD_OPTION_LISTEN_BACKLOG_SIZE, TRAINLOG_HTTP_BACKLOG,
MHD_OPTION_NOTIFY_COMPLETED, request_completed, NULL,
MHD_OPTION_END);
if (daemon == NULL) {
char message[256];
(void)snprintf(message, sizeof(message),
"impossible d'écouter sur 127.0.0.1:%u%s%s",
(unsigned int)port, errno == 0 ? "" : " : ",
errno == 0 ? "" : strerror(errno));
web_diagnostic(diagnostic, diagnostic_capacity, message);
return -1;
}
(void)printf("Trainlog Web : http://127.0.0.1:%u/\n", (unsigned int)port);
(void)fflush(stdout);
while (*stop_requested == 0) {
fd_set read_set;
fd_set write_set;
fd_set except_set;
struct timeval timeout;
unsigned long long timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS;
int maximum_fd = 0;
int selected;
FD_ZERO(&read_set);
FD_ZERO(&write_set);
FD_ZERO(&except_set);
if (MHD_get_fdset(daemon, &read_set, &write_set, &except_set,
&maximum_fd) != MHD_YES) {
web_diagnostic(diagnostic, diagnostic_capacity,
"impossible de préparer la boucle HTTP");
result = -1;
break;
}
if (MHD_get_timeout(daemon, &timeout_ms) != MHD_YES ||
timeout_ms > TRAINLOG_HTTP_POLL_MAX_MS)
timeout_ms = TRAINLOG_HTTP_POLL_MAX_MS;
timeout.tv_sec = (time_t)(timeout_ms / 1000ULL);
timeout.tv_usec = (suseconds_t)((timeout_ms % 1000ULL) * 1000ULL);
selected = select(maximum_fd + 1, &read_set, &write_set, &except_set,
&timeout);
if (selected < 0) {
if (errno == EINTR) continue;
web_diagnostic(diagnostic, diagnostic_capacity,
"échec de la boucle HTTP locale");
result = -1;
break;
}
if (*stop_requested == 0 && MHD_run_from_select(daemon, &read_set,
&write_set, &except_set) != MHD_YES) {
web_diagnostic(diagnostic, diagnostic_capacity,
"échec du traitement HTTP local");
result = -1;
break;
}
}
MHD_stop_daemon(daemon);
return result;
}