From 891180e13d93b8101b3d434ba18f5a56fc391bee Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 20 Mar 2024 07:04:24 -0700 Subject: [PATCH] Protect Sheet But Allow Sort Fix #3951. When an Excel sheet is protected, even when sorting is explicitly allowed without a password, sorts are permitted only on "protected ranges" within the sheet. PhpSpreadsheet already supports protected ranges, and only minor tinkering is necessary for that (e.g. the protected range can have, but does not require, a password). The more important part of this change is documenting the far-from-intuitive way that Excel handles this. To that end, documentation is updated, and a new sample is added. A new class, `Worksheet\ProtectedRange` is added in place of the string array which had been used. `Worksheet::getProtectedCells` is deprecated in favor of the new `Worksheet::getProtectedCellRanges`. --- docs/topics/recipes.md | 9 ++ samples/Basic4/51_ProtectedSort.php | 91 ++++++++++++ src/PhpSpreadsheet/Reader/Xls.php | 2 +- src/PhpSpreadsheet/Reader/Xlsx.php | 2 +- .../Worksheet/ProtectedRange.php | 45 ++++++ src/PhpSpreadsheet/Worksheet/Worksheet.php | 28 +++- src/PhpSpreadsheet/Writer/Xls/Worksheet.php | 3 +- src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 13 +- .../Worksheet/ByColumnAndRowTest.php | 8 +- .../ByColumnAndRowUndeprecatedTest.php | 6 +- .../Writer/Xlsx/Issue3951Test.php | 129 ++++++++++++++++++ 11 files changed, 316 insertions(+), 20 deletions(-) create mode 100644 samples/Basic4/51_ProtectedSort.php create mode 100644 src/PhpSpreadsheet/Worksheet/ProtectedRange.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Issue3951Test.php diff --git a/docs/topics/recipes.md b/docs/topics/recipes.md index 33d218d43..ede0f34e1 100644 --- a/docs/topics/recipes.md +++ b/docs/topics/recipes.md @@ -1295,6 +1295,15 @@ $protection->setInsertRows(false); $protection->setFormatCells(false); ``` +Note that allowing sort without providing the sheet password +(similarly with autoFilter) requires that you explicitly +enable the cell ranges for which sort is permitted, +with or without a range password: +```php +$sheet->protectCells('A:A'); // column A can be sorted without password +$sheet->protectCells('B:B', 'sortpw'); // column B can be sorted if the range password sortpw is supplied +``` + If writing Xlsx files you can specify the algorithm used to hash the password before calling `setPassword()` like so: diff --git a/samples/Basic4/51_ProtectedSort.php b/samples/Basic4/51_ProtectedSort.php new file mode 100644 index 000000000..cdc95d6c2 --- /dev/null +++ b/samples/Basic4/51_ProtectedSort.php @@ -0,0 +1,91 @@ +log('First sheet - protected, sorts not allowed'); +$sheet = $spreadsheet->getActiveSheet(); +$sheet->setTitle('sorttrue'); +$sheet->getCell('A1')->setValue(10); +$sheet->getCell('A2')->setValue(5); +$sheet->getCell('B1')->setValue(15); +$protection = $sheet->getProtection(); +$protection->setPassword('testpassword'); +$protection->setSheet(true); +$protection->setInsertRows(true); +$protection->setFormatCells(true); +$protection->setObjects(true); +$protection->setAutoFilter(false); +$protection->setSort(true); +$comment = $sheet->getComment('A1'); +$text = new RichText(); +$text->addText(new TextElement('Sort options should be grayed out. Sheet password to remove protections is testpassword for all sheets.')); +$comment->setText($text)->setHeight('120pt')->setWidth('120pt'); + +$helper->log('Second sheet - protected, sorts allowed, but no permitted range defined'); +$sheet = $spreadsheet->createSheet(); +$sheet->setTitle('sortfalse'); +$sheet->getCell('A1')->setValue(10); +$sheet->getCell('A2')->setValue(5); +$sheet->getCell('B1')->setValue(15); +$protection = $sheet->getProtection(); +$protection->setPassword('testpassword'); +$protection->setSheet(true); +$protection->setInsertRows(true); +$protection->setFormatCells(true); +$protection->setObjects(true); +$protection->setAutoFilter(false); +$protection->setSort(false); +$comment = $sheet->getComment('A1'); +$text = new RichText(); +$text->addText(new TextElement('Sort options not grayed out, but no permissible sort range.')); +$comment->setText($text)->setHeight('120pt')->setWidth('120pt'); + +$helper->log('Third sheet - protected, sorts allowed, but only on permitted range A:A, no range password needed'); +$sheet = $spreadsheet->createSheet(); +$sheet->setTitle('sortfalsenocolpw'); +$sheet->getCell('A1')->setValue(10); +$sheet->getCell('A2')->setValue(5); +$sheet->getCell('C1')->setValue(15); +$protection = $sheet->getProtection(); +$protection->setPassword('testpassword'); +$protection->setSheet(true); +$protection->setInsertRows(true); +$protection->setFormatCells(true); +$protection->setObjects(true); +$protection->setAutoFilter(false); +$protection->setSort(false); +$sheet->protectCells('A:A'); +$comment = $sheet->getComment('A1'); +$text = new RichText(); +$text->addText(new TextElement('Column A may be sorted without a password. No sort for any other column.')); +$comment->setText($text)->setHeight('120pt')->setWidth('120pt'); + +$helper->log('Fourth sheet - protected, sorts allowed, but only on permitted range A:A, and range password needed'); +$sheet = $spreadsheet->createSheet(); +$sheet->setTitle('sortfalsecolpw'); +$sheet->getCell('A1')->setValue(10); +$sheet->getCell('A2')->setValue(5); +$sheet->getCell('C1')->setValue(15); +$protection = $sheet->getProtection(); +$protection->setPassword('testpassword'); +$protection->setSheet(true); +$protection->setInsertRows(true); +$protection->setFormatCells(true); +$protection->setObjects(true); +$protection->setAutoFilter(false); +$protection->setSort(false); +$sheet->protectCells('A:A', 'sortpw', false, 'sortrange'); +$comment = $sheet->getComment('A1'); +$text = new RichText(); +$text->addText(new TextElement('Column A may be sorted with password sortpw. No sort for any other column.')); +$comment->setText($text)->setHeight('120pt')->setWidth('120pt'); + +// Save +$helper->write($spreadsheet, __FILE__, ['Xls', 'Xlsx']); +$spreadsheet->disconnectWorksheets(); diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index 6d20e1563..da12ce8a8 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -4972,7 +4972,7 @@ class Xls extends BaseReader // Apply range protection to sheet if ($cellRanges) { - $this->phpSheet->protectCells(implode(' ', $cellRanges), strtoupper(dechex($wPassword)), true); + $this->phpSheet->protectCells(implode(' ', $cellRanges), ($wPassword === 0) ? '' : strtoupper(dechex($wPassword)), true); } } } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index c8b2de2a9..4b855ddc7 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -2175,7 +2175,7 @@ class Xlsx extends BaseReader if ($xmlSheet->protectedRanges->protectedRange) { foreach ($xmlSheet->protectedRanges->protectedRange as $protectedRange) { - $docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true); + $docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true, (string) $protectedRange['name'], (string) $protectedRange['securityDescriptor']); } } } diff --git a/src/PhpSpreadsheet/Worksheet/ProtectedRange.php b/src/PhpSpreadsheet/Worksheet/ProtectedRange.php new file mode 100644 index 000000000..bd4197628 --- /dev/null +++ b/src/PhpSpreadsheet/Worksheet/ProtectedRange.php @@ -0,0 +1,45 @@ +sqref = $sqref; + $this->name = $name; + $this->password = $password; + $this->securityDescriptor = $securityDescriptor; + } + + public function getSqref(): string + { + return $this->sqref; + } + + public function getName(): string + { + return $this->name ?: ('p' . md5($this->sqref)); + } + + public function getPassword(): string + { + return $this->password; + } + + public function getSecurityDescriptor(): string + { + return $this->securityDescriptor; + } +} diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 0e1f4dc32..7afb95b79 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -192,7 +192,7 @@ class Worksheet implements IComparable /** * Collection of protected cell ranges. * - * @var string[] + * @var ProtectedRange[] */ private array $protectedCells = []; @@ -1866,14 +1866,14 @@ class Worksheet implements IComparable * * @return $this */ - public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password, bool $alreadyHashed = false): static + public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password = '', bool $alreadyHashed = false, string $name = '', string $securityDescriptor = ''): static { $range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range)); - if (!$alreadyHashed) { + if (!$alreadyHashed && $password !== '') { $password = Shared\PasswordHasher::hashPassword($password); } - $this->protectedCells[$range] = $password; + $this->protectedCells[$range] = new ProtectedRange($range, $password, $name, $securityDescriptor); return $this; } @@ -1901,11 +1901,29 @@ class Worksheet implements IComparable } /** - * Get protected cells. + * Get password for protected cells. * * @return string[] + * + * @deprecated 2.0.1 use getProtectedCellRanges instead + * @see Worksheet::getProtectedCellRanges() */ public function getProtectedCells(): array + { + $array = []; + foreach ($this->protectedCells as $key => $protectedRange) { + $array[$key] = $protectedRange->getPassword(); + } + + return $array; + } + + /** + * Get protected cells. + * + * @return ProtectedRange[] + */ + public function getProtectedCellRanges(): array { return $this->protectedCells; } diff --git a/src/PhpSpreadsheet/Writer/Xls/Worksheet.php b/src/PhpSpreadsheet/Writer/Xls/Worksheet.php index 0ef9f280d..dadfc7003 100644 --- a/src/PhpSpreadsheet/Writer/Xls/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xls/Worksheet.php @@ -1496,7 +1496,8 @@ class Worksheet extends BIFFwriter */ private function writeRangeProtection(): void { - foreach ($this->phpSheet->getProtectedCells() as $range => $password) { + foreach ($this->phpSheet->getProtectedCellRanges() as $range => $protectedCells) { + $password = $protectedCells->getPassword(); // number of ranges, e.g. 'A1:B3 C20:D25' $cellRanges = explode(' ', $range); $cref = count($cellRanges); diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index a14ff2812..7784da61b 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -974,19 +974,20 @@ class Worksheet extends WriterPart */ private function writeProtectedRanges(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet): void { - if (count($worksheet->getProtectedCells()) > 0) { + if (count($worksheet->getProtectedCellRanges()) > 0) { // protectedRanges $objWriter->startElement('protectedRanges'); // Loop protectedRanges - foreach ($worksheet->getProtectedCells() as $protectedCell => $passwordHash) { + foreach ($worksheet->getProtectedCellRanges() as $protectedCell => $protectedRange) { // protectedRange $objWriter->startElement('protectedRange'); - $objWriter->writeAttribute('name', 'p' . md5($protectedCell)); + $objWriter->writeAttribute('name', $protectedRange->getName()); $objWriter->writeAttribute('sqref', $protectedCell); - if (!empty($passwordHash)) { - $objWriter->writeAttribute('password', $passwordHash); - } + $passwordHash = $protectedRange->getPassword(); + $this->writeAttributeIf($objWriter, $passwordHash !== '', 'password', $passwordHash); + $securityDescriptor = $protectedRange->getSecurityDescriptor(); + $this->writeAttributeIf($objWriter, $securityDescriptor !== '', 'securityDescriptor', $securityDescriptor); $objWriter->endElement(); } diff --git a/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php b/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php index 467071924..bc2717804 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php @@ -133,8 +133,10 @@ class ByColumnAndRowTest extends TestCase $sheet->fromArray($data, null, 'B2', true); $sheet->protectCells([2, 2, 3, 3], 'secret', false); - $protectedRanges = $sheet->getProtectedCells(); + $protectedRanges = $sheet->/** @scrutinizer ignore-deprecated*/ getProtectedCells(); self::assertArrayHasKey('B2:C3', $protectedRanges); + $protectedRanges2 = $sheet->getProtectedCellRanges(); + self::assertArrayHasKey('B2:C3', $protectedRanges2); $spreadsheet->disconnectWorksheets(); } @@ -147,11 +149,11 @@ class ByColumnAndRowTest extends TestCase $sheet->fromArray($data, null, 'B2', true); $sheet->protectCells('B2:C3', 'secret', false); - $protectedRanges = $sheet->getProtectedCells(); + $protectedRanges = $sheet->getProtectedCellRanges(); self::assertArrayHasKey('B2:C3', $protectedRanges); $sheet->unprotectCells([2, 2, 3, 3]); - $protectedRanges = $sheet->getProtectedCells(); + $protectedRanges = $sheet->getProtectedCellRanges(); self::assertEmpty($protectedRanges); $spreadsheet->disconnectWorksheets(); } diff --git a/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowUndeprecatedTest.php b/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowUndeprecatedTest.php index 66862e98d..2209bc3ca 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowUndeprecatedTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowUndeprecatedTest.php @@ -144,7 +144,7 @@ class ByColumnAndRowUndeprecatedTest extends TestCase $sheet->fromArray($data, null, 'B2', true); $sheet->protectCells([2, 2, 3, 3], 'secret', false); - $protectedRanges = $sheet->getProtectedCells(); + $protectedRanges = $sheet->getProtectedCellRanges(); self::assertArrayHasKey('B2:C3', $protectedRanges); } @@ -157,11 +157,11 @@ class ByColumnAndRowUndeprecatedTest extends TestCase $sheet->fromArray($data, null, 'B2', true); $sheet->protectCells('B2:C3', 'secret', false); - $protectedRanges = $sheet->getProtectedCells(); + $protectedRanges = $sheet->getProtectedCellRanges(); self::assertArrayHasKey('B2:C3', $protectedRanges); $sheet->unprotectCells([2, 2, 3, 3]); - $protectedRanges = $sheet->getProtectedCells(); + $protectedRanges = $sheet->getProtectedCellRanges(); self::assertEmpty($protectedRanges); } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Issue3951Test.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Issue3951Test.php new file mode 100644 index 000000000..0833301a6 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Issue3951Test.php @@ -0,0 +1,129 @@ +getActiveSheet(); + $sheet->setTitle('sorttrue'); + $sheet->getCell('A1')->setValue(10); + $sheet->getCell('A2')->setValue(5); + $sheet->getCell('B1')->setValue(15); + $protection = $sheet->getProtection(); + $protection->setPassword('testpassword'); + $protection->setSheet(true); + $protection->setInsertRows(true); + $protection->setFormatCells(true); + $protection->setObjects(true); + $protection->setAutoFilter(false); + $protection->setSort(true); + + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle('sortfalse'); + $sheet->getCell('A1')->setValue(10); + $sheet->getCell('A2')->setValue(5); + $sheet->getCell('B1')->setValue(15); + $protection = $sheet->getProtection(); + $protection->setPassword('testpassword'); + $protection->setSheet(true); + $protection->setInsertRows(true); + $protection->setFormatCells(true); + $protection->setObjects(true); + $protection->setAutoFilter(false); + $protection->setSort(false); + + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle('sortfalsenocolpw'); + $sheet->getCell('A1')->setValue(10); + $sheet->getCell('A2')->setValue(5); + $sheet->getCell('C1')->setValue(15); + $protection = $sheet->getProtection(); + $protection->setPassword('testpassword'); + $protection->setSheet(true); + $protection->setInsertRows(true); + $protection->setFormatCells(true); + $protection->setObjects(true); + $protection->setAutoFilter(false); + $protection->setSort(false); + $sheet->protectCells('A:A'); + + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle('sortfalsecolpw'); + $sheet->getCell('A1')->setValue(10); + $sheet->getCell('A2')->setValue(5); + $sheet->getCell('C1')->setValue(15); + $protection = $sheet->getProtection(); + $protection->setPassword('testpassword'); + $protection->setSheet(true); + $protection->setInsertRows(true); + $protection->setFormatCells(true); + $protection->setObjects(true); + $protection->setAutoFilter(false); + $protection->setSort(false); + $sheet->protectCells('A:A', 'sortpw', false, 'sortrange'); + + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $type); + $spreadsheet->disconnectWorksheets(); + + self::assertCount(4, $reloadedSpreadsheet->getAllSheets()); + + $rsheet1 = $reloadedSpreadsheet->getSheetByNameOrThrow('sorttrue'); + $protection = $rsheet1->getProtection(); + self::assertNotEquals('', $protection->getPassword()); + self::assertTrue($protection->getSort()); + self::assertEmpty($rsheet1->getProtectedCellRanges()); + + $rsheet2 = $reloadedSpreadsheet->getSheetByNameOrThrow('sortfalse'); + $protection = $rsheet2->getProtection(); + self::assertNotEquals('', $protection->getPassword()); + self::assertFalse($protection->getSort()); + self::assertEmpty($rsheet2->getProtectedCellRanges()); + + $rsheet3 = $reloadedSpreadsheet->getSheetByNameOrThrow('sortfalsenocolpw'); + $protection = $rsheet3->getProtection(); + self::assertNotEquals('', $protection->getPassword()); + self::assertFalse($protection->getSort()); + $maxRow = ($type === 'Xlsx') ? 1048576 : 65536; + $testKey = "A1:A$maxRow"; + $protectedCells = $rsheet3->getProtectedCellRanges(); + self::assertCount(1, $protectedCells); + self::assertArrayHasKey($testKey, $protectedCells); + self::assertSame('', $protectedCells[$testKey]->getPassword()); + + $rsheet4 = $reloadedSpreadsheet->getSheetByNameOrThrow('sortfalsecolpw'); + $protection = $rsheet4->getProtection(); + self::assertNotEquals('', $protection->getPassword()); + self::assertFalse($protection->getSort()); + $maxRow = ($type === 'Xlsx') ? 1048576 : 65536; + $testKey = "A1:A$maxRow"; + $protectedCells = $rsheet4->getProtectedCellRanges(); + self::assertCount(1, $protectedCells); + self::assertArrayHasKey($testKey, $protectedCells); + self::assertNotEquals('', $protectedCells[$testKey]->getPassword()); + if ($type === 'Xlsx') { + self::assertSame('sortrange', $protectedCells[$testKey]->getName()); + } + + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public static function providerType(): array + { + return [ + ['Xlsx'], + ['Xls'], + ]; + } +}