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>
This commit is contained in:
95
core/Service/Export/CsvExportService.php
Normal file
95
core/Service/Export/CsvExportService.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
22
core/Service/Export/ExportColumn.php
Normal file
22
core/Service/Export/ExportColumn.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Service\Export;
|
||||
|
||||
use Closure;
|
||||
|
||||
/**
|
||||
* Column definition for CSV export.
|
||||
*
|
||||
* Header + extractor pair. The extractor receives the raw row
|
||||
* and returns the cell value (scalar|null). Use $allowSignedNumeric
|
||||
* for phone-number-like columns where a leading + or - is legitimate.
|
||||
*/
|
||||
final readonly class ExportColumn
|
||||
{
|
||||
public function __construct(
|
||||
public string $header,
|
||||
public Closure $extract,
|
||||
public bool $allowSignedNumeric = false,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// Load all global helper groups used by templates and page actions.
|
||||
require __DIR__ . '/helpers/app.php';
|
||||
require __DIR__ . '/helpers/branding.php';
|
||||
require __DIR__ . '/helpers/export.php';
|
||||
require __DIR__ . '/helpers/grid.php';
|
||||
require __DIR__ . '/helpers/i18n.php';
|
||||
require __DIR__ . '/helpers/request.php';
|
||||
|
||||
104
core/Support/helpers/export.php
Normal file
104
core/Support/helpers/export.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user