feat(web): add local HTTP infrastructure

This commit is contained in:
fy59 2026-09-16 09:37:48 +02:00
parent 456713586b
commit 7597a167f8
16 changed files with 936 additions and 107 deletions

View file

@ -19,8 +19,8 @@ over direct MTP; it never copies SQLite database files between devices.
| Local Web (planned) | Analyze and visualize canonical data; prepare programs and sessions through typed Trainlog Core services. |
No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI; `TRAINLOG_WEB_V1` is currently
contract-only and has no implementation.
a sibling adapter, not an extension of the TUI. Its loopback-only CLI/HTTP
infrastructure is implemented; its frontend and business API are not.
## Releases
@ -137,6 +137,7 @@ requires compatible versions of:
- libudev;
- libmtp;
- Notcurses Core;
- GNU libmicrohttpd;
- the standard math library.
Distribution packages may pull additional transitive libraries, including
@ -164,7 +165,8 @@ Desktop builds require:
- a C17 compiler toolchain;
- `pkg-config`;
- Notcurses Core development headers;
- SQLite, libuuid, utf8proc, libudev, and libmtp development headers;
- SQLite, libuuid, utf8proc, libudev, libmtp, and GNU libmicrohttpd development
headers;
- Python 3 for generated sources, validators, import/export helpers, and tests.
Android builds require JDK 17, an Android SDK supporting the configured API
@ -229,7 +231,7 @@ As of 2026-09-15:
- `APP_SHELL_V1=IMPLEMENTED_AWAITING_VISUAL_REVIEW_2`;
- `TRAINLOG_AI_SESSION_DRAFT_V1=VALIDATION_PENDING` pending a real Drive plus
Android-triggered bidirectional smoke test.
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`.
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`.
The latest executable result belongs in
[current state](docs/current_state.md), not in multiple README narratives.

View file

@ -193,6 +193,30 @@ opened and migrated once before HTTP listening, used without concurrent direct
SQLite-handle access, and closed by the process owner after server shutdown. A
single-threaded event loop is the preferred server architecture.
`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` implements that owner. CLI parsing
precedes all path, database, terminal and HTTP initialization. The common owner
resolves the private XDG database path, opens/migrates it exactly once, dispatches
to either the unchanged TUI or Web adapter, then closes it exactly once.
`--help`, `--version` and CLI errors stop before path or database access.
The HTTP adapter uses GNU libmicrohttpd in external-select mode with
`MHD_USE_NO_THREAD_SAFETY`: libmicrohttpd creates no polling or request thread.
All callbacks, future Core calls and SQLite access therefore run serially on
the process event-loop thread. A concrete IPv4 `sockaddr_in` binds only
`127.0.0.1`; port zero remains unavailable through the CLI. SIGINT/SIGTERM
handlers only set a `sig_atomic_t` flag. The loop then stops libmicrohttpd,
returns to the process owner and lets it close the database.
The only implemented route is read-only `GET /api/v1/health`; `/` and every
unknown route return 404, and other methods on health return 405. The adapter
bounds concurrent/per-IP connections at 32, listen backlog at 32, per-connection
memory at 16 KiB, decoded request headers at 8 KiB, request bodies at 4 KiB and
idle time at 10 seconds. It validates `Host` against `127.0.0.1` and the active
port, emits explicit JSON content type, `nosniff`, `no-store`, a deny-all CSP
and no permissive CORS header. Assets, telemetry and remote resources are
absent. Before the first mutation route, origin validation and explicit
CSRF protection remain mandatory in addition to these transport bounds.
Every Web page retains one application shell:
- Header navigation: Dashboard, Analyse, Programmes, Séances, Exercices;

View file

@ -39,7 +39,8 @@ parallel implementation of its rules.
| Dashboard characterization | `WEB_DASHBOARD_CHARACTERIZATION_V1=PASS/FROZEN` |
| Dashboard Core read model | `WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` |
| TUI Dashboard adoption | `WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED` |
| Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
Desktop and Android schema numbers are independent. Neither changes the frozen
Trainlog JSON V1 contract.
@ -136,8 +137,10 @@ the result. No Dashboard HTTP endpoint exists yet.
`TRAINLOG_WEB_V1` architecture, API independence, local-network boundary,
browser shell, Dashboard, layout ownership, build/runtime separation, and
security invariants are now canonical. No Web server, frontend, CLI option,
HTTP endpoint, embedded asset, or layout persistence is implemented yet.
security invariants are canonical. The CLI and local HTTP infrastructure are
implemented: `-w`/`--web`, optional `--port`, exact IPv4 loopback binding and
the technical `/api/v1/health` route. No frontend, embedded asset, Dashboard
route, business mutation, browser launch or layout persistence exists yet.
## Data semantics
@ -169,6 +172,10 @@ The Dashboard Core extraction tranche passes **62/62 normal Meson tests** and
**62/62 ASan/UBSan Meson tests**, including the standalone public-header and
Core characterization targets, plus both canonical JSON validators.
The CLI/HTTP infrastructure tranche passes **67/67 normal Meson tests**, adding
pure CLI parsing, process-level help/version checks and an isolated loopback
HTTP lifecycle/security suite. The same suite is required under ASan/UBSan.
`TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence,
formatting, layout-invariance and source-derived text-boundary tests, plus
Android resource-parity, language-owner, typed sync-presentation, and
@ -181,9 +188,8 @@ smoke test are not automated. The latter is why
## Active limitations
- `TRAINLOG_WEB_V1` remains contract-only; Dashboard characterization, Core
extraction and TUI adoption are frozen. The next implementation cursor is
CLI/local HTTP infrastructure, not React.
- `TRAINLOG_WEB_V1` has only its Core prerequisite and CLI/HTTP infrastructure;
the next cursor is the frontend shell. No Dashboard HTTP route exists.
- `APP_SHELL_V1` still awaits the recorded human visual/accessibility review.
- AI proposal exchange still awaits one real Drive plus Android-triggered
bidirectional smoke test.

View file

@ -24,23 +24,20 @@ Desktop schema v18, Android schema v17, Notcurses, direct
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft
exchange and APP_SHELL_V1 retain their explicit manual validation/review gates.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_NOT_STARTED`; its local-only
architecture is canonical, but no Web code exists yet.
`TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; its local-only
CLI/HTTP infrastructure exists, but no frontend or business endpoint exists.
## Current cursor
```text
CURRENT_OPERATIONAL_CURSOR=WEB_CLI_HTTP_INFRASTRUCTURE_V1
CURRENT_OPERATIONAL_CURSOR=WEB_FRONTEND_SHELL_V1
```
`WEB_DASHBOARD_CORE_READ_MODEL_V1=PASS/FROZEN` and
`WEB_TUI_READ_MODEL_ADOPTION_V1=PASS/FROZEN`. The characterized Dashboard SQL,
facts, limits and calculations now live in the typed Core service
`trainlog_dashboard_load()`; the TUI supplies the selected period and current
time, then presents the returned snapshot. Adoption was necessary to prove
parity and was therefore completed in the extraction tranche rather than left
as an empty roadmap step. The next authorized slice is local CLI/HTTP
infrastructure; it does not yet authorize React or Dashboard Web metrics.
`WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN`. `trainlog -w` and `--web` now
dispatch through the common process owner to a bounded, single-threaded
loopback HTTP adapter after one database open. Only `/api/v1/health` exists.
The next slice is the frontend shell and embedded production assets; it does
not authorize Dashboard business routes or new metrics.
## TRAINLOG_WEB_V1 implementation gate
@ -50,7 +47,7 @@ Implementation order is fixed as:
WEB_DASHBOARD_CHARACTERIZATION_V1
-> WEB_DASHBOARD_CORE_READ_MODEL_V1 [PASS/FROZEN]
-> WEB_TUI_READ_MODEL_ADOPTION_V1 [PASS/FROZEN]
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1
-> WEB_CLI_HTTP_INFRASTRUCTURE_V1 [PASS/FROZEN]
-> WEB_FRONTEND_SHELL_V1
-> WEB_DASHBOARD_V1
```

View file

@ -13,6 +13,20 @@ A Trainlog feature is not complete without relevant validation.
Frozen formats, persistence migrations, synchronization semantics, and user-data
mutations require executable coverage where practical.
`WEB_CLI_HTTP_INFRASTRUCTURE_V1` adds pure CLI tests for the default TUI mode,
both Web switches, help, version, unknown and repeated options, positional
arguments, Web-only `--port`, missing/non-numeric/negative/zero/out-of-range
ports, 65535, and option ordering. Process-level tests execute `--help` and
`--version` without initializing the database, TUI or server.
The isolated HTTP test opens one in-memory database, starts the production
single-threaded adapter on an explicitly reserved loopback port, and verifies
health JSON/version, content type, `nosniff`, CSP, absence of CORS, 404, 405
with `Allow: GET`, invalid Host, oversized body, oversized-header rejection,
SIGTERM shutdown and explicit failure on an already occupied port. It never
uses the Internet. A separate real-launch smoke uses an isolated XDG directory,
performs the health request and stops the production binary with SIGINT.
## 2. Frozen Trainlog JSON v1
Run:

View file

@ -0,0 +1,27 @@
#ifndef TRAINLOG_CLI_H
#define TRAINLOG_CLI_H
#include <stddef.h>
#include <stdint.h>
typedef enum TrainlogRunMode {
TRAINLOG_RUN_TUI = 0,
TRAINLOG_RUN_WEB,
TRAINLOG_RUN_HELP,
TRAINLOG_RUN_VERSION
} TrainlogRunMode;
typedef struct TrainlogCliOptions {
TrainlogRunMode mode;
uint16_t port;
} TrainlogCliOptions;
/* CONTRACT: parsing is side-effect free apart from getopt's process-local
* state, which is reset for every call. No database, terminal or server is
* initialized. diagnostic is always NUL-terminated when capacity is nonzero. */
int trainlog_cli_parse(int argc, char *const argv[], TrainlogCliOptions *options,
char *diagnostic, size_t diagnostic_capacity);
void trainlog_cli_print_usage(const char *program_name);
#endif

View file

@ -0,0 +1,11 @@
#ifndef TRAINLOG_PATHS_H
#define TRAINLOG_PATHS_H
#include <stddef.h>
/* Resolve and create the private XDG data directory, then return trainlog.db.
* The caller owns output; truncation and non-directory collisions fail. */
int trainlog_paths_database(char *output, size_t output_capacity,
char *diagnostic, size_t diagnostic_capacity);
#endif

View file

@ -0,0 +1,17 @@
#ifndef TRAINLOG_WEB_SERVER_H
#define TRAINLOG_WEB_SERVER_H
#include <signal.h>
#include <stddef.h>
#include <stdint.h>
#include "trainlog/database.h"
/* Blocking, single-threaded local HTTP adapter. Port zero is reserved for
* controlled test harnesses and is rejected by the CLI. The database remains
* borrowed and process-owned; this infrastructure route never reads it. */
int trainlog_web_server_run(TrainlogDatabase *database, uint16_t port,
const volatile sig_atomic_t *stop_requested,
char *diagnostic, size_t diagnostic_capacity);
#endif

View file

@ -6,6 +6,7 @@ udev_dep = dependency('libudev', required: true)
mtp_dep = dependency('libmtp', required: true)
notcurses_dep = dependency('notcurses-core', required: true)
microhttpd_dep = dependency('libmicrohttpd', required: true)
utf8proc_dep = dependency('libutf8proc', required: false)
if not utf8proc_dep.found()
@ -83,6 +84,7 @@ strict_c_args = [
'-Wconversion',
'-Wformat=2',
'-Wshadow',
'-DTRAINLOG_VERSION="@0@"'.format(meson.project_version()),
]
trainlog_core_sources = files(
@ -140,7 +142,9 @@ trainlog_core_dep = declare_dependency(
)
trainlog_tui_sources = files(
'src/cli.c',
'src/main.c',
'src/paths.c',
'src/app_shell.c',
'src/presentation.c',
'src/chart.c',
@ -148,6 +152,7 @@ trainlog_tui_sources = files(
'src/terminal.c',
'src/theme.c',
'src/tui.c',
'src/web_server.c',
)
trainlog_exe = executable(
@ -156,6 +161,7 @@ trainlog_exe = executable(
dependencies: [
trainlog_core_dep,
notcurses_dep,
microhttpd_dep,
],
c_args: strict_c_args,
install: true,
@ -217,6 +223,32 @@ test_dashboard_header = executable(
)
test('dashboard_header', test_dashboard_header)
test_cli = executable(
'test_cli',
['tests/test_cli.c', 'src/cli.c'],
include_directories: trainlog_include,
c_args: strict_c_args,
)
test('cli', test_cli)
test_web_server = executable(
'test_web_server',
['tests/test_web_server.c', 'src/web_server.c'],
dependencies: [trainlog_core_dep, microhttpd_dep],
c_args: strict_c_args,
)
test('web_server', test_web_server, timeout: 30)
test('cli_help', trainlog_exe, args: ['--help'])
test('cli_version', trainlog_exe, args: ['--version'])
test_web_headers = executable(
'test_web_headers',
'tests/test_web_headers.c',
include_directories: trainlog_include,
c_args: strict_c_args,
)
test('web_headers', test_web_headers)
test_training_knowledge = executable(
'test_training_knowledge',
'tests/test_training_knowledge.c',

118
tui/src/cli.c Normal file
View file

@ -0,0 +1,118 @@
#include "trainlog/cli.h"
#include <errno.h>
#include <getopt.h>
#include <limits.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define TRAINLOG_WEB_DEFAULT_PORT 8080U
static void cli_diagnostic(char *output, size_t capacity, const char *message)
{
if (output != NULL && capacity > 0U)
(void)snprintf(output, capacity, "%s", message);
}
static int parse_port(const char *value, uint16_t *port)
{
char *end = NULL;
unsigned long parsed;
if (value == NULL || value[0] == '\0' || value[0] == '-') return -1;
errno = 0;
parsed = strtoul(value, &end, 10);
if (errno != 0 || end == value || *end != '\0' || parsed == 0UL ||
parsed > 65535UL) return -1;
*port = (uint16_t)parsed;
return 0;
}
int trainlog_cli_parse(int argc, char *const argv[], TrainlogCliOptions *options,
char *diagnostic, size_t diagnostic_capacity)
{
static const struct option long_options[] = {
{"web", no_argument, NULL, 'w'},
{"port", required_argument, NULL, 1},
{"help", no_argument, NULL, 'h'},
{"version", no_argument, NULL, 'V'},
{NULL, 0, NULL, 0}
};
bool web_seen = false;
bool port_seen = false;
bool help_seen = false;
bool version_seen = false;
int option;
if (options == NULL || argc < 1 || argv == NULL) return -1;
options->mode = TRAINLOG_RUN_TUI;
options->port = TRAINLOG_WEB_DEFAULT_PORT;
cli_diagnostic(diagnostic, diagnostic_capacity, "");
opterr = 0;
optind = 0;
while ((option = getopt_long(argc, argv, "whV", long_options, NULL)) != -1) {
switch (option) {
case 'w':
if (web_seen) {
cli_diagnostic(diagnostic, diagnostic_capacity,
"l'option Web est répétée");
return -1;
}
web_seen = true;
break;
case 1:
if (port_seen) {
cli_diagnostic(diagnostic, diagnostic_capacity,
"l'option --port est répétée");
return -1;
}
if (parse_port(optarg, &options->port) != 0) {
cli_diagnostic(diagnostic, diagnostic_capacity,
"--port attend un entier compris entre 1 et 65535");
return -1;
}
port_seen = true;
break;
case 'h': help_seen = true; break;
case 'V': version_seen = true; break;
case '?':
default:
cli_diagnostic(diagnostic, diagnostic_capacity,
"option inconnue ou argument manquant");
return -1;
}
}
if (optind != argc) {
cli_diagnostic(diagnostic, diagnostic_capacity,
"aucun argument positionnel n'est accepté");
return -1;
}
if ((help_seen && version_seen) ||
((help_seen || version_seen) && (web_seen || port_seen))) {
cli_diagnostic(diagnostic, diagnostic_capacity,
"--help et --version doivent être utilisés seuls");
return -1;
}
if (port_seen && !web_seen) {
cli_diagnostic(diagnostic, diagnostic_capacity,
"--port n'est valide qu'avec -w ou --web");
return -1;
}
if (help_seen) options->mode = TRAINLOG_RUN_HELP;
else if (version_seen) options->mode = TRAINLOG_RUN_VERSION;
else if (web_seen) options->mode = TRAINLOG_RUN_WEB;
return 0;
}
void trainlog_cli_print_usage(const char *program_name)
{
const char *name = program_name != NULL && program_name[0] != '\0'
? program_name : "trainlog";
(void)printf(
"Usage : %s [--help | --version | -w|--web [--port PORT]]\n"
" sans option lancer la TUI\n"
" -w, --web lancer le serveur Web local\n"
" --port PORT port Web explicite (1..65535, défaut 8080)\n"
" --help afficher cette aide\n"
" --version afficher la version\n", name);
}

View file

@ -1,118 +1,112 @@
/**
* @file main.c
* @brief Trainlog TUI process entry point.
*/
/** @file main.c @brief Trainlog process owner and interface dispatcher. */
#include <errno.h>
#include <limits.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include "trainlog/cli.h"
#include "trainlog/database.h"
#include "trainlog/paths.h"
#include "trainlog/tui.h"
#include "trainlog/web_server.h"
static int ensure_directory(const char *path)
static volatile sig_atomic_t web_stop_requested;
static void request_web_stop(int signal_number)
{
if (mkdir(path, 0700) == 0 || errno == EEXIST) {
return 0;
}
return -1;
(void)signal_number;
web_stop_requested = 1;
}
static int build_database_path(char *output, size_t output_size)
static int run_web(TrainlogDatabase *database, uint16_t port)
{
const char *xdg = getenv("XDG_DATA_HOME");
const char *home = getenv("HOME");
char base[PATH_MAX];
char trainlog_dir[PATH_MAX];
int written;
if (xdg != NULL && xdg[0] != '\0') {
written = snprintf(base, sizeof(base), "%s", xdg);
} else if (home != NULL && home[0] != '\0') {
written = snprintf(base, sizeof(base), "%s/.local/share", home);
} else {
return -1;
struct sigaction action;
struct sigaction previous_interrupt;
struct sigaction previous_terminate;
char diagnostic[256];
int result;
(void)memset(&action, 0, sizeof(action));
action.sa_handler = request_web_stop;
(void)sigemptyset(&action.sa_mask);
web_stop_requested = 0;
if (sigaction(SIGINT, &action, &previous_interrupt) != 0) {
(void)fprintf(stderr,
"trainlog: impossible d'installer les gestionnaires de signaux\n");
return 1;
}
if (written < 0 || (size_t)written >= sizeof(base)) {
return -1;
if (sigaction(SIGTERM, &action, &previous_terminate) != 0) {
(void)sigaction(SIGINT, &previous_interrupt, NULL);
(void)fprintf(stderr,
"trainlog: impossible d'installer les gestionnaires de signaux\n");
return 1;
}
/*
* Creating ~/.local and ~/.local/share recursively would normally require
* a generic mkdir -p helper. On Arch these parents already exist for a
* desktop user; XDG_DATA_HOME custom paths are likewise expected to have
* their parent created by the user.
*/
if (ensure_directory(base) != 0 && errno != EEXIST) {
/* Continue only when the base already exists. */
struct stat info;
if (stat(base, &info) != 0 || !S_ISDIR(info.st_mode)) {
return -1;
}
result = trainlog_web_server_run(database, port, &web_stop_requested,
diagnostic, sizeof(diagnostic));
(void)sigaction(SIGINT, &previous_interrupt, NULL);
(void)sigaction(SIGTERM, &previous_terminate, NULL);
if (result != 0) {
(void)fprintf(stderr, "trainlog: serveur Web : %s\n",
diagnostic[0] == '\0' ? "échec non détaillé" : diagnostic);
return 1;
}
written = snprintf(
trainlog_dir,
sizeof(trainlog_dir),
"%s/trainlog",
base
);
if (written < 0 || (size_t)written >= sizeof(trainlog_dir)) {
return -1;
}
if (ensure_directory(trainlog_dir) != 0) {
return -1;
}
written = snprintf(
output,
output_size,
"%s/trainlog.db",
trainlog_dir
);
return written >= 0 && (size_t)written < output_size ? 0 : -1;
return 0;
}
int main(void)
int main(int argc, char *argv[])
{
char database_path[PATH_MAX];
char path_diagnostic[256];
char database_diagnostic[256];
char cli_diagnostic[256];
TrainlogCliOptions options;
TrainlogDatabase *database = NULL;
TrainlogStatus status;
int result;
if (build_database_path(database_path, sizeof(database_path)) != 0) {
(void)fprintf(stderr, "trainlog: unable to create data directory\n");
if (trainlog_cli_parse(argc, argv, &options, cli_diagnostic,
sizeof(cli_diagnostic)) != 0) {
(void)fprintf(stderr, "trainlog: %s\n", cli_diagnostic);
trainlog_cli_print_usage(argv[0]);
return 2;
}
if (options.mode == TRAINLOG_RUN_HELP) {
trainlog_cli_print_usage(argv[0]);
return 0;
}
if (options.mode == TRAINLOG_RUN_VERSION) {
(void)printf("trainlog %s\n", TRAINLOG_VERSION);
return 0;
}
/* CONTRACT: newly created data directories and SQLite files are private
* to the local user, independently of the invoking shell's umask. */
(void)umask(0077);
if (trainlog_paths_database(database_path, sizeof(database_path),
path_diagnostic, sizeof(path_diagnostic)) != 0) {
(void)fprintf(stderr, "trainlog: chemin de données : %s\n",
path_diagnostic);
return 1;
}
status = trainlog_database_open_with_diagnostic(
database_path,
&database,
database_diagnostic,
sizeof(database_diagnostic)
);
status = trainlog_database_open_with_diagnostic(database_path, &database,
database_diagnostic, sizeof(database_diagnostic));
if (status != TRAINLOG_STATUS_OK) {
(void)fprintf(
stderr,
"trainlog: unable to open database '%s' (status %d): %s\n",
database_path,
(int)status,
database_diagnostic[0] != '\0'
? database_diagnostic
: "no SQLite diagnostic available"
);
(void)fprintf(stderr,
"trainlog: impossible d'ouvrir la base '%s' (statut %d) : %s\n",
database_path, (int)status,
database_diagnostic[0] == '\0'
? "diagnostic SQLite indisponible" : database_diagnostic);
return 1;
}
result = trainlog_tui_run(database);
if (chmod(database_path, 0600) != 0) {
(void)fprintf(stderr,
"trainlog: impossible de protéger les permissions de la base\n");
trainlog_database_close(database);
return 1;
}
result = options.mode == TRAINLOG_RUN_WEB
? run_web(database, options.port)
: trainlog_tui_run(database);
trainlog_database_close(database);
return result;
}

74
tui/src/paths.c Normal file
View file

@ -0,0 +1,74 @@
#include "trainlog/paths.h"
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
static void path_diagnostic(char *output, size_t capacity, const char *message)
{
if (output != NULL && capacity > 0U)
(void)snprintf(output, capacity, "%s", message);
}
static int ensure_private_directory(const char *path)
{
struct stat info;
if (mkdir(path, 0700) == 0) return 0;
if (errno != EEXIST || stat(path, &info) != 0 || !S_ISDIR(info.st_mode))
return -1;
return 0;
}
int trainlog_paths_database(char *output, size_t output_capacity,
char *diagnostic, size_t diagnostic_capacity)
{
const char *xdg = getenv("XDG_DATA_HOME");
const char *home = getenv("HOME");
char base[PATH_MAX];
char trainlog_dir[PATH_MAX];
int written;
if (output == NULL || output_capacity == 0U) return -1;
output[0] = '\0';
path_diagnostic(diagnostic, diagnostic_capacity, "");
if (xdg != NULL && xdg[0] != '\0')
written = snprintf(base, sizeof(base), "%s", xdg);
else if (home != NULL && home[0] != '\0')
written = snprintf(base, sizeof(base), "%s/.local/share", home);
else {
path_diagnostic(diagnostic, diagnostic_capacity,
"XDG_DATA_HOME et HOME sont absents");
return -1;
}
if (written < 0 || (size_t)written >= sizeof(base)) {
path_diagnostic(diagnostic, diagnostic_capacity,
"chemin de données trop long");
return -1;
}
if (ensure_private_directory(base) != 0) {
path_diagnostic(diagnostic, diagnostic_capacity,
"répertoire de données inaccessible");
return -1;
}
written = snprintf(trainlog_dir, sizeof(trainlog_dir), "%s/trainlog", base);
if (written < 0 || (size_t)written >= sizeof(trainlog_dir) ||
ensure_private_directory(trainlog_dir) != 0) {
path_diagnostic(diagnostic, diagnostic_capacity,
"répertoire Trainlog inaccessible ou trop long");
return -1;
}
if (chmod(trainlog_dir, 0700) != 0) {
path_diagnostic(diagnostic, diagnostic_capacity,
"permissions du répertoire Trainlog impossibles à garantir");
return -1;
}
written = snprintf(output, output_capacity, "%s/trainlog.db", trainlog_dir);
if (written < 0 || (size_t)written >= output_capacity) {
output[0] = '\0';
path_diagnostic(diagnostic, diagnostic_capacity,
"chemin de base de données trop long");
return -1;
}
return 0;
}

249
tui/src/web_server.c Normal file
View file

@ -0,0 +1,249 @@
#include "trainlog/web_server.h"
#include <arpa/inet.h>
#include <errno.h>
#include <microhttpd.h>
#include <netinet/in.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/select.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;
} TrainlogWebContext;
typedef struct TrainlogHttpRequestState {
size_t body_size;
bool body_too_large;
} 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_json(struct MHD_Connection *connection,
unsigned int status, const char *body)
{
struct MHD_Response *response;
enum MHD_Result result;
response = MHD_create_response_from_buffer(strlen(body), (void *)body,
MHD_RESPMEM_PERSISTENT);
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 (status == MHD_HTTP_METHOD_NOT_ALLOWED &&
MHD_add_response_header(response, MHD_HTTP_HEADER_ALLOW, "GET") !=
MHD_YES) {
MHD_destroy_response(response);
return MHD_NO;
}
result = MHD_queue_response(connection, status, response);
MHD_destroy_response(response);
return result;
}
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 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 char *host;
(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 state->body_size += *upload_data_size;
(void)upload_data;
*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");
if (state->body_too_large)
return queue_json(connection, MHD_HTTP_CONTENT_TOO_LARGE,
"{\"error\":\"body_too_large\"}\n");
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");
if (strcmp(url, "/api/v1/health") != 0)
return queue_json(connection, MHD_HTTP_NOT_FOUND,
"{\"error\":\"not_found\"}\n");
if (strcmp(method, MHD_HTTP_METHOD_GET) != 0)
return queue_json(connection, MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n");
return queue_json(connection, MHD_HTTP_OK, HEALTH);
}
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, "");
(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;
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;
}

68
tui/tests/test_cli.c Normal file
View file

@ -0,0 +1,68 @@
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "trainlog/cli.h"
#define CHECK(value) do { if (!(value)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #value); return false; } } while (0)
static bool parses(int argc, char **argv, TrainlogRunMode mode,
unsigned int port)
{
TrainlogCliOptions options;
char diagnostic[128];
CHECK(trainlog_cli_parse(argc, argv, &options, diagnostic,
sizeof(diagnostic)) == 0);
CHECK(diagnostic[0] == '\0');
CHECK(options.mode == mode);
CHECK((unsigned int)options.port == port);
return true;
}
static bool rejects(int argc, char **argv)
{
TrainlogCliOptions options;
char diagnostic[128];
CHECK(trainlog_cli_parse(argc, argv, &options, diagnostic,
sizeof(diagnostic)) != 0);
CHECK(diagnostic[0] != '\0');
return true;
}
int main(void)
{
char *plain[] = {"trainlog", NULL};
char *short_web[] = {"trainlog", "-w", NULL};
char *long_web[] = {"trainlog", "--web", NULL};
char *port[] = {"trainlog", "-w", "--port", "65535", NULL};
char *port_first[] = {"trainlog", "--port", "8081", "--web", NULL};
char *help[] = {"trainlog", "--help", NULL};
char *version[] = {"trainlog", "--version", NULL};
char *unknown[] = {"trainlog", "--unknown", NULL};
char *port_alone[] = {"trainlog", "--port", "8080", NULL};
char *port_missing[] = {"trainlog", "-w", "--port", NULL};
char *port_text[] = {"trainlog", "-w", "--port", "abc", NULL};
char *port_negative[] = {"trainlog", "-w", "--port", "-1", NULL};
char *port_zero[] = {"trainlog", "-w", "--port", "0", NULL};
char *port_large[] = {"trainlog", "-w", "--port", "65536", NULL};
char *duplicate_web[] = {"trainlog", "-w", "--web", NULL};
char *duplicate_port[] = {"trainlog", "-w", "--port", "80",
"--port", "81", NULL};
char *mixed_help[] = {"trainlog", "--help", "--web", NULL};
char *positional[] = {"trainlog", "extra", NULL};
return parses(1, plain, TRAINLOG_RUN_TUI, 8080U) &&
parses(2, short_web, TRAINLOG_RUN_WEB, 8080U) &&
parses(2, long_web, TRAINLOG_RUN_WEB, 8080U) &&
parses(4, port, TRAINLOG_RUN_WEB, 65535U) &&
parses(4, port_first, TRAINLOG_RUN_WEB, 8081U) &&
parses(2, help, TRAINLOG_RUN_HELP, 8080U) &&
parses(2, version, TRAINLOG_RUN_VERSION, 8080U) &&
rejects(2, unknown) && rejects(3, port_alone) &&
rejects(3, port_missing) && rejects(4, port_text) &&
rejects(4, port_negative) && rejects(4, port_zero) &&
rejects(4, port_large) && rejects(3, duplicate_web) &&
rejects(6, duplicate_port) && rejects(3, mixed_help) &&
rejects(2, positional) ? 0 : 1;
}

View file

@ -0,0 +1,10 @@
/* Standalone public-header compilation contract for the Web infrastructure. */
#include "trainlog/cli.h"
#include "trainlog/paths.h"
#include "trainlog/web_server.h"
int main(void)
{
TrainlogCliOptions options = {TRAINLOG_RUN_TUI, 8080U};
return options.mode == TRAINLOG_RUN_TUI && options.port == 8080U ? 0 : 1;
}

186
tui/tests/test_web_server.c Normal file
View file

@ -0,0 +1,186 @@
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#include "trainlog/database.h"
#include "trainlog/web_server.h"
#define CHECK(value) do { if (!(value)) { \
(void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", \
__FILE__, __LINE__, #value); return false; } } while (0)
static volatile sig_atomic_t child_stop;
static void stop_child(int signal_number)
{
(void)signal_number;
child_stop = 1;
}
static int reserve_port(uint16_t *port, int keep_open)
{
struct sockaddr_in address;
socklen_t length = (socklen_t)sizeof(address);
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd < 0) return -1;
(void)memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = 0;
if (bind(socket_fd, (const struct sockaddr *)&address, sizeof(address)) != 0 ||
getsockname(socket_fd, (struct sockaddr *)&address, &length) != 0 ||
(keep_open != 0 && listen(socket_fd, 1) != 0)) {
(void)close(socket_fd);
return -1;
}
*port = ntohs(address.sin_port);
if (keep_open == 0) {
(void)close(socket_fd);
return -2;
}
return socket_fd;
}
static int connect_with_retry(uint16_t port)
{
struct sockaddr_in address;
struct timespec delay = {0, 10000000L};
size_t attempt;
(void)memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = htons(port);
for (attempt = 0U; attempt < 200U; ++attempt) {
int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd >= 0 && connect(socket_fd,
(const struct sockaddr *)&address, sizeof(address)) == 0)
return socket_fd;
if (socket_fd >= 0) (void)close(socket_fd);
(void)nanosleep(&delay, NULL);
}
return -1;
}
static bool exchange(uint16_t port, const char *request, char *response,
size_t capacity)
{
size_t sent = 0U;
size_t used = 0U;
size_t length = strlen(request);
int socket_fd = connect_with_retry(port);
CHECK(socket_fd >= 0);
while (sent < length) {
ssize_t count = send(socket_fd, request + sent, length - sent, 0);
CHECK(count > 0);
sent += (size_t)count;
}
(void)shutdown(socket_fd, SHUT_WR);
while (used + 1U < capacity) {
ssize_t count = recv(socket_fd, response + used, capacity - used - 1U, 0);
if (count == 0) break;
CHECK(count > 0);
used += (size_t)count;
}
response[used] = '\0';
(void)close(socket_fd);
return true;
}
static bool test_http_contract(TrainlogDatabase *database)
{
char response[32768];
char large_request[12000];
uint16_t port;
pid_t child;
int status;
struct sigaction action;
CHECK(reserve_port(&port, 0) == -2);
child = fork();
CHECK(child >= 0);
if (child == 0) {
char diagnostic[256];
(void)memset(&action, 0, sizeof(action));
action.sa_handler = stop_child;
(void)sigemptyset(&action.sa_mask);
(void)sigaction(SIGTERM, &action, NULL);
child_stop = 0;
_exit(trainlog_web_server_run(database, port, &child_stop,
diagnostic, sizeof(diagnostic)) == 0 ? 0 : 1);
}
CHECK(exchange(port,
"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)));
CHECK(strstr(response, "HTTP/1.1 200") != NULL);
CHECK(strstr(response, "Content-Type: application/json; charset=utf-8") != NULL);
CHECK(strstr(response, "X-Content-Type-Options: nosniff") != NULL);
CHECK(strstr(response, "Content-Security-Policy: default-src 'none'") != NULL);
CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL);
CHECK(strstr(response,
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.2\"}") != NULL);
CHECK(exchange(port, "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL);
CHECK(exchange(port,
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 405") != NULL);
CHECK(strstr(response, "Allow: GET") != NULL);
CHECK(exchange(port,
"GET /api/v1/health HTTP/1.1\r\nHost: foreign.example\r\n\r\n",
response, sizeof(response)) && strstr(response, "HTTP/1.1 400") != NULL);
(void)snprintf(large_request, sizeof(large_request),
"POST /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\n"
"Content-Length: 5000\r\n\r\n%05000d", 0);
CHECK(exchange(port, large_request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 413") != NULL);
(void)memset(large_request, 'a', sizeof(large_request));
(void)snprintf(large_request, 128U,
"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Large: ");
{
size_t prefix = strlen(large_request);
(void)memset(large_request + prefix, 'a', 8500U);
(void)memcpy(large_request + prefix + 8500U, "\r\n\r\n", 5U);
}
CHECK(exchange(port, large_request, response, sizeof(response)) &&
(strstr(response, "HTTP/1.1 431") != NULL ||
strstr(response, "HTTP/1.1 400") != NULL));
CHECK(kill(child, SIGTERM) == 0);
CHECK(waitpid(child, &status, 0) == child);
CHECK(WIFEXITED(status) && WEXITSTATUS(status) == 0);
return true;
}
static bool test_port_in_use(TrainlogDatabase *database)
{
volatile sig_atomic_t stop = 0;
char diagnostic[256];
uint16_t port;
int occupied = reserve_port(&port, 1);
CHECK(occupied >= 0);
CHECK(trainlog_web_server_run(database, port, &stop, diagnostic,
sizeof(diagnostic)) != 0);
CHECK(strstr(diagnostic, "127.0.0.1") != NULL);
(void)close(occupied);
return true;
}
int main(void)
{
TrainlogDatabase *database = NULL;
bool passed;
if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK)
return 1;
passed = test_http_contract(database) && test_port_in_use(database);
trainlog_database_close(database);
return passed ? 0 : 1;
}