Files
breadcrumb-the-shire/core/Support/helpers/export.php
fs 12356e2266 feat(core): generic CSV export primitive + helpdesk domains consumer
Add a reusable CSV-export building block to the core so any Grid.js list
page can gain a filter/sort-aware download with two clicks of glue.

Core primitive:
- core/Service/Export/CsvExportService: flavor-aware writer (plain CSV
  or Excel-compatible UTF-8+BOM+semicolon), with OWASP-aligned formula-
  injection escape (=, @, +, -, TAB, CR) applied to both rows *and*
  headers. Value objects for columns (ExportColumn) carry an extractor
  closure and an allowSignedNumeric flag for phone-number-shaped cells.
- core/Support/helpers/export.php: thin HTTP layer (exportSendCsv,
  exportCapLimit, exportResolveFlavor, exportRequireGetRequest) reusing
  the requestInput() contract for GR-CORE-003 consistency. Filename is
  sanitized and length-clamped; callers must still enforce auth.
- templates/partials/app-list-export-dropdown.phtml: zero-config
  <details class="dropdown"> with CSV + Excel triggers.
- web/js/core/app-list-export.js: initListExport({ gridConfig, exportUrl })
  mirrors the current grid filters + sort onto the export URL and
  navigates, preserving session cookies for download.

First consumer — Helpdesk domains:
- DomainListService extracts the shared filter/enrich/sort logic from
  domains-data so the grid endpoint and the new domains/export endpoint
  cannot drift.
- domains/export endpoint delegates to DomainListService, declares
  ExportColumns (with translated level labels), and exits via
  exportSendCsv.
- domains-data now ~20 lines, delegating to DomainListService.

Tests:
- CsvExportServiceTest (10 cases): both flavors, BOM/no-BOM, formula
  escapes incl. TAB/CR, header escape, signed-numeric allowlist,
  quoting, multiline, empty columns, generator-compatible iterable.
- ExportHelpersTest (5 cases): exportCapLimit bounds.
- DomainListServiceTest (8 cases): filter, search, "all" sentinel,
  sort, paging, enrichment, BC failure, tenant-scoped security filter.

Gates: PHPUnit green, PHPStan clean on touched files, module:sync ok.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 21:57:19 +02:00

105 lines
3.3 KiB
PHP

<?php
use MintyPHP\Http\Input\RequestInput;
use MintyPHP\Service\Export\CsvExportService;
use MintyPHP\Service\Export\ExportColumn;
if (!function_exists('exportRequireGetRequest')) {
/**
* Enforce GET for export endpoints. Exports are stateless and must not
* start a write transaction — GET matches semantics, keeps them
* bookmarkable and CSRF-free. Mirrors `gridRequireGetRequest()` and
* reads the method through `requestInput()` for GR-CORE-003 compliance.
*/
function exportRequireGetRequest(): void
{
if (requestInput()->isMethod('GET')) {
return;
}
header('Allow: GET');
http_response_code(405);
exit;
}
}
if (!function_exists('exportResolveFlavor')) {
/**
* Reads `format=csv|excel` from the request. Unknown values fall
* back to 'csv'. Accepts legacy alias 'excel-csv'.
*/
function exportResolveFlavor(RequestInput $request, string $default = CsvExportService::FLAVOR_CSV): string
{
$raw = strtolower(trim((string) $request->query('format', $default)));
if ($raw === 'excel' || $raw === 'excel-csv' || $raw === 'xls') {
return CsvExportService::FLAVOR_EXCEL;
}
return CsvExportService::FLAVOR_CSV;
}
}
if (!function_exists('exportCapLimit')) {
/**
* Bounds a user-supplied limit to [1, $max]. Non-numeric / <=0
* values collapse to $max.
*/
function exportCapLimit(mixed $requested, int $max = 5000): int
{
$value = is_numeric($requested) ? (int) $requested : 0;
if ($value < 1 || $value > $max) {
return $max;
}
return $value;
}
}
if (!function_exists('exportSendCsv')) {
/**
* Streams rows as a CSV download and ends the response.
*
* IMPORTANT: this helper does NOT enforce authentication or authorization.
* Callers MUST run `Guard::requireLogin()` and the appropriate
* `Guard::requireAbilityOrForbidden(...)` check BEFORE invoking it —
* see `modules/helpdesk/pages/helpdesk/domains/export().php` for the
* reference pattern.
*
* @param iterable<array<string,mixed>> $rows
* @param list<ExportColumn> $columns
*/
function exportSendCsv(
string $filename,
iterable $rows,
array $columns,
string $flavor = CsvExportService::FLAVOR_CSV
): void {
$safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', $filename) ?: 'export.csv';
// Clamp total filename to 100 chars while preserving the .csv suffix.
if (strlen($safeName) > 100) {
$safeName = substr($safeName, 0, 96);
}
if (!str_ends_with(strtolower($safeName), '.csv')) {
$safeName .= '.csv';
}
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $safeName . '"');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('X-Content-Type-Options: nosniff');
$handle = fopen('php://output', 'w');
if ($handle === false) {
exit;
}
$service = new CsvExportService();
$service->writeTo($handle, $rows, $columns, $flavor);
fclose($handle);
exit;
}
}