feat(web): add local HTTP infrastructure
This commit is contained in:
parent
456713586b
commit
7597a167f8
16 changed files with 936 additions and 107 deletions
10
README.md
10
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
27
tui/include/trainlog/cli.h
Normal file
27
tui/include/trainlog/cli.h
Normal 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
|
||||
11
tui/include/trainlog/paths.h
Normal file
11
tui/include/trainlog/paths.h
Normal 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
|
||||
17
tui/include/trainlog/web_server.h
Normal file
17
tui/include/trainlog/web_server.h
Normal 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
|
||||
|
|
@ -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
118
tui/src/cli.c
Normal 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);
|
||||
}
|
||||
164
tui/src/main.c
164
tui/src/main.c
|
|
@ -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
74
tui/src/paths.c
Normal 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
249
tui/src/web_server.c
Normal 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
68
tui/tests/test_cli.c
Normal 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;
|
||||
}
|
||||
10
tui/tests/test_web_headers.c
Normal file
10
tui/tests/test_web_headers.c
Normal 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
186
tui/tests/test_web_server.c
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue