opsdash-app/opsdash/lib/Service/PersistSanitizer.php
blade34242 32c5b95894
All checks were successful
Nextcloud Server Tests / version-consistency (push) Successful in 32s
Nextcloud Server Tests / matrix-config (push) Successful in 27s
Nextcloud Server Tests / Nextcloud stable30 / PHP 8.2 (stable30, 8.2) (push) Successful in 15m47s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.2 (stable31, 8.2) (push) Successful in 16m10s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.3 (stable31, 8.3) (push) Successful in 15m58s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.2 (stable32, 8.2) (push) Successful in 15m55s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.3 (stable32, 8.3) (push) Successful in 16m23s
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.2 (stable33, 8.2) (push) Successful in 17m14s
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.3 (stable33, 8.3) (push) Successful in 16m23s
Refine recap delivery scheduling
2026-05-15 14:01:57 +07:00

637 lines
23 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Opsdash\Service;
final class PersistSanitizer {
private const MAX_TARGET_HOURS = 10000;
private const MAX_GROUP = 9;
private const PRESET_NAME_MAX_LEN = 80;
private const RATIO_DECIMALS = 1;
public function __construct(
private ?PersistDeckSanitizer $deckSanitizer = null,
private ?PersistWidgetsSanitizer $widgetsSanitizer = null,
private ?PersistOnboardingSanitizer $onboardingSanitizer = null,
) {
$this->deckSanitizer ??= new PersistDeckSanitizer();
$this->widgetsSanitizer ??= new PersistWidgetsSanitizer();
$this->onboardingSanitizer ??= new PersistOnboardingSanitizer();
}
/**
* @param array<string,mixed> $targets
* @param array<string,int> $allowedSet
* @return array<string,float>
*/
public function cleanTargets(array $targets, array $allowedSet): array {
$out = [];
foreach ($targets as $id => $value) {
$key = substr((string)$id, 0, 128);
if (!isset($allowedSet[$key])) {
continue;
}
if (!is_numeric($value)) {
continue;
}
$out[$key] = round($this->clampFloat((float)$value, 0.0, self::MAX_TARGET_HOURS), 2);
}
return $out;
}
/**
* @param array<string,mixed> $groupsById
* @param array<string,int> $allowedSet
* @param array<int,string> $allowedIds
* @return array<string,int>
*/
public function cleanGroups(array $groupsById, array $allowedSet, array $allowedIds): array {
$out = [];
foreach ($allowedIds as $id) {
$key = substr((string)$id, 0, 128);
if ($key === '') {
continue;
}
$out[$key] = 0;
}
foreach ($groupsById as $id => $raw) {
$key = substr((string)$id, 0, 128);
if ($key === '' || !isset($allowedSet[$key])) {
continue;
}
$n = is_numeric($raw) ? (int)floor((float)$raw) : 0;
if ($n < 0 || $n > self::MAX_GROUP) {
$n = 0;
}
$out[$key] = $n;
}
return $out;
}
/**
* @param mixed $cfg
* @return array<string,mixed>
*/
public function cleanTargetsConfig($cfg): array {
$base = $this->defaultTargetsConfig();
if (!is_array($cfg)) {
return $base;
}
$out = $base;
if (isset($cfg['totalHours'])) {
$out['totalHours'] = round($this->clampFloat((float)$cfg['totalHours'], 0, self::MAX_TARGET_HOURS), 2);
}
if (isset($cfg['categories']) && is_array($cfg['categories'])) {
$cats = [];
foreach ($cfg['categories'] as $cat) {
if (!is_array($cat)) {
continue;
}
$id = substr((string)($cat['id'] ?? ''), 0, 64);
if ($id === '') {
$id = 'cat_' . count($cats);
}
$label = trim((string)($cat['label'] ?? ''));
if ($label === '') {
$label = ucfirst($id);
}
$target = round($this->clampFloat((float)($cat['targetHours'] ?? 0), 0, self::MAX_TARGET_HOURS), 2);
$includeWeekend = !empty($cat['includeWeekend']);
$paceMode = ((string)($cat['paceMode'] ?? '') === 'time_aware') ? 'time_aware' : 'days_only';
$groupIds = [];
if (isset($cat['groupIds']) && is_array($cat['groupIds'])) {
foreach ($cat['groupIds'] as $gid) {
$n = (int)$gid;
if ($n < 0 || $n > self::MAX_GROUP) {
continue;
}
if (!in_array($n, $groupIds, true)) {
$groupIds[] = $n;
}
}
}
$color = $this->sanitizeHexColor($cat['color'] ?? null);
$cats[] = [
'id' => $id,
'label' => $label,
'targetHours' => $target,
'includeWeekend' => $includeWeekend,
'paceMode' => $paceMode,
'color' => $color,
'groupIds' => $groupIds,
];
if (count($cats) >= 12) {
break;
}
}
if (!empty($cats)) {
$out['categories'] = $cats;
}
}
$out['activityCard'] = $this->cleanActivityCardConfig($cfg['activityCard'] ?? null);
$out['balance'] = $this->cleanBalanceConfig($cfg['balance'] ?? null, $out['categories']);
if (isset($cfg['pace']) && is_array($cfg['pace'])) {
$pace = $cfg['pace'];
$out['pace']['includeWeekendTotal'] = !empty($pace['includeWeekendTotal']);
$mode = (string)($pace['mode'] ?? $out['pace']['mode']);
$out['pace']['mode'] = $mode === 'time_aware' ? 'time_aware' : 'days_only';
if (isset($pace['thresholds']) && is_array($pace['thresholds'])) {
$thr = $pace['thresholds'];
if (isset($thr['onTrack'])) {
$out['pace']['thresholds']['onTrack'] = round($this->clampFloat((float)$thr['onTrack'], -100, 100), 2);
}
if (isset($thr['atRisk'])) {
$out['pace']['thresholds']['atRisk'] = round($this->clampFloat((float)$thr['atRisk'], -100, 100), 2);
}
}
}
if (isset($cfg['forecast']) && is_array($cfg['forecast'])) {
$fc = $cfg['forecast'];
$out['forecast']['methodPrimary'] = ((string)($fc['methodPrimary'] ?? '') === 'momentum') ? 'momentum' : 'linear';
if (isset($fc['momentumLastNDays'])) {
$n = (int)round((float)$fc['momentumLastNDays']);
if ($n < 1) {
$n = 1;
}
if ($n > 14) {
$n = 14;
}
$out['forecast']['momentumLastNDays'] = $n;
}
if (isset($fc['padding'])) {
$out['forecast']['padding'] = round($this->clampFloat((float)$fc['padding'], 0, 100), 1);
}
}
if (isset($cfg['ui']) && is_array($cfg['ui'])) {
$ui = $cfg['ui'];
foreach ($out['ui'] as $key => $val) {
if (array_key_exists($key, $ui)) {
$out['ui'][$key] = !empty($ui[$key]);
}
}
}
if (isset($cfg['timeSummary']) && is_array($cfg['timeSummary'])) {
$ts = $cfg['timeSummary'];
foreach ($out['timeSummary'] as $key => $val) {
if (array_key_exists($key, $ts)) {
$out['timeSummary'][$key] = (bool)$ts[$key];
}
}
}
if (isset($cfg['includeZeroDaysInStats'])) {
$out['includeZeroDaysInStats'] = !empty($cfg['includeZeroDaysInStats']);
}
if (isset($cfg['allDayHours'])) {
$out['allDayHours'] = round($this->clampFloat((float)$cfg['allDayHours'], 0, 24), 2);
}
return $out;
}
/**
* @param mixed $cfg
* @return array<string,mixed>
*/
public function cleanActivityCardConfig($cfg): array {
$base = $this->defaultActivityCardConfig();
if (!is_array($cfg)) {
return $base;
}
$result = $base;
$booleanKeys = [
'showWeekendShare',
'showEveningShare',
'showEarliestLatest',
'showOverlaps',
'showLongestSession',
'showLastDayOff',
'showDayOffTrend',
'showHint',
];
foreach ($booleanKeys as $key) {
if (array_key_exists($key, $cfg)) {
$result[$key] = !empty($cfg[$key]);
}
}
if (isset($cfg['forecastMode'])) {
$mode = strtolower((string)$cfg['forecastMode']);
if (in_array($mode, ['off', 'total', 'calendar', 'category'], true)) {
$result['forecastMode'] = $mode;
}
}
return $result;
}
/**
* @param mixed $cfg
* @param array<int,array<string,mixed>> $categories
* @return array<string,mixed>
*/
public function cleanBalanceConfig($cfg, array $categories): array {
$base = $this->defaultBalanceConfig();
$result = $base;
$available = [];
foreach ($categories as $cat) {
if (!is_array($cat)) {
continue;
}
$id = substr((string)($cat['id'] ?? ''), 0, 64);
if ($id !== '') {
$available[] = $id;
}
}
if (!is_array($cfg)) {
if (!empty($available)) {
$result['categories'] = array_slice($available, 0, count($result['categories']));
}
return $result;
}
$orderSource = isset($cfg['categories']) && is_array($cfg['categories']) ? $cfg['categories'] : $base['categories'];
$order = [];
foreach ($orderSource as $rawId) {
$id = substr((string)$rawId, 0, 64);
if ($id === '') {
continue;
}
if (!empty($available) && !in_array($id, $available, true)) {
continue;
}
if (!in_array($id, $order, true)) {
$order[] = $id;
}
}
if (empty($order)) {
$order = !empty($available) ? array_slice($available, 0, count($base['categories'])) : $base['categories'];
}
$result['categories'] = $order;
$result['useCategoryMapping'] = !empty($cfg['useCategoryMapping']);
$method = (string)($cfg['index']['method'] ?? $base['index']['method']);
$result['index']['method'] = $method === 'shannon_evenness' ? 'shannon_evenness' : 'simple_range';
$basis = (string)($cfg['index']['basis'] ?? $base['index']['basis']);
$allowedBasis = ['off', 'category', 'calendar', 'both'];
$result['index']['basis'] = in_array($basis, $allowedBasis, true) ? $basis : 'category';
if (!$result['useCategoryMapping'] && $result['index']['basis'] === 'category') {
$result['index']['basis'] = 'calendar';
}
if (isset($cfg['thresholds']) && is_array($cfg['thresholds'])) {
$thr = $cfg['thresholds'];
if (isset($thr['noticeAbove'])) {
$result['thresholds']['noticeAbove'] = round($this->clampFloat((float)$thr['noticeAbove'], 0.0, 1.0), self::RATIO_DECIMALS);
}
if (isset($thr['noticeBelow'])) {
$result['thresholds']['noticeBelow'] = round($this->clampFloat((float)$thr['noticeBelow'], 0.0, 1.0), self::RATIO_DECIMALS);
}
if (isset($thr['warnAbove'])) {
$result['thresholds']['warnAbove'] = round($this->clampFloat((float)$thr['warnAbove'], 0.0, 1.0), self::RATIO_DECIMALS);
}
if (isset($thr['warnBelow'])) {
$result['thresholds']['warnBelow'] = round($this->clampFloat((float)$thr['warnBelow'], 0.0, 1.0), self::RATIO_DECIMALS);
}
if (isset($thr['warnIndex'])) {
$result['thresholds']['warnIndex'] = round($this->clampFloat((float)$thr['warnIndex'], 0.0, 1.0), self::RATIO_DECIMALS);
}
}
$displayMode = (string)($cfg['relations']['displayMode'] ?? $base['relations']['displayMode']);
$result['relations']['displayMode'] = $displayMode === 'factor' ? 'factor' : 'ratio';
if (isset($cfg['trend']) && is_array($cfg['trend'])) {
$lookback = (int)($cfg['trend']['lookbackWeeks'] ?? $base['trend']['lookbackWeeks']);
if ($lookback < 1) {
$lookback = 1;
}
if ($lookback > 6) {
$lookback = 6;
}
$result['trend']['lookbackWeeks'] = $lookback;
}
if (isset($cfg['dayparts']) && is_array($cfg['dayparts'])) {
$result['dayparts']['enabled'] = !empty($cfg['dayparts']['enabled']);
}
if (isset($cfg['ui']) && is_array($cfg['ui'])) {
$result['ui']['showNotes'] = !empty($cfg['ui']['showNotes']);
}
return $result;
}
/**
* @param mixed $value
* @return array<string,mixed>
*/
public function sanitizeReportingConfig($value): array {
$defaults = $this->defaultReportingConfig();
if (!is_array($value)) {
return $defaults;
}
$threshold = (float)($value['riskThreshold'] ?? $defaults['riskThreshold']);
if (!is_finite($threshold) || $threshold < 0 || $threshold > 1) {
$threshold = $defaults['riskThreshold'];
}
$legacySchedule = $value['schedule'] ?? 'both';
if ($legacySchedule !== 'week' && $legacySchedule !== 'month') {
$legacySchedule = 'both';
}
$legacyCadence = $value['interim'] ?? 'none';
if (!in_array($legacyCadence, ['none', 'midweek', 'daily'], true)) {
$legacyCadence = 'none';
}
$modes = [
'week' => $this->sanitizeReportingModeConfig(
is_array($value['modes']['week'] ?? null) ? $value['modes']['week'] : null,
[
'enabled' => $legacySchedule === 'week' || $legacySchedule === 'both',
'delivery' => $legacyCadence === 'midweek' ? 'checkpoint_final' : 'final',
'sendTimeLocal' => '06:00',
]
),
'month' => $this->sanitizeReportingModeConfig(
is_array($value['modes']['month'] ?? null) ? $value['modes']['month'] : null,
[
'enabled' => $legacySchedule === 'month' || $legacySchedule === 'both',
'delivery' => $legacyCadence === 'midweek' ? 'checkpoint_final' : 'final',
'sendTimeLocal' => '18:00',
]
),
];
return [
'enabled' => !empty($value['enabled']),
'modes' => $modes,
'alertOnRisk' => array_key_exists('alertOnRisk', $value) ? (bool)$value['alertOnRisk'] : true,
'riskThreshold' => round($threshold, 3),
'notifyEmail' => array_key_exists('notifyEmail', $value) ? (bool)$value['notifyEmail'] : true,
'notifyNotification' => !empty($value['notifyNotification']),
];
}
/**
* @param mixed $value
* @return array<string,mixed>
*/
public function sanitizeDeckSettings($value): array {
return $this->deckSanitizer->sanitize($value, $this->defaultDeckSettings());
}
/**
* @param mixed $value
* @return array<string,mixed>
*/
public function sanitizeWidgets($value): array {
return $this->widgetsSanitizer->sanitize($value);
}
public function sanitizeThemePreference($value): ?string {
$v = strtolower(trim((string)$value));
if ($v === 'light' || $v === 'dark' || $v === 'auto') {
return $v;
}
return null;
}
/**
* @param mixed $state
* @return array<string,mixed>
*/
public function cleanOnboardingState($state): array {
return $this->onboardingSanitizer->sanitize($state, $this->defaultOnboardingState());
}
public function sanitizePresetName(string $name): string {
$clean = trim(preg_replace('/\s+/', ' ', $name) ?? '');
if ($clean === '') {
return '';
}
// Allow letters, numbers, space, dot, dash, underscore only; strip everything else
$clean = preg_replace('/[^A-Za-z0-9 _\-.]/u', '', $clean) ?? '';
if ($clean === '') {
return '';
}
if (mb_strlen($clean) > self::PRESET_NAME_MAX_LEN) {
$clean = mb_substr($clean, 0, self::PRESET_NAME_MAX_LEN);
}
return $clean;
}
/**
* @return array<string,mixed>
*/
private function defaultTargetsConfig(): array {
return [
'totalHours' => 48,
'categories' => [
['id' => 'work', 'label' => 'Work', 'targetHours' => 32, 'includeWeekend' => false, 'paceMode' => 'days_only', 'groupIds' => [1]],
['id' => 'hobby', 'label' => 'Hobby', 'targetHours' => 6, 'includeWeekend' => true, 'paceMode' => 'days_only', 'groupIds' => [2]],
['id' => 'sport', 'label' => 'Sport', 'targetHours' => 4, 'includeWeekend' => true, 'paceMode' => 'days_only', 'groupIds' => [3]],
],
'pace' => [
'includeWeekendTotal' => true,
'mode' => 'days_only',
'thresholds' => ['onTrack' => -2, 'atRisk' => -10],
],
'forecast' => [
'methodPrimary' => 'linear',
'momentumLastNDays' => 2,
'padding' => 1.5,
],
'ui' => [
'showTotalDelta' => true,
'showNeedPerDay' => true,
'showCategoryBlocks' => true,
'badges' => true,
'includeWeekendToggle' => true,
'showCalendarCharts' => true,
'showCategoryCharts' => true,
],
'allDayHours' => 8.0,
'timeSummary' => [
'showTotal' => true,
'showAverage' => true,
'showMedian' => true,
'showBusiest' => true,
'showWorkday' => true,
'showWeekend' => true,
'showWeekendShare' => true,
'showCalendarSummary' => true,
'showTopCategory' => true,
'showBalance' => true,
],
'activityCard' => $this->defaultActivityCardConfig(),
'balance' => $this->defaultBalanceConfig(),
'includeZeroDaysInStats' => false,
];
}
/**
* @param mixed $value
* @param array<string,mixed> $fallback
* @return array<string,mixed>
*/
private function sanitizeReportingModeConfig($value, array $fallback): array {
if (!is_array($value)) {
return $fallback;
}
$delivery = $value['delivery'] ?? null;
if ($delivery !== 'final' && $delivery !== 'checkpoint_final') {
$delivery = ($value['cadence'] ?? null) === 'mid' ? 'checkpoint_final' : ($fallback['delivery'] ?? 'final');
}
$sendTimeLocal = trim((string)($value['sendTimeLocal'] ?? ''));
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $sendTimeLocal)) {
$sendTimeLocal = (string)($fallback['sendTimeLocal'] ?? '06:00');
}
return [
'enabled' => array_key_exists('enabled', $value) ? (bool)$value['enabled'] : !empty($fallback['enabled']),
'delivery' => $delivery,
'sendTimeLocal' => $sendTimeLocal,
];
}
/**
* @return array<string,mixed>
*/
private function defaultActivityCardConfig(): array {
return [
'showWeekendShare' => true,
'showEveningShare' => true,
'showEarliestLatest' => true,
'showOverlaps' => true,
'showLongestSession' => true,
'showLastDayOff' => true,
'showDayOffTrend' => true,
'showHint' => true,
'forecastMode' => 'total',
];
}
/**
* @return array<string,mixed>
*/
private function defaultBalanceConfig(): array {
return [
'categories' => ['work', 'hobby', 'sport'],
'useCategoryMapping' => true,
'index' => ['method' => 'simple_range', 'basis' => 'category'],
'thresholds' => [
'noticeAbove' => 0.15,
'noticeBelow' => 0.15,
'warnAbove' => 0.30,
'warnBelow' => 0.30,
'warnIndex' => 0.60,
],
'relations' => ['displayMode' => 'ratio'],
'trend' => ['lookbackWeeks' => 3],
'dayparts' => ['enabled' => false],
'ui' => [
'showNotes' => false,
],
];
}
/**
* @return array<string,mixed>
*/
private function defaultReportingConfig(): array {
return [
'enabled' => false,
'modes' => [
'week' => [
'enabled' => true,
'delivery' => 'final',
'sendTimeLocal' => '06:00',
],
'month' => [
'enabled' => false,
'delivery' => 'checkpoint_final',
'sendTimeLocal' => '18:00',
],
],
'alertOnRisk' => true,
'riskThreshold' => 0.85,
'notifyEmail' => true,
'notifyNotification' => true,
];
}
/**
* @return array<string,mixed>
*/
private function defaultDeckSettings(): array {
return [
'enabled' => true,
'filtersEnabled' => true,
'defaultFilter' => 'all',
'hiddenBoards' => [],
'mineMode' => 'assignee',
'solvedIncludesArchived' => true,
'ticker' => [
'autoScroll' => true,
'intervalSeconds' => 5,
'showBoardBadges' => true,
],
];
}
/**
* @return array<string,mixed>
*/
private function defaultOnboardingState(): array {
return [
'completed' => false,
'version' => 0,
'strategy' => '',
'completed_at' => '',
'dashboardMode' => 'standard',
'releaseNotesSeenVersion' => '',
];
}
private function clampFloat(float $value, float $min, float $max): float {
if (!is_finite($value)) {
return $min;
}
if ($value < $min) {
return $min;
}
if ($value > $max) {
return $max;
}
return $value;
}
private function sanitizeHexColor($value): ?string {
if (!is_string($value)) {
return null;
}
$v = trim($value);
if ($v === '') {
return null;
}
if (preg_match('/^#([0-9a-fA-F]{6})$/', $v, $m)) {
return strtoupper('#' . $m[1]);
}
if (preg_match('/^#([0-9a-fA-F]{3})$/', $v, $m)) {
$r = $m[1][0];
$g = $m[1][1];
$b = $m[1][2];
return strtoupper('#' . $r . $r . $g . $g . $b . $b);
}
return null;
}
}