Repository navigation
enh: validate datetime fields #8388
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b67f6b5
21160cd
3d9ec54
e74c757
a971676
be76dbb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| <?php | ||
|
|
||
| /** | ||
| * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors | ||
| * SPDX-License-Identifier: AGPL-3.0-or-later | ||
| */ | ||
|
|
||
| namespace OCA\Deck\Command; | ||
|
|
||
| use OCP\DB\Exception; | ||
| use OCP\IDBConnection; | ||
| use Symfony\Component\Console\Command\Command; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
| use Symfony\Component\Console\Input\InputOption; | ||
| use Symfony\Component\Console\Output\OutputInterface; | ||
|
|
||
| class FixCardDate extends Command { | ||
| public function __construct( | ||
| private IDBConnection $db, | ||
| ) { | ||
| parent::__construct(); | ||
| } | ||
|
|
||
| protected function configure() { | ||
| $this | ||
| ->setName('deck:fix-card-date') | ||
| ->setDescription('Fixes the date fields of cards. In some DBs, the date fields may have been stored incorrectly, causing issues with date handling. This command will attempt to fix those dates.') | ||
| ->addOption( | ||
| 'default-year', | ||
| null, | ||
| InputOption::VALUE_OPTIONAL, | ||
| 'Default year to use for cards with invalid dates. If not provided, the large year number will be trimmed to first 4 digits.', | ||
| ) | ||
| ->addOption( | ||
| 'dry-run', | ||
| null, | ||
| InputOption::VALUE_NONE, | ||
| 'Dry run, do not make any changes.', | ||
| ) | ||
| ; | ||
| } | ||
|
|
||
| /** | ||
| * @throws Exception | ||
| */ | ||
| protected function execute(InputInterface $input, OutputInterface $output): int { | ||
| $isDryRun = (bool)$input->getOption('dry-run'); | ||
|
luka-nextcloud marked this conversation as resolved.
|
||
| $defaultYear = $input->getOption('default-year'); | ||
|
luka-nextcloud marked this conversation as resolved.
|
||
|
|
||
| // Validate default year if provided | ||
| if ($defaultYear !== null && !preg_match('/^\d{4}$/', $defaultYear)) { | ||
| $output->writeln('<error>Invalid default year provided. It must be a 4-digit year.</error>'); | ||
| return 1; | ||
| } | ||
|
|
||
| // Fetch cards with date values and validate invalid years in PHP for DB portability. | ||
| $qb = $this->db->getQueryBuilder(); | ||
| $qb->select('id', 'duedate', 'startdate', 'done') | ||
| ->from('deck_cards') | ||
| ->where($qb->expr()->orX( | ||
| $qb->expr()->isNotNull('duedate'), | ||
| $qb->expr()->isNotNull('startdate'), | ||
| $qb->expr()->isNotNull('done'), | ||
| )); | ||
| $result = $qb->executeQuery(); | ||
|
|
||
| $hasInvalidCards = false; | ||
|
|
||
| while (($card = $result->fetchAssociative()) !== false) { | ||
| $cardId = $card['id']; | ||
| $duedate = $card['duedate']; | ||
| $startdate = $card['startdate']; | ||
| $done = $card['done']; | ||
| $hasInvalidDueDate = $duedate && $this->isInvalidDate($duedate); | ||
| $hasInvalidStartDate = $startdate && $this->isInvalidDate($startdate); | ||
| $hasInvalidDoneDate = $done && $this->isInvalidDate($done); | ||
|
|
||
| if (!$hasInvalidDueDate && !$hasInvalidStartDate && !$hasInvalidDoneDate) { | ||
| continue; | ||
| } | ||
|
|
||
| $hasInvalidCards = true; | ||
|
|
||
| $output->writeln("Card ID: $cardId"); | ||
|
|
||
| if ($hasInvalidDueDate) { | ||
| $output->writeln(" Original Due Date: $duedate"); | ||
| $duedate = $this->fixDate($duedate, $defaultYear); | ||
|
|
||
| if (!$isDryRun) { | ||
| $this->updateCardDate($cardId, 'duedate', $duedate); | ||
| } | ||
| $output->writeln(" Fixed Due Date: $duedate"); | ||
| } | ||
|
|
||
| if ($hasInvalidStartDate) { | ||
| $output->writeln(" Original Start Date: $startdate"); | ||
| $startdate = $this->fixDate($startdate, $defaultYear); | ||
|
|
||
| if (!$isDryRun) { | ||
| $this->updateCardDate($cardId, 'startdate', $startdate); | ||
| } | ||
| $output->writeln(" Fixed Start Date: $startdate"); | ||
| } | ||
|
|
||
| if ($hasInvalidDoneDate) { | ||
| $output->writeln(" Original Done Date: $done"); | ||
| $done = $this->fixDate($done, $defaultYear); | ||
|
|
||
| if (!$isDryRun) { | ||
| $this->updateCardDate($cardId, 'done', $done); | ||
| } | ||
| $output->writeln(" Fixed Done Date: $done"); | ||
| } | ||
| } | ||
|
|
||
| if (!$hasInvalidCards) { | ||
| $output->writeln('No cards with invalid dates found.'); | ||
| } | ||
|
|
||
| return 0; | ||
| } | ||
|
|
||
| protected function isInvalidDate(string $date): bool { | ||
| $parts = explode('-', $date); | ||
| $year = $parts[0]; | ||
| return $year > 9999; | ||
| } | ||
|
|
||
| protected function fixDate(string $date, ?string $defaultYear): string { | ||
| $parts = explode('-', $date); | ||
| $year = $parts[0]; | ||
| $fixedYear = $defaultYear; | ||
| if (!$defaultYear) { | ||
| $fixedYear = substr($year, 0, 4); | ||
| } | ||
| return str_replace($year . '-', $fixedYear . '-', $date); | ||
| } | ||
|
|
||
| protected function updateCardDate(int $cardId, string $field, string $newDate): void { | ||
| $qb = $this->db->getQueryBuilder(); | ||
| $qb->update('deck_cards') | ||
| ->set($field, $qb->createNamedParameter($newDate)) | ||
|
luka-nextcloud marked this conversation as resolved.
|
||
| ->set('last_modified', $qb->createNamedParameter(time())) | ||
| ->where($qb->expr()->eq('id', $qb->createNamedParameter($cardId))); | ||
| $qb->executeStatement(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ | |
|
|
||
| use Exception; | ||
| use OCA\Deck\BadRequestException; | ||
| use OCA\Deck\Model\OptionalNullableValue; | ||
|
|
||
| abstract class BaseValidator { | ||
|
|
||
|
|
@@ -31,22 +32,35 @@ private function validate($data) { | |
|
|
||
| foreach ($data as $field => $value) { | ||
| $field_rule = $rules[$field]; | ||
| $isOptional = is_array($field_rule) && in_array('optional', $field_rule, true); | ||
|
|
||
| if ($isOptional) { | ||
| if ($value instanceof OptionalNullableValue) { | ||
| $value = $value->getValue(); | ||
| } | ||
| if ($value === null || $value === '') { | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| if (is_array($field_rule)) { | ||
| foreach ($field_rule as $rule) { | ||
| if ($rule === 'optional') { | ||
| continue; | ||
| } | ||
| // The format for specifying validation rules and parameters follows an | ||
| // easy {rule}:{parameters} formatting convention. For instance the | ||
| // rule "Max:3" states that the value may only be three letters. | ||
| if (str_contains($rule, ':')) { | ||
| [$rule, $parameter] = explode(':', $rule, 2); | ||
| if (!$this->{$rule}($value, $parameter)) { | ||
| throw new BadRequestException( | ||
| $this->getErrorMessage($rule, $field, $parameter)); | ||
| $this->getErrorMessage($rule, $field, $parameter, $isOptional)); | ||
| } | ||
| } else { | ||
| if (!$this->{$rule}($value)) { | ||
| throw new BadRequestException( | ||
| $field . ' must be provided and must be ' . str_replace('_', ' ', $rule)); | ||
| $this->getErrorMessage($rule, $field, null, $isOptional)); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -135,6 +149,61 @@ private function min($value, $limit): bool { | |
| return $this->getSize($value) >= $limit; | ||
| } | ||
|
|
||
| /** | ||
| * @throws Exception | ||
| */ | ||
| private function datetime(mixed $value): bool { | ||
| if (!is_string($value) || $value === '') { | ||
| return false; | ||
| } | ||
|
|
||
| // Java/Android commonly send ISO_OFFSET_DATE_TIME and ISO_INSTANT. | ||
| if (preg_match('/^(?<date>\d{4}-\d{2}-\d{2})T(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d{1,9}))?)?(?<timezone>Z|[+\-]\d{2}:\d{2})$/', $value, $matches)) { | ||
| $second = ($matches['second'] ?? '') !== '' ? $matches['second'] : '00'; | ||
| $fraction = $matches['fraction'] ?? ''; | ||
|
|
||
| if ($fraction !== '') { | ||
| // PHP supports microseconds, so normalize Java nanoseconds to 6 digits. | ||
| $fraction = str_pad(substr($fraction, 0, 6), 6, '0'); | ||
| } else { | ||
| $fraction = '000000'; | ||
| } | ||
|
|
||
| $timezone = $matches['timezone'] === 'Z' ? '+00:00' : $matches['timezone']; | ||
| $normalizedValue = sprintf( | ||
| '%sT%s:%s:%s.%s%s', | ||
| $matches['date'], | ||
| $matches['hour'], | ||
| $matches['minute'], | ||
| $second, | ||
| $fraction, | ||
| $timezone | ||
| ); | ||
|
|
||
| $datetime = \DateTime::createFromFormat('Y-m-d\TH:i:s.uP', $normalizedValue); | ||
| if ($datetime && $datetime->format('Y-m-d\TH:i:s.uP') === $normalizedValue) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| $allowedFormats = [ | ||
| 'Y-m-d', | ||
| 'Y-m-d H:i:s', | ||
| 'Y-m-d\TH:i:sP', | ||
| 'Y-m-d\TH:i:s\Z', | ||
| 'Y-m-d\TH:i:s.v\Z', | ||
| ]; | ||
|
Comment on lines
+189
to
+195
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. to we need to be restrictive and selective on formats? Would not passing them to For, other clients (thinking about the Deck mobile app) might send different formats that have been accepted so far.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it is worth to restrict the formats by allowing common formats. I will check the Deck android app to ensure the format sent from mobile is supported.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @blizzz Could you please provide the datetime formats and some sample values sent from the mobile app so I can ensure the validation logic correctly cover those formats? I am not able to run the app because I have no way to get the apk of https://github.com/stefan-niedermann/nextcloud-deck
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With external clients it can also be someone's curl script that then breaks. According to Clause, Android Deck does this:
and further
Suggestion: try {
$year = (int)(new \DateTimeImmutable($value))->format('Y');
} catch (\Exception) {
return false;
}
return $year >= 1 && $year <= 9999;which still validates and is format agnostic.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I have updated to add support for formats
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Yep, I stand corrected. OK. Ideally the openapi also announces/documents the allowed formats. @stefan-niedermann do the formats work for you? |
||
|
|
||
| foreach ($allowedFormats as $format) { | ||
| $datetime = \DateTime::createFromFormat($format, $value); | ||
| if ($datetime && $datetime->format($format) === $value) { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Get the size of an attribute. | ||
| * | ||
|
|
@@ -159,13 +228,17 @@ protected function getSize($value): int { | |
| * @param $parameter | ||
| * @return string | ||
| */ | ||
| protected function getErrorMessage(string $rule, $field, $parameter = null): string { | ||
| protected function getErrorMessage(string $rule, $field, $parameter = null, $isOptional = false): string { | ||
| if (in_array($rule, ['max', 'min'], true)) { | ||
| return $rule === 'max' | ||
| ? $field . ' cannot be longer than ' . $parameter . ' characters ' | ||
| : $field . ' must be at least ' . $parameter . ' characters long '; | ||
| } | ||
|
|
||
| if ($isOptional) { | ||
| return $field . ' must be ' . str_replace('_', ' ', $rule); | ||
| } | ||
|
|
||
| return $field . ' must be provided and must be ' . str_replace('_', ' ', $rule); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I understand this is a one time thing to run. What is the motivation to go with a command, not a one-time repair job?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is because admin should input the default year to fix those fields.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hm, if you were an admin, what would you choose? Like, is there a reason to pick any other number than 9999? What would you pinpoint it at?