Files
breadcrumb-the-shire/core/Service/Export/CsvExportService.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

96 lines
3.0 KiB
PHP

<?php
namespace MintyPHP\Service\Export;
use InvalidArgumentException;
/**
* Writes CSV rows to a stream.
*
* Two flavors:
* - csv: comma delimited, UTF-8, no BOM (RFC-4180-ish)
* - excel: semicolon delimited, UTF-8 with BOM (opens natively in Excel on Windows)
*
* Values are sanitized against formula injection (=, @, +, -).
* HTTP headers and output buffering are handled by the caller
* (see core/Support/helpers/export.php).
*/
final class CsvExportService
{
public const FLAVOR_CSV = 'csv';
public const FLAVOR_EXCEL = 'excel';
private const UTF8_BOM = "\xEF\xBB\xBF";
/**
* @param resource $handle
* @param iterable<array<string,mixed>> $rows
* @param list<ExportColumn> $columns
*/
public function writeTo($handle, iterable $rows, array $columns, string $flavor = self::FLAVOR_CSV): void
{
if (!is_resource($handle)) {
throw new InvalidArgumentException('writeTo() expects an open stream resource');
}
if ($columns === []) {
throw new InvalidArgumentException('At least one column is required');
}
$delimiter = $flavor === self::FLAVOR_EXCEL ? ';' : ',';
if ($flavor === self::FLAVOR_EXCEL) {
fwrite($handle, self::UTF8_BOM);
}
$headers = array_map(
static fn (ExportColumn $c): string => self::escapeValue($c->header),
$columns
);
fputcsv($handle, $headers, $delimiter, '"', '\\');
foreach ($rows as $row) {
$line = [];
foreach ($columns as $column) {
$value = ($column->extract)($row);
$line[] = self::escapeValue($value, $column->allowSignedNumeric);
}
fputcsv($handle, $line, $delimiter, '"', '\\');
}
}
/**
* Prefixes a single apostrophe to values that would be interpreted
* as a formula by spreadsheet apps. Defuses the leading-character
* set recommended by OWASP CSV Injection: `=`, `@`, `+`, `-`, TAB, CR.
*
* When $allowSignedNumeric is true, purely numeric/phone-shaped values
* starting with + or - are left intact (useful for `+49 …` columns).
*
* @api Called from page actions that build rows manually
*/
public static function escapeValue(mixed $value, bool $allowSignedNumeric = false): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
$string = (string) $value;
if ($string === '') {
return '';
}
$first = $string[0];
if ($first === '=' || $first === '@' || $first === "\t" || $first === "\r") {
return "'" . $string;
}
if ($first === '+' || $first === '-') {
if ($allowSignedNumeric && preg_match('/^[+\-][0-9\s().\-]+$/', $string) === 1) {
return $string;
}
return "'" . $string;
}
return $string;
}
}