diff --git a/contracts/dashboard-layout-v1.json b/contracts/dashboard-layout-v1.json new file mode 100644 index 0000000..61fb85a --- /dev/null +++ b/contracts/dashboard-layout-v1.json @@ -0,0 +1,15 @@ +{ + "format": "trainlog-dashboard-layout-contract", + "version": 1, + "columns": 12, + "max_y": 200, + "tiles": [ + { "id": "next-session", "min_width": 4, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 0, "y": 0, "width": 5, "height": 4 } }, + { "id": "activity", "min_width": 5, "max_width": 12, "min_height": 3, "max_height": 7, "default": { "x": 5, "y": 0, "width": 7, "height": 4 } }, + { "id": "progression", "min_width": 6, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 4, "width": 8, "height": 5 } }, + { "id": "last-session", "min_width": 3, "max_width": 7, "min_height": 3, "max_height": 6, "default": { "x": 8, "y": 4, "width": 4, "height": 3 } }, + { "id": "max-records", "min_width": 3, "max_width": 8, "min_height": 3, "max_height": 7, "default": { "x": 8, "y": 7, "width": 4, "height": 3 } }, + { "id": "muscle-distribution", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 9, "default": { "x": 0, "y": 9, "width": 6, "height": 5 } }, + { "id": "cardio-recovery", "min_width": 5, "max_width": 12, "min_height": 4, "max_height": 8, "default": { "x": 6, "y": 10, "width": 6, "height": 4 } } + ] +} diff --git a/docs/architecture.md b/docs/architecture.md index ed3097f..447c2a5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -302,6 +302,13 @@ It does not enter business SQLite V1, `TRAINLOG_FORMAT_V1`, scientific or AI exports, or session synchronization. Small-screen layouts are derived responsive projections, not a second persisted truth. +`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` implements this path as a strict private +0600 JSON preference, atomically replaced without SQLite involvement. GET/PUT/ +DELETE `/api/v1/dashboard-layout` expose the canonical Trainlog shape. ETag and +If-Match guard optimistic revisions; mutation additionally requires exact +loopback or `trainlog.perf` Origin and an ephemeral startup-random CSRF header. +Invalid files are preserved for diagnosis while GET safely returns the default. + `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN` fixes one coherent, read-only `GET /api/v1/dashboard` snapshot. One outer SQLite read savepoint covers every projection; statements are finalized before return. The seven domains mean: diff --git a/docs/current_state.md b/docs/current_state.md index 34ab0bb..e68b3c0 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -43,6 +43,7 @@ parallel implementation of its rules. | Web CLI/HTTP infrastructure | `WEB_CLI_HTTP_INFRASTRUCTURE_V1=PASS/FROZEN` | | Web frontend shell | `WEB_FRONTEND_SHELL_V1=PASS/FROZEN` | | Web Dashboard grid | `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` | +| Web Dashboard layout | `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` | Desktop and Android schema numbers are independent. Neither changes the frozen @@ -195,9 +196,15 @@ and 12 Vitest tests for parsing and factual Footer integration. `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` uses `react-grid-layout` 2.2.4 behind an independent Trainlog model/validator. Its 12-column desktop layout has seven stable tile IDs, bounded per-tile dimensions, vertical collision compaction, -explicit edit/cancel/reset/session-only-save behavior, mouse drag/two-axis +explicit edit/cancel/reset behavior, mouse drag/two-axis resize, an announced keyboard alternative, and derived six/one-column -responsive projections. No layout is persisted yet. +responsive projections. Persistence is supplied by the following frozen slice. + +`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` adds a strict versioned seven-tile file +under the private XDG configuration directory, backend revalidation, atomic +0600 replacement, ETag/If-Match conflict control, Origin plus ephemeral CSRF +protection, and durable React load/save/reset behavior. Only the desktop +12-column canon persists. `TRAINLOG_I18N_V0_1_1=PASS` is covered by desktop presentation, persistence, formatting, layout-invariance and source-derived text-boundary tests, plus @@ -214,9 +221,9 @@ smoke test are not automated. The latter is why - `WEB_DASHBOARD_DATA_CONTRACT_V1=PASS/FROZEN`: the bounded Core snapshot, `/api/v1/dashboard`, explicit unavailable states and factual Footer data are implemented. The seven tile bodies intentionally retain shell placeholders. -- `WEB_DASHBOARD_GRID_V1=PASS/FROZEN`: layout changes live only in current - React memory. Versioned disk/API persistence is the next tranche, - `WEB_DASHBOARD_LAYOUT_V1`. +- `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN`: versioned disk/API persistence is + complete. The seven tile bodies remain placeholders for + `WEB_DASHBOARD_TILES_V1`. - `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. diff --git a/docs/design/web_dashboard_layout_v1.md b/docs/design/web_dashboard_layout_v1.md new file mode 100644 index 0000000..0a11be4 --- /dev/null +++ b/docs/design/web_dashboard_layout_v1.md @@ -0,0 +1,79 @@ +# Web Dashboard layout V1 + +Status: `WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` + +## File contract + +The canonical UI preference is +`$XDG_CONFIG_HOME/trainlog/web/dashboard-layout-v1.json`, falling back to +`$HOME/.config/trainlog/web/dashboard-layout-v1.json`. Trainlog-owned +directories are mode 0700 and the regular file is mode 0600. It is not SQLite, +training data, an exchange format or synchronization input. + +The strict document has exactly `format`, `version`, `revision`, `columns` and +`tiles`. Format is `trainlog-dashboard-layout`, version is 1, columns is 12, +and all seven stable Grid V1 IDs occur exactly once. Tiles contain only +`id,x,y,width,height`; responsive projections and translated titles never +persist. The canonical constraint/default table is +`contracts/dashboard-layout-v1.json`; TypeScript consumes it directly and a +Meson contract test rejects C divergence. + +Revision zero denotes no valid persisted preference. A successful PUT stores +the expected revision plus one. The maximum is JavaScript's safe integer, +9,007,199,254,740,991. Coordinates are integral, `y <= 200`, dimensions obey +the frozen per-tile bounds, all tiles fit 12 columns and overlap is forbidden. + +## Parser and boundedness + +Trainlog uses system `yyjson` 0.13.0 (MIT), selected for strict typed C parsing, +small surface and pkg-config/Meson integration. HTTP bodies and files are both +limited to 4 KiB. Only the exact four-level schema is accepted; unexpected +keys, types, array sizes and unsupported versions are rejected. IDs fit a +32-byte bounded buffer and the tile count is exactly seven. No general JSON +framework was introduced. + +Reads use `O_NOFOLLOW|O_CLOEXEC|O_NONBLOCK`, `fstat`, regular-file and size +checks. A missing file is normal. An invalid, oversized, non-regular or symlink +preference yields the complete default with source `invalid_persisted`; it is +not silently removed or replaced, and the server emits one bounded diagnostic. + +## Atomic mutation + +PUT validates before filesystem mutation, creates a same-directory `mkstemp`, +sets 0600 and close-on-exec, writes fully, fsyncs, closes, atomically renames, +then attempts directory fsync. Failures before rename unlink the temporary and +leave the prior file untouched. After rename the visible commit is authoritative +even if directory fsync cannot prove crash durability, so clients are never +invited to retry against a stale revision. + +DELETE checks the same revision then unlinks only the preference and attempts +directory fsync. It never touches SQLite. Saving the default through PUT is +valid and deliberate; frontend Reset remains a draft until Save. + +## HTTP and mutation security + +`GET /api/v1/dashboard-layout` returns default, persisted or invalid-persisted +source, an ETag containing the revision and an ephemeral CSRF token header. +`PUT` and `DELETE` require the loaded ETag in `If-Match`; stale revisions return +412. Missing/malformed preconditions return 428. + +Mutations additionally require an exact Origin of either the direct active +loopback URL or `http://trainlog.perf`, plus the 256-bit startup-random token in +`X-Trainlog-CSRF-Token`. The token is never persisted, logged or placed in a +URL. nginx may keep rewriting backend Host to the already accepted +`127.0.0.1:8080`; Origin remains the browser origin and requires no proxy +change. Host validation, loopback binding, no-CORS behavior and request bounds +remain unchanged. + +## Frontend lifecycle + +The Dashboard withholds the grid behind a discreet loading state until GET +finishes, avoiding default-to-persisted flashing. Network failure falls back to +the default with an accessible warning and disables Save because no safe +revision/token was loaded. Entry into edit mode snapshots the loaded canon; +Cancel is local, Reset changes only the draft, and Save validates then PUTs. +Success adopts the server response and revision. A 412 keeps editing open, +announces the conflict and exposes `Recharger l'agencement`. + +Only the 12-column desktop canon is sent. Six-column/tablet and one-column/phone +projections remain derived and cannot trigger mutation. diff --git a/docs/roadmap.md b/docs/roadmap.md index c0887ac..a0de87f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,13 +26,13 @@ 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_STARTED`; its local-only CLI/HTTP infrastructure, embedded frontend shell and the frozen Dashboard data -contract and the frozen interactive grid exist. Final tile rendering and layout -persistence remain future slices. +contract, frozen interactive grid and versioned layout persistence exist. Final +tile rendering remains a future slice. ## Current cursor ```text -CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_LAYOUT_V1 +CURRENT_OPERATIONAL_CURSOR=WEB_DASHBOARD_TILES_V1 ``` `WEB_FRONTEND_SHELL_V1=PASS/FROZEN`. `trainlog -w` serves the embedded React, @@ -43,6 +43,9 @@ undifferentiated implementation of every Dashboard concern. `WEB_DASHBOARD_GRID_V1=PASS/FROZEN` adds the validated 12-column desktop grid, explicit edit mode, drag/resize, keyboard alternative and derived responsive projections without persisting layout state. +`WEB_DASHBOARD_LAYOUT_V1=PASS/FROZEN` persists only that canonical desktop +layout with atomic XDG storage, optimistic revisions and protected HTTP +mutations; responsive projections remain derived. ## TRAINLOG_WEB_V1 implementation gate @@ -75,7 +78,7 @@ must consume them without reconstructing or redefining their mathematics. ```text WEB_DASHBOARD_DATA_CONTRACT_V1 [PASS/FROZEN] -> WEB_DASHBOARD_GRID_V1 [PASS/FROZEN] - -> WEB_DASHBOARD_LAYOUT_V1 + -> WEB_DASHBOARD_LAYOUT_V1 [PASS/FROZEN] -> real-data tile delivery -> real-data visualization delivery ``` diff --git a/docs/tests.md b/docs/tests.md index a49c84f..1e9c3cd 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -76,6 +76,15 @@ normal-mode handles, edit actions, cancel/reset/session-only save, visible keyboard focus and live announcements. Existing shell tests continue to prove the factual Footer, navigation and absence of invented data. +`WEB_DASHBOARD_LAYOUT_V1` adds Core-independent filesystem tests for default, +strict parsing, bounds, corruption, oversized/non-regular/symlink inputs, +private permissions, atomic replacement, revision conflicts, preservation on +invalid writes and reset. A canonical JSON parity test prevents C/TypeScript +constraint drift. HTTP tests cover GET/PUT/DELETE, ETag/If-Match, two stale +clients, CSRF/Origin rejection and `trainlog.perf` Origin acceptance. Vitest +covers default/persisted/invalid sources, ETag/token parsing, canonical-only +PUT, conflict translation, loading, local cancel/reset and durable Save. + ## 2. Frozen Trainlog JSON v1 Run: diff --git a/tools/validate_dashboard_layout_contract.py b/tools/validate_dashboard_layout_contract.py new file mode 100644 index 0000000..48e8a0e --- /dev/null +++ b/tools/validate_dashboard_layout_contract.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +import json +import pathlib +import re +import sys + +root = pathlib.Path(__file__).resolve().parents[1] +contract = json.loads((root / "contracts/dashboard-layout-v1.json").read_text()) +source = (root / "tui/src/dashboard_layout.c").read_text() +frontend = (root / "web/src/dashboard/dashboardLayout.ts").read_text() + +if contract.get("columns") != 12 or contract.get("max_y") != 200 or len(contract.get("tiles", [])) != 7: + sys.exit("invalid canonical dashboard layout contract") +for tile in contract["tiles"]: + constraint = '{{"{}", {}U, {}U, {}U, {}U}}'.format( + tile["id"], tile["min_width"], tile["max_width"], tile["min_height"], tile["max_height"]) + default = tile["default"] + default_fragment = '{{"{}", {}U, {}U, {}U, {}U}}'.format( + tile["id"], default["x"], default["y"], default["width"], default["height"]) + if constraint not in source or default_fragment not in source: + sys.exit(f"C contract drift: {tile['id']}") +if "dashboard-layout-v1.json" not in frontend: + sys.exit("frontend does not consume canonical dashboard layout contract") +print("PASS dashboard layout contract C/TypeScript parity") diff --git a/tui/include/trainlog/dashboard_layout.h b/tui/include/trainlog/dashboard_layout.h new file mode 100644 index 0000000..40d5357 --- /dev/null +++ b/tui/include/trainlog/dashboard_layout.h @@ -0,0 +1,59 @@ +#ifndef TRAINLOG_DASHBOARD_LAYOUT_H +#define TRAINLOG_DASHBOARD_LAYOUT_H + +#include +#include +#include + +#define TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT 7U +#define TRAINLOG_DASHBOARD_LAYOUT_COLUMNS 12U +#define TRAINLOG_DASHBOARD_LAYOUT_MAX_Y 200U +#define TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY 4096U +#define TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION 9007199254740991ULL + +typedef struct TrainlogDashboardTileLayout { + char id[32]; + uint16_t x; + uint16_t y; + uint16_t width; + uint16_t height; +} TrainlogDashboardTileLayout; + +typedef struct TrainlogDashboardLayout { + uint64_t revision; + TrainlogDashboardTileLayout tiles[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT]; +} TrainlogDashboardLayout; + +typedef enum TrainlogDashboardLayoutSource { + TRAINLOG_DASHBOARD_LAYOUT_DEFAULT = 0, + TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, + TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED +} TrainlogDashboardLayoutSource; + +typedef enum TrainlogDashboardLayoutResult { + TRAINLOG_DASHBOARD_LAYOUT_OK = 0, + TRAINLOG_DASHBOARD_LAYOUT_INVALID, + TRAINLOG_DASHBOARD_LAYOUT_CONFLICT, + TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR +} TrainlogDashboardLayoutResult; + +/* CONTRACT: this is the sole backend owner of the versioned Dashboard UI + * preference. It never accesses SQLite. Callers own all buffers and layouts. + * Revision zero denotes the default/no-valid-file state. */ +void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout); +bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout); +TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse( + const char *json, size_t size, TrainlogDashboardLayout *layout); +TrainlogDashboardLayoutResult trainlog_dashboard_layout_load( + TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source); +TrainlogDashboardLayoutResult trainlog_dashboard_layout_save( + const TrainlogDashboardLayout *layout, uint64_t expected_revision, + TrainlogDashboardLayout *saved); +TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete( + uint64_t expected_revision); +bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout, + TrainlogDashboardLayoutSource source, bool include_source, + char *output, size_t capacity, size_t *size); +bool trainlog_dashboard_layout_path(char *output, size_t capacity); + +#endif diff --git a/tui/meson.build b/tui/meson.build index 1c1b632..73f88b6 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -8,6 +8,7 @@ mtp_dep = dependency('libmtp', required: true) notcurses_dep = dependency('notcurses-core', required: true) microhttpd_dep = dependency('libmicrohttpd', required: true) +yyjson_dep = dependency('yyjson', required: true) utf8proc_dep = dependency('libutf8proc', required: false) if not utf8proc_dep.found() @@ -38,12 +39,14 @@ if web_assets_enabled '../web/tsconfig.json', '../web/tsconfig.app.json', '../web/tsconfig.node.json', + '../contracts/dashboard-layout-v1.json', '../web/vite.config.ts', '../web/index.html', '../web/src/main.tsx', '../web/src/vite-env.d.ts', '../web/src/api/health.ts', '../web/src/api/dashboard.ts', + '../web/src/api/dashboardLayout.ts', '../web/src/app/App.tsx', '../web/src/app/routes.ts', '../web/src/app/useRoute.ts', @@ -239,6 +242,7 @@ trainlog_tui_sources = files( 'src/terminal.c', 'src/theme.c', 'src/tui.c', + 'src/dashboard_layout.c', 'src/web_server.c', ) trainlog_tui_sources += web_assets_source @@ -250,6 +254,7 @@ trainlog_exe = executable( trainlog_core_dep, notcurses_dep, microhttpd_dep, + yyjson_dep, ], include_directories: trainlog_private_include, c_args: strict_c_args, @@ -330,12 +335,23 @@ test('cli', test_cli) test_web_server = executable( 'test_web_server', - ['tests/test_web_server.c', 'src/web_server.c', web_assets_source], - dependencies: [trainlog_core_dep, microhttpd_dep], + ['tests/test_web_server.c', 'src/web_server.c', 'src/dashboard_layout.c', web_assets_source], + dependencies: [trainlog_core_dep, microhttpd_dep, yyjson_dep], include_directories: trainlog_private_include, c_args: strict_c_args, ) test('web_server', test_web_server, timeout: 30) + +test_dashboard_layout = executable( + 'test_dashboard_layout', + ['tests/test_dashboard_layout.c', 'src/dashboard_layout.c'], + dependencies: yyjson_dep, + include_directories: trainlog_include, + c_args: strict_c_args, +) +test('dashboard_layout', test_dashboard_layout) +test('dashboard_layout_contract', find_program('python3'), + args: [meson.project_source_root() / 'tools/validate_dashboard_layout_contract.py']) test('cli_help', trainlog_exe, args: ['--help']) test('cli_version', trainlog_exe, args: ['--version']) diff --git a/tui/src/dashboard_layout.c b/tui/src/dashboard_layout.c new file mode 100644 index 0000000..7d190c5 --- /dev/null +++ b/tui/src/dashboard_layout.c @@ -0,0 +1,325 @@ +#include "trainlog/dashboard_layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +typedef struct TileContract { + const char *id; + uint16_t min_width; + uint16_t max_width; + uint16_t min_height; + uint16_t max_height; +} TileContract; + +static const TileContract contracts[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = { + {"next-session", 4U, 8U, 3U, 7U}, + {"activity", 5U, 12U, 3U, 7U}, + {"progression", 6U, 12U, 4U, 9U}, + {"last-session", 3U, 7U, 3U, 6U}, + {"max-records", 3U, 8U, 3U, 7U}, + {"muscle-distribution", 5U, 12U, 4U, 9U}, + {"cardio-recovery", 5U, 12U, 4U, 8U} +}; + +static const TrainlogDashboardTileLayout defaults[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = { + {"next-session", 0U, 0U, 5U, 4U}, {"activity", 5U, 0U, 7U, 4U}, + {"progression", 0U, 4U, 8U, 5U}, {"last-session", 8U, 4U, 4U, 3U}, + {"max-records", 8U, 7U, 4U, 3U}, + {"muscle-distribution", 0U, 9U, 6U, 5U}, + {"cardio-recovery", 6U, 10U, 6U, 4U} +}; + +static int contract_index(const char *id) +{ + size_t index; + for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) + if (strcmp(id, contracts[index].id) == 0) return (int)index; + return -1; +} + +void trainlog_dashboard_layout_default(TrainlogDashboardLayout *layout) +{ + if (layout == NULL) return; + layout->revision = 0U; + (void)memcpy(layout->tiles, defaults, sizeof(defaults)); +} + +static bool overlap(const TrainlogDashboardTileLayout *a, + const TrainlogDashboardTileLayout *b) +{ + return a->x < b->x + b->width && a->x + a->width > b->x && + a->y < b->y + b->height && a->y + a->height > b->y; +} + +bool trainlog_dashboard_layout_validate(const TrainlogDashboardLayout *layout) +{ + bool seen[TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT] = {false}; + size_t index; + if (layout == NULL || layout->revision > TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) + return false; + for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) { + const TrainlogDashboardTileLayout *tile = &layout->tiles[index]; + int found = contract_index(tile->id); + const TileContract *contract; + size_t other; + if (found < 0 || seen[(size_t)found]) return false; + seen[(size_t)found] = true; + contract = &contracts[(size_t)found]; + if (tile->y > TRAINLOG_DASHBOARD_LAYOUT_MAX_Y || + tile->width < contract->min_width || tile->width > contract->max_width || + tile->height < contract->min_height || tile->height > contract->max_height || + tile->x + tile->width > TRAINLOG_DASHBOARD_LAYOUT_COLUMNS) + return false; + for (other = index + 1U; other < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++other) + if (overlap(tile, &layout->tiles[other])) return false; + } + return true; +} + +static bool exact_object(const yyjson_val *value, size_t expected) +{ + return yyjson_is_obj(value) && yyjson_obj_size(value) == expected; +} + +static bool read_u16(yyjson_val *value, uint16_t *output) +{ + uint64_t number; + if (!yyjson_is_uint(value)) return false; + number = yyjson_get_uint(value); + if (number > UINT16_MAX) return false; + *output = (uint16_t)number; + return true; +} + +static bool equals_uint(yyjson_val *value, uint64_t expected) +{ + return yyjson_is_uint(value) && yyjson_get_uint(value) == expected; +} + +TrainlogDashboardLayoutResult trainlog_dashboard_layout_parse( + const char *json, size_t size, TrainlogDashboardLayout *layout) +{ + yyjson_read_err error; + yyjson_doc *document; + yyjson_val *root; + yyjson_val *tiles; + size_t index; + if (json == NULL || layout == NULL || size == 0U || + size >= TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY) + return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + document = yyjson_read_opts((char *)json, size, YYJSON_READ_NOFLAG, NULL, &error); + if (document == NULL) return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + root = yyjson_doc_get_root(document); + if (!exact_object(root, 5U) || + !yyjson_equals_str(yyjson_obj_get(root, "format"), "trainlog-dashboard-layout") || + !equals_uint(yyjson_obj_get(root, "version"), 1U) || + !equals_uint(yyjson_obj_get(root, "columns"), 12U) || + !yyjson_is_uint(yyjson_obj_get(root, "revision"))) { + yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + } + layout->revision = yyjson_get_uint(yyjson_obj_get(root, "revision")); + tiles = yyjson_obj_get(root, "tiles"); + if (!yyjson_is_arr(tiles) || yyjson_arr_size(tiles) != TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT) { + yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + } + for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) { + yyjson_val *tile = yyjson_arr_get(tiles, index); + yyjson_val *id = yyjson_obj_get(tile, "id"); + const char *text; + size_t length; + if (!exact_object(tile, 5U) || !yyjson_is_str(id) || + !read_u16(yyjson_obj_get(tile, "x"), &layout->tiles[index].x) || + !read_u16(yyjson_obj_get(tile, "y"), &layout->tiles[index].y) || + !read_u16(yyjson_obj_get(tile, "width"), &layout->tiles[index].width) || + !read_u16(yyjson_obj_get(tile, "height"), &layout->tiles[index].height)) { + yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + } + text = yyjson_get_str(id); length = yyjson_get_len(id); + if (length == 0U || length >= sizeof(layout->tiles[index].id)) { + yyjson_doc_free(document); return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + } + (void)memcpy(layout->tiles[index].id, text, length); + layout->tiles[index].id[length] = '\0'; + } + yyjson_doc_free(document); + return trainlog_dashboard_layout_validate(layout) + ? TRAINLOG_DASHBOARD_LAYOUT_OK : TRAINLOG_DASHBOARD_LAYOUT_INVALID; +} + +bool trainlog_dashboard_layout_path(char *output, size_t capacity) +{ + const char *base = getenv("XDG_CONFIG_HOME"); + const char *home; + int length; + if (output == NULL || capacity == 0U) return false; + if (base != NULL && base[0] != '\0') + length = snprintf(output, capacity, "%s/trainlog/web/dashboard-layout-v1.json", base); + else { + home = getenv("HOME"); + if (home == NULL || home[0] == '\0') return false; + length = snprintf(output, capacity, "%s/.config/trainlog/web/dashboard-layout-v1.json", home); + } + return length > 0 && (size_t)length < capacity; +} + +static bool ensure_private_directories(const char *path) +{ + char web[4096]; + char trainlog[4096]; + char *last; + struct stat status; + if (path == NULL || strlen(path) >= sizeof(web)) return false; + (void)snprintf(web, sizeof(web), "%s", path); + last = strrchr(web, '/'); if (last == NULL) return false; *last = '\0'; + (void)snprintf(trainlog, sizeof(trainlog), "%s", web); + last = strrchr(trainlog, '/'); if (last == NULL) return false; *last = '\0'; + /* CONTRACT: only the two Trainlog-owned directories are created/chmodded; + * an existing symlink or non-directory is never followed. */ + { + if (mkdir(trainlog, 0700) != 0 && errno != EEXIST) return false; + if (lstat(trainlog, &status) != 0 || !S_ISDIR(status.st_mode)) return false; + if (chmod(trainlog, 0700) != 0) return false; + } + if (mkdir(web, 0700) != 0 && errno != EEXIST) return false; + if (lstat(web, &status) != 0 || !S_ISDIR(status.st_mode)) return false; + return chmod(web, 0700) == 0; +} + +static bool sync_parent(const char *path) +{ + char parent[4096]; char *slash; int fd; bool ok; + if (strlen(path) >= sizeof(parent)) return false; + (void)snprintf(parent, sizeof(parent), "%s", path); + slash = strrchr(parent, '/'); if (slash == NULL) return false; *slash = '\0'; + fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) return false; + ok = fsync(fd) == 0; if (close(fd) != 0) ok = false; return ok; +} + +bool trainlog_dashboard_layout_serialize(const TrainlogDashboardLayout *layout, + TrainlogDashboardLayoutSource source, bool include_source, + char *output, size_t capacity, size_t *size) +{ + size_t used = 0U; size_t index; int count; + const char *source_name = source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED + ? "persisted" : source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED + ? "invalid_persisted" : "default"; + if (!trainlog_dashboard_layout_validate(layout) || output == NULL || size == NULL) + return false; +#define APPEND(...) do { count = snprintf(output + used, capacity - used, __VA_ARGS__); \ + if (count < 0 || (size_t)count >= capacity - used) return false; used += (size_t)count; } while (0) + APPEND("{\"format\":\"trainlog-dashboard-layout\",\"version\":1," + "\"revision\":%llu,\"columns\":12", (unsigned long long)layout->revision); + if (include_source) APPEND(",\"source\":\"%s\"", source_name); + APPEND(",\"tiles\":["); + for (index = 0U; index < TRAINLOG_DASHBOARD_LAYOUT_TILE_COUNT; ++index) { + const TrainlogDashboardTileLayout *tile = &layout->tiles[index]; + APPEND("%s{\"id\":\"%s\",\"x\":%u,\"y\":%u,\"width\":%u,\"height\":%u}", + index == 0U ? "" : ",", tile->id, tile->x, tile->y, + tile->width, tile->height); + } + APPEND("]}\n"); +#undef APPEND + *size = used; return true; +} + +TrainlogDashboardLayoutResult trainlog_dashboard_layout_load( + TrainlogDashboardLayout *layout, TrainlogDashboardLayoutSource *source) +{ + char path[4096]; char buffer[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY]; + struct stat status; ssize_t count; int fd; TrainlogDashboardLayoutResult result; + if (layout == NULL || source == NULL || !trainlog_dashboard_layout_path(path, sizeof(path))) + return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + trainlog_dashboard_layout_default(layout); *source = TRAINLOG_DASHBOARD_LAYOUT_DEFAULT; + fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + if (fd < 0) { + if (errno == ENOENT || errno == ELOOP) { + if (errno == ELOOP) *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED; + return TRAINLOG_DASHBOARD_LAYOUT_OK; + } + return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + } + if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || status.st_size <= 0 || + status.st_size >= (off_t)sizeof(buffer)) { + (void)close(fd); *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED; + return TRAINLOG_DASHBOARD_LAYOUT_OK; + } + count = read(fd, buffer, (size_t)status.st_size); + if (close(fd) != 0 || count != status.st_size) { + *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED; + return TRAINLOG_DASHBOARD_LAYOUT_OK; + } + result = trainlog_dashboard_layout_parse(buffer, (size_t)count, layout); + if (result != TRAINLOG_DASHBOARD_LAYOUT_OK) { + trainlog_dashboard_layout_default(layout); + *source = TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED; + } else *source = TRAINLOG_DASHBOARD_LAYOUT_PERSISTED; + return TRAINLOG_DASHBOARD_LAYOUT_OK; +} + +static bool write_all(int fd, const char *data, size_t size) +{ + size_t used = 0U; + while (used < size) { ssize_t count = write(fd, data + used, size - used); + if (count < 0 && errno == EINTR) continue; if (count <= 0) return false; used += (size_t)count; } + return true; +} + +TrainlogDashboardLayoutResult trainlog_dashboard_layout_save( + const TrainlogDashboardLayout *layout, uint64_t expected_revision, + TrainlogDashboardLayout *saved) +{ + TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source; + char path[4096], temporary[4128], json[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY]; + size_t size; int fd; bool ok; + if (layout == NULL || saved == NULL || !trainlog_dashboard_layout_validate(layout) || + layout->revision != expected_revision || expected_revision >= TRAINLOG_DASHBOARD_LAYOUT_MAX_REVISION) + return TRAINLOG_DASHBOARD_LAYOUT_INVALID; + if (trainlog_dashboard_layout_load(¤t, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK) + return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT; + *saved = *layout; saved->revision = expected_revision + 1U; + if (!trainlog_dashboard_layout_path(path, sizeof(path)) || + !ensure_private_directories(path) || + !trainlog_dashboard_layout_serialize(saved, TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, + false, json, sizeof(json), &size) || + snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path) >= (int)sizeof(temporary)) + return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + fd = mkstemp(temporary); + if (fd < 0) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + ok = fchmod(fd, 0600) == 0 && fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 && + write_all(fd, json, size) && fsync(fd) == 0; + if (close(fd) != 0) ok = false; + if (!ok || rename(temporary, path) != 0) { + (void)unlink(temporary); return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + } + /* INVARIANT: after rename the valid file is committed. Directory fsync is + * attempted for crash durability, but its failure cannot roll back or make + * a successful visible commit safe to retry against the old revision. */ + (void)sync_parent(path); + return TRAINLOG_DASHBOARD_LAYOUT_OK; +} + +TrainlogDashboardLayoutResult trainlog_dashboard_layout_delete(uint64_t expected_revision) +{ + TrainlogDashboardLayout current; TrainlogDashboardLayoutSource source; char path[4096]; + if (trainlog_dashboard_layout_load(¤t, &source) != TRAINLOG_DASHBOARD_LAYOUT_OK) + return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + if (current.revision != expected_revision) return TRAINLOG_DASHBOARD_LAYOUT_CONFLICT; + if (!trainlog_dashboard_layout_path(path, sizeof(path))) return TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + if (unlink(path) != 0) { + return errno == ENOENT ? TRAINLOG_DASHBOARD_LAYOUT_OK + : TRAINLOG_DASHBOARD_LAYOUT_IO_ERROR; + } + (void)sync_parent(path); + return TRAINLOG_DASHBOARD_LAYOUT_OK; +} diff --git a/tui/src/web_server.c b/tui/src/web_server.c index 62b84c7..9ffb36a 100644 --- a/tui/src/web_server.c +++ b/tui/src/web_server.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -12,6 +13,7 @@ #include #include "trainlog/web_dashboard.h" +#include "trainlog/dashboard_layout.h" #include "web_assets.h" #define TRAINLOG_HTTP_CONNECTION_LIMIT 32U @@ -26,11 +28,14 @@ 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) @@ -39,6 +44,35 @@ static void web_diagnostic(char *output, size_t capacity, const char *message) (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) { @@ -139,6 +173,50 @@ static bool declared_body_too_large(struct MHD_Connection *connection) 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, @@ -167,8 +245,11 @@ static enum MHD_Result handle_request(void *closure, (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; + 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; } @@ -216,6 +297,94 @@ static enum MHD_Result handle_request(void *closure, "{\"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, @@ -276,6 +445,12 @@ int trainlog_web_server_run(TrainlogDatabase *database, uint16_t 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, diff --git a/tui/tests/test_dashboard_layout.c b/tui/tests/test_dashboard_layout.c new file mode 100644 index 0000000..0877497 --- /dev/null +++ b/tui/tests/test_dashboard_layout.c @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "trainlog/dashboard_layout.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 serialize_file_layout(TrainlogDashboardLayout *layout, + char *json, size_t capacity, size_t *size) +{ + return trainlog_dashboard_layout_serialize(layout, + TRAINLOG_DASHBOARD_LAYOUT_PERSISTED, false, json, capacity, size); +} + +static bool test_validation(void) +{ + TrainlogDashboardLayout layout; + char json[4096]; size_t size; + trainlog_dashboard_layout_default(&layout); + CHECK(trainlog_dashboard_layout_validate(&layout)); + CHECK(serialize_file_layout(&layout, json, sizeof(json), &size)); + CHECK(trainlog_dashboard_layout_parse(json, size, &layout) == TRAINLOG_DASHBOARD_LAYOUT_OK); + layout.tiles[0].width = 1U; CHECK(!trainlog_dashboard_layout_validate(&layout)); + trainlog_dashboard_layout_default(&layout); + (void)snprintf(layout.tiles[1].id, sizeof(layout.tiles[1].id), "%s", layout.tiles[0].id); + CHECK(!trainlog_dashboard_layout_validate(&layout)); + trainlog_dashboard_layout_default(&layout); layout.tiles[1].x = 0U; + CHECK(!trainlog_dashboard_layout_validate(&layout)); + trainlog_dashboard_layout_default(&layout); layout.tiles[0].y = 201U; + CHECK(!trainlog_dashboard_layout_validate(&layout)); + CHECK(trainlog_dashboard_layout_parse("{}", 2U, &layout) == TRAINLOG_DASHBOARD_LAYOUT_INVALID); + return true; +} + +static bool write_raw(const char *path, const char *text) +{ + int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600); size_t size = strlen(text); + ssize_t count; if (fd < 0) return false; count = write(fd, text, size); + return close(fd) == 0 && count == (ssize_t)size; +} + +static bool test_persistence(void) +{ + char root[] = "/tmp/trainlog-layout-test-XXXXXX"; + char path[4096]; char target[4096]; char json[4096]; size_t size; + TrainlogDashboardLayout layout, saved, loaded; + TrainlogDashboardLayoutSource source; struct stat status; + CHECK(mkdtemp(root) != NULL); CHECK(setenv("XDG_CONFIG_HOME", root, 1) == 0); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK); + CHECK(source == TRAINLOG_DASHBOARD_LAYOUT_DEFAULT && loaded.revision == 0U); + trainlog_dashboard_layout_default(&layout); + layout.tiles[3].y = 14U; + CHECK(trainlog_dashboard_layout_validate(&layout)); + CHECK(trainlog_dashboard_layout_save(&layout, 0U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_OK); + CHECK(saved.revision == 1U); + CHECK(trainlog_dashboard_layout_path(path, sizeof(path))); + CHECK(lstat(path, &status) == 0 && S_ISREG(status.st_mode) && + (status.st_mode & 0777U) == 0600U); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK); + CHECK(source == TRAINLOG_DASHBOARD_LAYOUT_PERSISTED && loaded.revision == 1U); + layout = loaded; layout.tiles[0].width = 1U; + CHECK(trainlog_dashboard_layout_save(&layout, 1U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_INVALID); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK && + loaded.revision == 1U); + trainlog_dashboard_layout_default(&layout); + layout.tiles[3].y = 14U; + CHECK(trainlog_dashboard_layout_save(&layout, 0U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT); + layout = loaded; layout.tiles[3].y = 18U; + CHECK(trainlog_dashboard_layout_save(&layout, 1U, &saved) == TRAINLOG_DASHBOARD_LAYOUT_OK); + CHECK(saved.revision == 2U); + CHECK(trainlog_dashboard_layout_delete(1U) == TRAINLOG_DASHBOARD_LAYOUT_CONFLICT); + CHECK(trainlog_dashboard_layout_delete(2U) == TRAINLOG_DASHBOARD_LAYOUT_OK); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK && + source == TRAINLOG_DASHBOARD_LAYOUT_DEFAULT); + CHECK(snprintf(target, sizeof(target), "%s/target", root) > 0); + CHECK(write_raw(target, "sentinel")); CHECK(symlink(target, path) == 0); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK && + source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED); + CHECK(unlink(path) == 0); + CHECK(write_raw(path, "{broken")); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK && + source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED); + CHECK(access(path, F_OK) == 0); + CHECK(unlink(path) == 0); + { + char oversized[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY]; + (void)memset(oversized, 'x', sizeof(oversized)); + { + int fd = open(path, O_WRONLY | O_CREAT | O_EXCL, 0600); + CHECK(fd >= 0 && write(fd, oversized, sizeof(oversized)) == (ssize_t)sizeof(oversized)); + CHECK(close(fd) == 0); + } + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK && + source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED); + } + CHECK(unlink(path) == 0 && mkdir(path, 0700) == 0); + CHECK(trainlog_dashboard_layout_load(&loaded, &source) == TRAINLOG_DASHBOARD_LAYOUT_OK && + source == TRAINLOG_DASHBOARD_LAYOUT_INVALID_PERSISTED); + CHECK(rmdir(path) == 0); + CHECK(serialize_file_layout(&loaded, json, sizeof(json), &size)); + CHECK(size < sizeof(json)); + return true; +} + +int main(void) +{ + return test_validation() && test_persistence() ? 0 : 1; +} diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 62a9c74..5196fb6 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -16,6 +16,7 @@ #include #include "trainlog/database.h" +#include "trainlog/dashboard_layout.h" #include "trainlog/web_server.h" #include "web_assets.h" @@ -123,11 +124,21 @@ static bool asset_path(const char *html, const char *suffix, char *output, return false; } +static bool response_header(const char *response, const char *name, + char *output, size_t capacity) +{ + const char *start = strstr(response, name); const char *end; size_t length; + if (start == NULL) return false; start += strlen(name); + end = strstr(start, "\r\n"); if (end == NULL) return false; + length = (size_t)(end - start); if (length >= capacity) return false; + (void)memcpy(output, start, length); output[length] = '\0'; return true; +} + static bool test_http_contract(TrainlogDatabase *database) { char response[32768]; char large_request[12000]; - char request[512]; + char request[8192]; char javascript_path[256]; char stylesheet_path[256]; uint16_t port; @@ -167,6 +178,55 @@ static bool test_http_contract(TrainlogDatabase *database) CHECK(strstr(response, "\"no_cardio_data_source\"") != NULL); CHECK(strstr(response, "\"window_days\":90") != NULL); CHECK(strstr(response, "\"partial\":false") != NULL); + { + TrainlogDashboardLayout layout; + char body[TRAINLOG_DASHBOARD_LAYOUT_JSON_CAPACITY]; + char token[80]; char etag[32]; size_t body_size; + CHECK(exchange(port, + "GET /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", + response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL); + CHECK(strstr(response, "\"source\":\"default\"") != NULL); + CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) && strcmp(etag, "\"0\"") == 0); + CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)) && strlen(token) == 64U); + trainlog_dashboard_layout_default(&layout); layout.tiles[3].y = 14U; + CHECK(trainlog_dashboard_layout_serialize(&layout, + TRAINLOG_DASHBOARD_LAYOUT_DEFAULT, false, body, sizeof(body), &body_size)); + (void)snprintf(request, sizeof(request), + "PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n" + "If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: wrong\r\n" + "Content-Length: %zu\r\n\r\n%s", (unsigned int)port, body_size, body); + CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL); + (void)snprintf(request, sizeof(request), + "PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Origin: http://127.0.0.1:%u\r\nContent-Type: text/plain\r\n" + "If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\n" + "Content-Length: %zu\r\n\r\n%s", (unsigned int)port, token, body_size, body); + CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 415") != NULL); + (void)snprintf(request, sizeof(request), + "PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n" + "If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: %zu\r\n\r\n%s", + (unsigned int)port, token, body_size, body); + CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL); + CHECK(strstr(response, "\"revision\":1") != NULL && strstr(response, "\"source\":\"persisted\"") != NULL); + CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL); + (void)snprintf(request, sizeof(request), + "DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Origin: http://trainlog.perf\r\nIf-Match: \"0\"\r\n" + "X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token); + CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 412") != NULL); + (void)snprintf(request, sizeof(request), + "DELETE /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Origin: http://trainlog.perf\r\nIf-Match: \"1\"\r\n" + "X-Trainlog-CSRF-Token: %s\r\nContent-Length: 0\r\n\r\n", token); + CHECK(exchange(port, request, response, sizeof(response)) && strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"source\":\"default\"") != NULL); + CHECK(exchange(port, + "PUT /api/v1/dashboard-layout HTTP/1.1\r\nHost: 127.0.0.1\r\n" + "Content-Type: application/json\r\nContent-Length: 2\r\n\r\n{}", + response, sizeof(response)) && strstr(response, "HTTP/1.1 403") != NULL); + } CHECK(exchange(port, "POST /api/v1/dashboard 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 && @@ -296,8 +356,11 @@ static bool test_dashboard_core_error_translation(void) int main(void) { + char config_root[] = "/tmp/trainlog-web-config-XXXXXX"; TrainlogDatabase *database = NULL; bool passed; + if (mkdtemp(config_root) == NULL || setenv("XDG_CONFIG_HOME", config_root, 1) != 0) + return 1; if (trainlog_database_open(":memory:", &database) != TRAINLOG_STATUS_OK) return 1; if (!trainlog_web_assets_available()) { diff --git a/web/src/api/dashboardLayout.test.ts b/web/src/api/dashboardLayout.test.ts new file mode 100644 index 0000000..e57e8be --- /dev/null +++ b/web/src/api/dashboardLayout.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { DEFAULT_DASHBOARD_LAYOUT } from '../dashboard/dashboardLayout' +import { fetchDashboardLayout, parseDashboardLayout, saveDashboardLayout } from './dashboardLayout' + +const token = 'a'.repeat(64) +const payload = (source: 'default' | 'persisted' | 'invalid_persisted' = 'default', revision = 0) => ({ + format: 'trainlog-dashboard-layout', version: 1, revision, columns: 12, + source, tiles: DEFAULT_DASHBOARD_LAYOUT, +}) + +describe('API de persistance Dashboard', () => { + it.each([['default', 0], ['persisted', 3], ['invalid_persisted', 0]] as const)( + 'valide une source backend %s', (source, revision) => { + expect(parseDashboardLayout(payload(source, revision), `"${revision}"`, token).source).toBe(source) + }) + + it('rejette une réponse sans ETag/token et un layout invalide', () => { + expect(() => parseDashboardLayout(payload(), '', '')).toThrow() + const invalid = payload(); invalid.tiles = [...DEFAULT_DASHBOARD_LAYOUT, DEFAULT_DASHBOARD_LAYOUT[0]] + expect(() => parseDashboardLayout(invalid, '"0"', token)).toThrow() + }) + + it('charge le layout et conserve ETag/token', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, status: 200, + headers: new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': token }), + json: () => Promise.resolve(payload()) }))) + const loaded = await fetchDashboardLayout() + expect(loaded.etag).toBe('"0"') + expect(loaded.csrfToken).toBe(token) + }) + + it('envoie uniquement le canon desktop avec If-Match et CSRF', async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ ETag: '"1"', 'X-Trainlog-CSRF-Token': token }), + json: () => Promise.resolve(payload('persisted', 1)), + })) + vi.stubGlobal('fetch', fetchMock) + const current = parseDashboardLayout(payload(), '"0"', token) + await saveDashboardLayout(current, DEFAULT_DASHBOARD_LAYOUT) + const init = fetchMock.mock.calls[0][1] as RequestInit + expect(init.method).toBe('PUT') + expect((init.headers as Record)['If-Match']).toBe('"0"') + expect((init.headers as Record)['X-Trainlog-CSRF-Token']).toBe(token) + const body = JSON.parse(String(init.body)) + expect(body.columns).toBe(12) + expect(body).not.toHaveProperty('source') + expect(body).not.toHaveProperty('breakpoints') + }) + + it('traduit 412 en conflit explicite', async () => { + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: false, status: 412, + headers: new Headers(), json: () => Promise.resolve({ error: 'revision_conflict' }) }))) + await expect(saveDashboardLayout(parseDashboardLayout(payload(), '"0"', token), + DEFAULT_DASHBOARD_LAYOUT)).rejects.toThrow('revision_conflict') + }) +}) diff --git a/web/src/api/dashboardLayout.ts b/web/src/api/dashboardLayout.ts new file mode 100644 index 0000000..a8bdb41 --- /dev/null +++ b/web/src/api/dashboardLayout.ts @@ -0,0 +1,72 @@ +import { TILE_IDS, validateDashboardLayout, type TileLayout } from '../dashboard/dashboardLayout' + +export interface DashboardLayoutSnapshot { + format: 'trainlog-dashboard-layout' + version: 1 + revision: number + columns: 12 + source: 'default' | 'persisted' | 'invalid_persisted' + tiles: TileLayout[] + etag: string + csrfToken: string +} + +const record = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +export function parseDashboardLayout(value: unknown, etag: string, csrfToken: string): DashboardLayoutSnapshot { + if (!record(value) || value.format !== 'trainlog-dashboard-layout' || value.version !== 1 || + value.columns !== 12 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || + !['default', 'persisted', 'invalid_persisted'].includes(String(value.source)) || !Array.isArray(value.tiles)) + throw new TypeError('agencement invalide') + const tiles = value.tiles.map((tile) => { + if (!record(tile) || typeof tile.id !== 'string' || !TILE_IDS.includes(tile.id as TileLayout['id']) || + !Number.isInteger(tile.x) || !Number.isInteger(tile.y) || + !Number.isInteger(tile.width) || !Number.isInteger(tile.height)) + throw new TypeError('tuile d’agencement invalide') + return { id: tile.id, x: tile.x, y: tile.y, width: tile.width, height: tile.height } as TileLayout + }) + if (validateDashboardLayout(tiles).length !== 0 || !/^"\d+"$/.test(etag) || csrfToken.length !== 64) + throw new TypeError('contrat d’agencement invalide') + return { ...value, tiles, etag, csrfToken } as DashboardLayoutSnapshot +} + +async function parseResponse(response: Response): Promise { + if (!response.ok) throw new Error(`dashboard-layout HTTP ${response.status}`) + return parseDashboardLayout(await response.json(), response.headers.get('ETag') ?? '', + response.headers.get('X-Trainlog-CSRF-Token') ?? '') +} + +export async function fetchDashboardLayout(signal?: AbortSignal): Promise { + return parseResponse(await fetch('/api/v1/dashboard-layout', { + headers: { Accept: 'application/json' }, signal, + })) +} + +export async function saveDashboardLayout(snapshot: DashboardLayoutSnapshot, + tiles: readonly TileLayout[]): Promise { + const body = { + format: 'trainlog-dashboard-layout', version: 1, revision: snapshot.revision, + columns: 12, tiles, + } + const response = await fetch('/api/v1/dashboard-layout', { + method: 'PUT', + headers: { + Accept: 'application/json', 'Content-Type': 'application/json', + 'If-Match': snapshot.etag, 'X-Trainlog-CSRF-Token': snapshot.csrfToken, + }, + body: JSON.stringify(body), + }) + if (response.status === 412) throw new Error('revision_conflict') + return parseResponse(response) +} + +export async function deleteDashboardLayout(snapshot: DashboardLayoutSnapshot): Promise { + const response = await fetch('/api/v1/dashboard-layout', { + method: 'DELETE', + headers: { Accept: 'application/json', 'If-Match': snapshot.etag, + 'X-Trainlog-CSRF-Token': snapshot.csrfToken }, + }) + if (response.status === 412) throw new Error('revision_conflict') + return parseResponse(response) +} diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx index b44ba89..7a7cd5e 100644 --- a/web/src/app/App.test.tsx +++ b/web/src/app/App.test.tsx @@ -25,11 +25,30 @@ const dashboard: DashboardSnapshot = { meta: { partial: false, invalid_data: false, generated_at: '2026-09-16T12:00:00Z' }, } +const dashboardLayout = { + format: 'trainlog-dashboard-layout', version: 1, revision: 0, columns: 12, + source: 'default', + tiles: [ + { id: 'next-session', x: 0, y: 0, width: 5, height: 4 }, + { id: 'activity', x: 5, y: 0, width: 7, height: 4 }, + { id: 'progression', x: 0, y: 4, width: 8, height: 5 }, + { id: 'last-session', x: 8, y: 4, width: 4, height: 3 }, + { id: 'max-records', x: 8, y: 7, width: 4, height: 3 }, + { id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 }, + { id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 }, + ], +} + +const layoutHeaders = new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }) + +function responseValue(input: RequestInfo | URL, dashboardValue: unknown, healthValue: unknown) { + const url = String(input) + if (url.includes('/dashboard-layout')) return { ok: true, status: 200, headers: layoutHeaders, json: () => Promise.resolve(dashboardLayout) } + return { ok: true, status: 200, headers: new Headers(), json: () => Promise.resolve(url.includes('/dashboard') ? dashboardValue : healthValue) } +} + function mockHealth(value: unknown = health) { - vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({ - ok: true, - json: () => Promise.resolve(String(input).includes('/dashboard') ? dashboard : value), - }))) + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, dashboard, value)))) } describe('shell Trainlog', () => { @@ -44,7 +63,7 @@ describe('shell Trainlog', () => { expect(footer.parentElement).toHaveClass('app-shell') expect(screen.getByRole('link', { name: 'Trainlog — Dashboard' })).toHaveTextContent('TRAINLOG') expect(screen.queryByText('Vos repères d’entraînement, sans extrapolation ni donnée inventée.')).not.toBeInTheDocument() - expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7) + await waitFor(() => expect(screen.getAllByText('INDISPONIBLE')).toHaveLength(7)) expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('Indisponible') expect(screen.getByLabelText('Dernière zone')).toHaveTextContent('Indisponible') @@ -96,10 +115,7 @@ describe('shell Trainlog', () => { it('alimente le Footer uniquement avec les faits Dashboard reçus', async () => { const actual = structuredClone(dashboard) actual.data.footer = { user: 'fy59', last_session_date: '2026-09-15T10:00:00+02:00', last_zones: ['DOS', 'BRAS'] } - vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({ - ok: true, - json: () => Promise.resolve(String(input).includes('/dashboard') ? actual : health), - }))) + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, actual, health)))) render() await waitFor(() => expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('fy59')) expect(screen.getByLabelText('Dernière séance')).toHaveTextContent('2026-09-15T10:00:00+02:00') @@ -108,10 +124,7 @@ describe('shell Trainlog', () => { it('rejette les snapshots Dashboard invalides sans inventer de Footer', async () => { expect(() => parseDashboard({ api_version: 1, data: {}, meta: {} })).toThrow() - vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve({ - ok: true, - json: () => Promise.resolve(String(input).includes('/dashboard') ? { status: 'ok' } : health), - }))) + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => Promise.resolve(responseValue(input, { status: 'ok' }, health)))) render() await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Backend connecté')) expect(screen.getByLabelText('Utilisateur')).toHaveTextContent('—') diff --git a/web/src/dashboard/DashboardGrid.test.tsx b/web/src/dashboard/DashboardGrid.test.tsx index 34a2693..ad1d518 100644 --- a/web/src/dashboard/DashboardGrid.test.tsx +++ b/web/src/dashboard/DashboardGrid.test.tsx @@ -1,30 +1,44 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { DashboardGrid } from './DashboardGrid' +import { DEFAULT_DASHBOARD_LAYOUT } from './dashboardLayout' + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + const submitted = init?.method === 'PUT' ? JSON.parse(String(init.body)) : null + return Promise.resolve({ + ok: true, status: 200, + headers: new Headers({ ETag: submitted ? '"1"' : '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }), + json: () => Promise.resolve({ format: 'trainlog-dashboard-layout', version: 1, + revision: submitted ? 1 : 0, columns: 12, source: submitted ? 'persisted' : 'default', + tiles: submitted?.tiles ?? DEFAULT_DASHBOARD_LAYOUT }), + }) + })) +}) describe('grille interactive du Dashboard', () => { - it('est verrouillée par défaut sans poignées de redimensionnement', () => { + it('est verrouillée par défaut sans poignées de redimensionnement', async () => { const { container } = render() - expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument() + expect(await screen.findByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument() expect(container.querySelector('.dashboard-layout')).not.toHaveClass('is-editing') expect(container.querySelectorAll('.react-resizable-handle')).toHaveLength(0) expect(screen.queryByRole('article', { name: /tuile modifiable/i })).not.toBeInTheDocument() }) - it('active drag, resize et les commandes transactionnelles en mode édition', () => { + it('active drag, resize et les commandes transactionnelles en mode édition', async () => { const { container } = render() - fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' })) + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) expect(container.querySelector('.dashboard-layout')).toHaveClass('is-editing') expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Réinitialiser' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Enregistrer pour cette session' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeInTheDocument() expect(container.querySelectorAll('.react-resizable-handle').length).toBeGreaterThan(0) expect(screen.getByRole('article', { name: 'Progression, tuile modifiable' })).toHaveAttribute('tabindex', '0') }) - it('déplace et redimensionne une tuile au clavier avec annonce accessible', () => { + it('déplace et redimensionne une tuile au clavier avec annonce accessible', async () => { render() - fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' })) + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' }) tile.focus() expect(tile).toHaveFocus() @@ -34,11 +48,11 @@ describe('grille interactive du Dashboard', () => { expect(screen.getByText(/Progression :.*largeur 9/)).toBeInTheDocument() }) - it('Annuler restaure le layout d’entrée et Enregistrer conserve l’état React', () => { + it('Annuler restaure le layout d’entrée et Enregistrer persiste l’état React', async () => { const { container } = render() const progression = () => container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) const initial = progression().style.width - fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' })) fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true }) expect(progression().style.width).not.toBe(initial) fireEvent.click(screen.getByRole('button', { name: 'Annuler' })) @@ -47,18 +61,47 @@ describe('grille interactive du Dashboard', () => { fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' })) fireEvent.keyDown(screen.getByRole('article', { name: 'Progression, tuile modifiable' }), { key: 'ArrowRight', shiftKey: true }) const edited = progression().style.width - fireEvent.click(screen.getByRole('button', { name: 'Enregistrer pour cette session' })) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument()) expect(progression().style.width).toBe(edited) - expect(screen.getByRole('button', { name: 'Modifier l’agencement' })).toBeInTheDocument() }) - it('Réinitialiser restaure le défaut dans le brouillon', () => { + it('Réinitialiser restaure le défaut dans le brouillon', async () => { const { container } = render() - fireEvent.click(screen.getByRole('button', { name: 'Modifier l’agencement' })) + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) const tile = screen.getByRole('article', { name: 'Progression, tuile modifiable' }) fireEvent.keyDown(tile, { key: 'ArrowRight', shiftKey: true }) const modified = (container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width fireEvent.click(screen.getByRole('button', { name: 'Réinitialiser' })) expect((container.querySelector('[data-testid="grid-item-progression"]') as HTMLElement).style.width).not.toBe(modified) }) + + it('conserve le brouillon et propose un reload lors d’un conflit 412', async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === 'PUT') return Promise.resolve({ ok: false, status: 412, + headers: new Headers(), json: () => Promise.resolve({ error: 'revision_conflict' }) }) + return Promise.resolve({ ok: true, status: 200, + headers: new Headers({ ETag: '"0"', 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }), + json: () => Promise.resolve({ format: 'trainlog-dashboard-layout', version: 1, + revision: 0, columns: 12, source: 'default', tiles: DEFAULT_DASHBOARD_LAYOUT }) }) + }) + vi.stubGlobal('fetch', fetchMock) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) + fireEvent.click(screen.getByRole('button', { name: 'Enregistrer' })) + expect(await screen.findByRole('alert')).toHaveTextContent('modifié dans un autre onglet') + expect(screen.getByRole('button', { name: 'Recharger l’agencement' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Annuler' })).toBeInTheDocument() + }) + + it('ne sauvegarde jamais depuis la projection téléphone', async () => { + window.innerWidth = 390 + const fetchMock = vi.mocked(fetch) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Modifier l’agencement' })) + expect(screen.getByRole('button', { name: 'Enregistrer' })).toBeDisabled() + expect(screen.getByText(/écran large/)).toBeInTheDocument() + expect(fetchMock).toHaveBeenCalledTimes(1) + window.innerWidth = 1440 + }) }) diff --git a/web/src/dashboard/DashboardGrid.tsx b/web/src/dashboard/DashboardGrid.tsx index 91da14e..b95f48e 100644 --- a/web/src/dashboard/DashboardGrid.tsx +++ b/web/src/dashboard/DashboardGrid.tsx @@ -1,7 +1,8 @@ -import { useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import GridLayout, { useContainerWidth, verticalCompactor, type Layout } from 'react-grid-layout' import { EmptyState } from '../components/EmptyState' import { Tile } from '../components/Tile' +import { fetchDashboardLayout, saveDashboardLayout, type DashboardLayoutSnapshot } from '../api/dashboardLayout' import { cloneLayout, DEFAULT_DASHBOARD_LAYOUT, @@ -12,6 +13,7 @@ import { tileSize, TILE_IDS, toGridLayout, + validateDashboardLayout, type TileId, type TileLayout, } from './dashboardLayout' @@ -36,6 +38,10 @@ export function DashboardGrid() { const [layout, setLayout] = useState(() => cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) const [editing, setEditing] = useState(false) const [announcement, setAnnouncement] = useState('') + const [snapshot, setSnapshot] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [preferenceError, setPreferenceError] = useState('') const beforeEditing = useRef(cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) // The viewport-derived seed prevents a wide first frame from overflowing on // phones before ResizeObserver has measured the actual grid container. @@ -49,6 +55,23 @@ export function DashboardGrid() { [layout, view.columns], ) + async function reloadLayout() { + setLoading(true) + try { + const loaded = await fetchDashboardLayout() + setSnapshot(loaded) + setLayout(cloneLayout(loaded.tiles)) + setPreferenceError(loaded.source === 'invalid_persisted' + ? 'L’agencement enregistré est invalide. Le défaut Trainlog est utilisé.' : '') + } catch { + setSnapshot(null) + setLayout(cloneLayout(DEFAULT_DASHBOARD_LAYOUT)) + setPreferenceError('Impossible de charger l’agencement enregistré. Le défaut Trainlog est utilisé.') + } finally { setLoading(false) } + } + + useEffect(() => { void reloadLayout() }, []) + function enterEditMode() { beforeEditing.current = cloneLayout(layout) setEditing(true) @@ -66,9 +89,21 @@ export function DashboardGrid() { setAnnouncement('Agencement par défaut restauré dans le brouillon.') } - function saveEditing() { - setEditing(false) - setAnnouncement('Agencement appliqué pour cette session. Il reviendra à sa valeur par défaut après rechargement.') + async function saveEditing() { + if (!desktop || snapshot === null || validateDashboardLayout(layout).length !== 0) { + setPreferenceError('Impossible d’enregistrer cet agencement.') + return + } + setSaving(true); setPreferenceError('') + try { + const saved = await saveDashboardLayout(snapshot, layout) + setSnapshot(saved); setLayout(cloneLayout(saved.tiles)); setEditing(false) + setAnnouncement('Agencement enregistré durablement.') + } catch (error) { + if (error instanceof Error && error.message === 'revision_conflict') + setPreferenceError('L’agencement a été modifié dans un autre onglet. Rechargez l’agencement courant.') + else setPreferenceError('L’enregistrement de l’agencement a échoué.') + } finally { setSaving(false) } } function acceptLibraryLayout(next: Layout) { @@ -94,6 +129,8 @@ export function DashboardGrid() { const libraryLayout = toGridLayout(visibleLayout, editing && desktop, view.columns) + if (loading) return

Chargement de l’agencement…

+ return (
@@ -105,10 +142,11 @@ export function DashboardGrid() { {!desktop &&

Revenez sur un écran large pour modifier la grille canonique.

} - + )}
+ {preferenceError &&
{preferenceError}{preferenceError.includes('autre onglet') && }
}

{announcement}

> = { - 'next-session': { minWidth: 4, maxWidth: 8, minHeight: 3, maxHeight: 7 }, - activity: { minWidth: 5, maxWidth: 12, minHeight: 3, maxHeight: 7 }, - progression: { minWidth: 6, maxWidth: 12, minHeight: 4, maxHeight: 9 }, - 'last-session': { minWidth: 3, maxWidth: 7, minHeight: 3, maxHeight: 6 }, - 'max-records': { minWidth: 3, maxWidth: 8, minHeight: 3, maxHeight: 7 }, - 'muscle-distribution': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 9 }, - 'cardio-recovery': { minWidth: 5, maxWidth: 12, minHeight: 4, maxHeight: 8 }, -} +export const TILE_CONSTRAINTS = Object.fromEntries(contract.tiles.map((tile) => [tile.id, { + minWidth: tile.min_width, maxWidth: tile.max_width, + minHeight: tile.min_height, maxHeight: tile.max_height, +}])) as Readonly> -export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = [ - { id: 'next-session', x: 0, y: 0, width: 5, height: 4 }, - { id: 'activity', x: 5, y: 0, width: 7, height: 4 }, - { id: 'progression', x: 0, y: 4, width: 8, height: 5 }, - { id: 'last-session', x: 8, y: 4, width: 4, height: 3 }, - { id: 'max-records', x: 8, y: 7, width: 4, height: 3 }, - { id: 'muscle-distribution', x: 0, y: 9, width: 6, height: 5 }, - { id: 'cardio-recovery', x: 6, y: 10, width: 6, height: 4 }, -] as const +export const DEFAULT_DASHBOARD_LAYOUT: readonly TileLayout[] = contract.tiles.map((tile) => ({ + id: tile.id as TileId, ...tile.default, +})) const tileIdSet = new Set(TILE_IDS) diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 23dbedd..ffaf7a3 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -58,6 +58,10 @@ h2 { color: var(--text); font-size: 1.08rem; letter-spacing: -.02em; } .layout-action-primary:hover { color: var(--crust); background: var(--blue); border-color: transparent; } .layout-help, .layout-breakpoint-note { margin: 0 auto 0 0; color: var(--subtext-0); font: 550 .7rem/1.4 var(--font-mono); } .layout-breakpoint-note { flex-basis: 100%; color: var(--yellow); } +.layout-loading { min-height: 12rem; display: grid; place-items: center; color: var(--subtext-0); } +.layout-message { margin: 0 0 1rem; padding: .7rem .85rem; display: flex; align-items: center; gap: .75rem; color: var(--subtext-0); background: var(--surface-0); border-radius: .55rem; font-size: .82rem; } +.layout-message.is-error { color: var(--peach); } +.layout-action:disabled { opacity: .55; cursor: not-allowed; } .dashboard-grid-frame { min-width: 0; } .dashboard-grid { min-width: 0; } .dashboard-grid > .react-grid-item { min-width: 0; }