diff --git a/lang/en.yml b/lang/en.yml index 35c20794dbd..c4e48704120 100644 --- a/lang/en.yml +++ b/lang/en.yml @@ -25,6 +25,10 @@ en: SilverStripe\Control\RequestProcessor: INVALID_REQUEST: 'Invalid request' REQUEST_ABORTED: 'Request aborted' + SilverStripe\Dev\BulkLoader: + CANNOT_CREATE: "Not allowed to create '{type}' records" + CANNOT_DELETE: "Not allowed to delete '{type}' records" + CANNOT_EDIT: "Not allowed to edit '{type}' records" SilverStripe\Dev\DevBuildController: CAN_DEV_BUILD_DESCRIPTION: 'Can execute /dev/build' CAN_DEV_BUILD_HELP: 'Can execute the build command (/dev/build).' @@ -167,6 +171,9 @@ en: INVALID: 'Please enter a valid URL' SilverStripe\ORM\DataObject: GENERALSEARCH: 'General Search' + NO_DUPLICATE: 'Cannot create duplicate {type}' + NO_DUPLICATE_MULTI_FIELD: 'Cannot create duplicate {type} - at least one of the following fields need to be changed: {fields}' + NO_DUPLICATE_SINGLE_FIELD: 'Cannot create duplicate {type} with "{field}" set to "{value}"' PLURALNAME: 'Data Objects' PLURALS: one: 'A Data Object' diff --git a/lang/it.yml b/lang/it.yml index a58d0441c46..c84f726f70d 100644 --- a/lang/it.yml +++ b/lang/it.yml @@ -27,9 +27,22 @@ it: SilverStripe\Control\RequestProcessor: INVALID_REQUEST: 'Richiesta non valida' REQUEST_ABORTED: 'Richiesta annullata' + SilverStripe\Dev\DevBuildController: + CAN_DEV_BUILD_DESCRIPTION: 'Può eseguire /dev/build' + CAN_DEV_BUILD_HELP: 'Può eseguire il comando di build (/dev/build).' + SilverStripe\Dev\DevConfigController: + CAN_DEV_CONFIG_DESCRIPTION: 'Può vedere /dev/config' + CAN_DEV_CONFIG_HELP: "Può vedere l'intera configurazione dell'applicazione (/dev/config)." SilverStripe\Dev\DevConfirmationController: INFO_DESCRIPTION: "Confermare l'operazione potenzialmente pericolosa" INFO_TITLE: 'Conferma di Sicurezza' + SilverStripe\Dev\DevelopmentAdmin: + ALL_DEV_ADMIN_DESCRIPTION: 'Può vedere ed eseguire tutte le azioni in /dev' + ALL_DEV_ADMIN_HELP: 'Può vedere ed eseguire tutte le azioni in /dev' + PERMISSIONS_CATEGORY: 'Permessi di sviluppo' + SilverStripe\Dev\TaskRunner: + BUILDTASK_CAN_RUN_DESCRIPTION: 'Può vedere ed eseguire ogni cosa in /dev/tasks' + BUILDTASK_CAN_RUN_HELP: 'Può vedere ed eseguire tutti i task di build (/dev/tasks). Può comunque essere scavalcato da specifici permessi del task' SilverStripe\Forms\CheckboxField: YESANSWER: Sì SilverStripe\Forms\CheckboxSetField_ss: @@ -41,6 +54,7 @@ it: CURRENT_PASSWORD_MISSING: 'Devi inserire la tua password attuale.' LOGGED_IN_ERROR: 'Devi essere autenticato per poter cambiare la tua password.' MAXIMUM: 'La password deve essere lunga almeno {max} caratteri.' + RANDOM_IF_EMPTY: 'Se lasciato vuoto una password casuale verrà generata automaticamente.' SHOWONCLICKTITLE: 'Cambia password' SilverStripe\Forms\DateField: NOTSET: 'non impostato' @@ -103,7 +117,7 @@ it: Create: Crea Delete: Elimina DeletePermissionsFailure: 'Non hai i permessi per eliminare' - Deleted: 'Eliminato {type} {name}' + Deleted: 'Eliminato {type} "{name}"' Save: Salva Saved: 'Salvato {name} {link}' SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest: @@ -111,6 +125,8 @@ it: NEW: 'Aggiungi un nuovo record' NEXT: 'Vai al prossimo record' PREVIOUS: 'Vai al record precedente' + SAVEDUP: 'Salvato correttamente.' + SAVETOASTMESSAGE: '{type} "{title}" salvato correttamente.' ViewPermissionsFailure: 'Pare tu non abbia i privilegi necessari per visualizzare "{ObjectTitle}"' SilverStripe\Forms\GridField\GridFieldEditButton: EDIT: Modifica @@ -140,16 +156,25 @@ it: IsNullLabel: 'è nullo.' SilverStripe\Forms\NumericField: VALIDATION: "'{value}' non è un numero, solo numeri possono essere accettati per questo campo" + SilverStripe\Forms\SearchableDropdownTrait: + SELECT: Seleziona... + SELECT_OR_TYPE_TO_SEARCH: 'Selezionare o digitare per cercare...' + TYPE_TO_SEARCH: 'Tipo di ricerca...' SilverStripe\Forms\TextField: VALIDATEMAXLENGTH: 'Il valore di {name} non deve superare i {maxLength} caratteri di lunghezza' SilverStripe\Forms\TimeField: VALIDATEFORMAT: "Inserisci un formato d'ora valido ({format})" + SilverStripe\Forms\UrlField: + INVALID: 'Prego inserire un URL valido' SilverStripe\ORM\DataObject: + GENERALSEARCH: 'Ricerca Generale' PLURALNAME: 'Data Object' PLURALS: many: '{count} Data Object' one: 'Un Data Object' other: '{count} Data Object' + many_many_FileTracking: 'Monitoraggio file' + many_many_LinkTracking: 'Monitoraggio link' SilverStripe\ORM\FieldType\DBBoolean: ANY: Qualsiasi YESANSWER: Sì @@ -180,6 +205,31 @@ it: many: '{count} anni' one: '{count} anno' other: '{count} anni' + SilverStripe\ORM\FieldType\DBDatetime: + nDays: + many: '{count} giorni' + one: 'un giorno' + other: '{count} giorni' + nHours: + many: '{count} ore' + one: "un'ora" + other: '{count} ore' + nMinutes: + many: '{count} minuti' + one: 'un minuto' + other: '{count} minuti' + nMonths: + many: '{count} mesi' + one: 'un mese' + other: '{count} mesi' + nSeconds: + many: '{count} secondi' + one: 'un secondo' + other: '{count} secondi' + nYears: + many: '{count} anni' + one: 'un anno' + other: '{count} anni' SilverStripe\ORM\FieldType\DBEnum: ANY: Qualsiasi SilverStripe\ORM\FieldType\DBForeignKey: @@ -238,15 +288,29 @@ it: SINGULARNAME: Gruppo Sort: 'Tipo ordinamento' ValidationIdentifierAlreadyExists: 'Esiste già un gruppo ({group}) con lo stesso {identifier}' + db_AccessAllSubsites: 'Accesso a tutti i sottositi' + db_Code: Codice db_Description: Descrizione + db_HtmlEditorConfig: 'Configurazione editor HTML' + db_LastSynced: 'Ultima sincronizzazione' + db_Locked: Bloccato db_Sort: Ordine db_Title: Titolo has_many_Groups: Gruppi + has_many_LDAPGroupMappings: 'Mappatura dei gruppi LDAP' has_many_Permissions: Permessi has_one_Parent: Parente many_many_Members: Membri many_many_Roles: Ruoli + many_many_SiteTreeContentReview: "Revisione contenuto dell'albero delle pagine" many_many_Subsites: Sottositi + SilverStripe\Security\InheritedPermissionsExtension: + db_CanEditType: 'Tipo può modificare' + db_CanViewType: 'Tipo può visualizzare' + many_many_EditorGroups: 'Gruppi di editori' + many_many_EditorMembers: 'Membri editori' + many_many_ViewerGroups: 'Gruppi di visualizzatori' + many_many_ViewerMembers: 'Membri visualizzatori' SilverStripe\Security\LoginAttempt: Email: 'Indirizzo e-mail' EmailHashed: 'Indirizzo email (hash)' @@ -258,6 +322,7 @@ it: other: "{count} tentativi d'accesso" SINGULARNAME: "Tentativo d'accesso" Status: Stato + db_EmailHashed: 'Email codificata' db_Status: Stato has_one_Member: Utente SilverStripe\Security\Member: @@ -271,6 +336,7 @@ it: CONFIRMPASSWORD: 'Conferma password' CURRENT_PASSWORD: 'Password Attuale' EDIT_PASSWORD: 'Nuova password' + EMAIL_FAILED: "Errore durante l'invio dell'email per il reset delle credenziali." EMPTYNEWPASSWORD: 'La nuova password non può essere vuota, riprova' ENTEREMAIL: 'Inserisci un indirizzo e-mail per ricevere il link di azzeramento della password' ERRORLOCKEDOUT2: 'Il tuo account è stato temporaneamente disabilitato perchè ci sono stati troppi tentativi di accesso errati. Riprova tra {count} minuti.' @@ -289,6 +355,7 @@ it: many: '{count} Utenti' one: 'Un Utente' other: '{count} Utenti' + RequiresPasswordChangeOnNextLogin: 'Richiede il cambio password al prossimo login' SINGULARNAME: Utente SUBJECTPASSWORDCHANGED: 'La tua password è stata cambiata' SUBJECTPASSWORDRESET: 'Link per azzerare la tua password' @@ -298,14 +365,39 @@ it: ValidationIdentifierFailed: "Non posso sovrascrivere l'utente esistente #{id} con identificatore identico ({name} = {value}))" WELCOMEBACK: 'Bentornato, {firstname}' YOUROLDPASSWORD: 'La tua vecchia password' + belongs_many_many_BlogPosts: 'Articoli del blog' belongs_many_many_Groups: Gruppi + db_AccountResetExpired: 'Reset credenziali scaduto' + db_AccountResetHash: 'Hash per il reset delle credenziali' + db_AutoLoginExpired: 'Auto login scaduto' + db_AutoLoginHash: "Hash per l'auto login" + db_BlogProfileSummary: 'Riassunto profilo per il blog' + db_DefaultRegisteredMethodID: 'ID metodo di registrazione di default' db_Email: E-mail + db_FailedLoginCount: 'Conteggio login falliti' db_FirstName: Nome + db_HasSkippedMFARegistration: 'Ha registrazione con skippedMFA' + db_IsExpired: 'È scaduto' + db_LastSynced: 'Ultima sincronizzazione' db_Locale: 'Localizzazione interfaccia' db_LockedOutUntil: 'Bloccato fino al' + db_PasswordEncryption: 'Password criptata' db_PasswordExpiry: 'Data di scadenza della password' + db_Salt: Seme db_Surname: Cognome + db_TempIDExpired: 'TempID scaduto' + db_TempIDHash: 'Hash di TempID' db_URLSegment: 'Segmento URL' + db_Username: 'Nome utente' + has_many_LoggedPasswords: 'Password registrate' + has_many_LoginSessions: 'Sessioni di login' + has_many_RegisteredMFAMethods: 'Metodi RegisteredMFA' + has_many_RememberLoginHashes: 'Ricordare gli hash di login' + has_one_AFile: 'Un file' + has_one_AImage: "Un'immagine" + has_one_BlogProfileImage: 'Immagine profilo per il blog' + has_one_FavouritePage: 'Pagina preferita' + many_many_SiteTreeContentReview: "Revisione contenuto dell'albero delle pagine" SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm: AUTHENTICATORNAME: 'Form di Login Utente CMS' BUTTONFORGOTPASSWORD: 'Password dimenticata' @@ -321,6 +413,8 @@ it: one: 'Una password utente' other: '{count} password utente' SINGULARNAME: 'Password utente' + db_PasswordEncryption: 'Password criptata' + db_Salt: Seme has_one_Member: Utente SilverStripe\Security\PasswordValidator: LOWCHARSTRENGTH: 'Perfavore aumenta la sicurezza della password aggiungendo alcuni dei seguenti caratteri: {chars}' @@ -340,6 +434,8 @@ it: other: '{count} Permessi' SINGULARNAME: Permesso UserPermissionsIntro: 'Assegnando gruppi a questo utente modificherà i suoi permessi. Vedi la sezione gruppi per dettagli sui permessi dei singoli gruppi.' + db_Arg: Argomento + db_Code: Codice db_Type: Tipo has_one_Group: Gruppo SilverStripe\Security\PermissionCheckboxSetField: @@ -359,6 +455,7 @@ it: belongs_many_many_Groups: Gruppi db_OnlyAdminCanApply: "Solo l'amministratore può applicare" db_Title: Titolo + has_many_Codes: Codici SilverStripe\Security\PermissionRoleCode: PLURALNAME: 'Codici di ruolo' PLURALS: @@ -367,6 +464,7 @@ it: other: '{count} codici di ruolo' PermsError: 'Non posso assegnare permessi privilegiati al codice "{code}" (richiede accesso ADMIN)' SINGULARNAME: 'Codice di ruolo' + db_Code: Codice has_one_Role: Ruolo SilverStripe\Security\RememberLoginHash: PLURALNAME: 'Hash di Login' @@ -375,6 +473,9 @@ it: one: 'Un Hash di Login' other: '{count} Hash di Login' SINGULARNAME: 'Hash di Login' + db_DeviceID: 'ID dispositivo' + db_ExpiryDate: 'Data scadenza' + has_one_LoginSession: 'Sessione di login' has_one_Member: Utente SilverStripe\Security\Security: ALREADYLOGGEDIN: 'Non hai accesso a questa pagina. Se hai un altro account che può accederci, puoi autenticarti qui sotto.' diff --git a/lang/nl.yml b/lang/nl.yml index 87608a01298..10550f0ecfb 100644 --- a/lang/nl.yml +++ b/lang/nl.yml @@ -248,6 +248,13 @@ nl: has_one_Parent: Bovenliggende many_many_Members: Leden many_many_Roles: Rollen + SilverStripe\Security\InheritedPermissionsExtension: + db_CanEditType: 'Kan bewerken type' + db_CanViewType: 'Kan bekijken type' + many_many_EditorGroups: Redacteursgroepen + many_many_EditorMembers: Redacteurs + many_many_ViewerGroups: Bekijkersgroepen + many_many_ViewerMembers: Bekijk-gebruikers SilverStripe\Security\LoginAttempt: Email: 'E-mailadres ' EmailHashed: 'E-mailadres (versleuteld)' @@ -298,8 +305,10 @@ nl: ValidationIdentifierFailed: 'Een bestaande gebruiker #{id} kan niet dezelfde unieke velden hebben ({name} = {value}))' WELCOMEBACK: 'Welkom terug, {firstname}' YOUROLDPASSWORD: 'Je oude wachtwoord' + belongs_many_many_BlogPosts: 'Blog Artikelen' belongs_many_many_Groups: Groepen db_AccountResetExpired: 'Het opnieuw instellen van het account is verlopen' + db_BlogProfileSummary: 'Blogprofiel samenvatting' db_Email: E-mail db_FirstName: Voornaam db_LastSynced: 'Laatst gesynchroniseerd' @@ -308,6 +317,7 @@ nl: db_Password: Wachtwoord db_PasswordExpiry: 'Wachtwoord vervaldatum' db_Surname: Achternaam + has_one_BlogProfileImage: 'Blogprofiel afbeelding' SilverStripe\Security\MemberAuthenticator\CMSMemberLoginForm: AUTHENTICATORNAME: Inlogformulier BUTTONFORGOTPASSWORD: 'Wachtwoord vergeten' diff --git a/src/Forms/GridField/GridFieldDataColumns.php b/src/Forms/GridField/GridFieldDataColumns.php index b7f08a7d817..10408e42452 100644 --- a/src/Forms/GridField/GridFieldDataColumns.php +++ b/src/Forms/GridField/GridFieldDataColumns.php @@ -6,6 +6,9 @@ use InvalidArgumentException; use LogicException; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\ORM\FieldType\DBHTMLText; +use SilverStripe\ORM\FieldType\DBHTMLVarchar; /** * @see GridField @@ -38,6 +41,8 @@ class GridFieldDataColumns extends AbstractGridFieldComponent implements GridFie private ?string $statusFlagColumn = null; + private bool $doEscapeFields = true; + /** * Modify the list of columns displayed in the table. * See {@link GridFieldDataColumns->getDisplayFields()} and {@link GridFieldDataColumns}. @@ -202,6 +207,28 @@ public function getFieldFormatting() return $this->fieldFormatting; } + /** + * Determines whether this component escapes strings returned from getColumnContent(). + * + * This is useful because by default strings are escaped for use in HTML. This + * means there are some circumstances in which the escaping done here can result + * in double escaping those values further down the line, such as use with + * GridFieldPrintButton which temporarily sets this to false. + */ + public function setDoEscapeFields(bool $doEscapeFields): static + { + $this->doEscapeFields = $doEscapeFields; + return $this; + } + + /** + * Get whether this component escapes strings returned from getColumnContent(). + */ + public function getDoEscapeFields(): bool + { + return $this->doEscapeFields; + } + /** * HTML for the column, content of the element. * @@ -295,12 +322,24 @@ protected function castValue($gridField, $fieldName, $value) // If the value is an object, we do one of two things if (method_exists($value, 'Nice')) { // If it has a "Nice" method, call that & make sure the result is safe - $value = nl2br(Convert::raw2xml($value->Nice()) ?? ''); + $value = $value->Nice(); + if ($this->getDoEscapeFields()) { + $value = nl2br(Convert::raw2xml($value)); + } } else { - // Otherwise call forTemplate - the result of this should already be safe - $value = $value->forTemplate(); + if (!$this->getDoEscapeFields() + && is_a($value, DBField::class, false) + && !is_a($value, DBHTMLText::class, false) + && !is_a($value, DBHTMLVarchar::class, false) + ) { + // For DBFields other than HTML variants, if we're not escaping values, get the raw value. + $value = $value->RAW(); + } else { + // Otherwise, check forTemplate() which is assumed to be safe. + $value = $value->forTemplate(); + } } - } else { + } elseif ($this->getDoEscapeFields()) { // Otherwise, just treat as a text string & make sure the result is safe $value = nl2br(Convert::raw2xml($value) ?? ''); } diff --git a/src/Forms/GridField/GridFieldFilterHeader.php b/src/Forms/GridField/GridFieldFilterHeader.php index d62b07c0dcc..0cd2908a180 100755 --- a/src/Forms/GridField/GridFieldFilterHeader.php +++ b/src/Forms/GridField/GridFieldFilterHeader.php @@ -7,6 +7,7 @@ use SilverStripe\Control\Controller; use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\ClassInfo; +use SilverStripe\Forms\CompositeField; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; use SilverStripe\Forms\Schema\FormSchema; @@ -295,7 +296,7 @@ public function getSearchFieldSchema(GridField $gridField) $searchField = $searchField && property_exists($searchField, 'name') ? $searchField->name : null; } - // Prefix "Search__" onto the filters for the React component + // Prefix "Search__" onto the filters to match the field names in the actual form $filters = $context->getSearchParams(); if (!empty($filters)) { $filters = array_combine(array_map(function ($key) { @@ -339,14 +340,10 @@ public function getSearchForm(GridField $gridField) return $this->searchForm; } - // Append a prefix to search field names to prevent conflicts with other fields in the search form - foreach ($searchFields as $field) { - $field->setName('Search__' . $field->getName()); - } - - $columns = $gridField->getColumns(); + $this->addSearchPrefixToFields($searchFields); // Update field titles to match column titles + $columns = $gridField->getColumns(); foreach ($columns as $columnField) { $metadata = $gridField->getColumnMetadata($columnField); // Get the field name, without any modifications @@ -359,9 +356,7 @@ public function getSearchForm(GridField $gridField) } } - foreach ($searchFields->getIterator() as $field) { - $field->addExtraClass('stacked no-change-track'); - } + $this->updateFieldClasses($searchFields); $name = $this->getTitle(singleton($gridField->getModelClass())); @@ -489,4 +484,30 @@ private function getBasicSearchContext(GridField $gridField, SearchContext $sear return $basicSearchContext; } + + /* + * Append a prefix to search field names to prevent conflicts with other fields in the search form + */ + private function addSearchPrefixToFields(FieldList $fields): void + { + foreach ($fields as $field) { + $field->setName('Search__' . $field->getName()); + if ($field instanceof CompositeField) { + $this->addSearchPrefixToFields($field->getChildren()); + } + } + } + + /** + * Update CSS classes for form fields, including nested inside composite fields + */ + private function updateFieldClasses(FieldList $fields): void + { + foreach ($fields as $field) { + $field->addExtraClass('stacked no-change-track'); + if ($field instanceof CompositeField) { + $this->updateFieldClasses($field->getChildren()); + } + } + } } diff --git a/src/Forms/GridField/GridFieldPrintButton.php b/src/Forms/GridField/GridFieldPrintButton.php index bebc63d65a6..4501a31c3cf 100644 --- a/src/Forms/GridField/GridFieldPrintButton.php +++ b/src/Forms/GridField/GridFieldPrintButton.php @@ -4,11 +4,11 @@ use LogicException; use SilverStripe\Control\HTTPRequest; -use SilverStripe\Core\Convert; use SilverStripe\Core\Extensible; use SilverStripe\Model\List\ArrayList; use SilverStripe\ORM\FieldType\DBDatetime; use SilverStripe\ORM\FieldType\DBHTMLText; +use SilverStripe\ORM\FieldType\DBHTMLVarchar; use SilverStripe\Security\Security; use SilverStripe\Model\ArrayData; use SilverStripe\View\Requirements; @@ -231,7 +231,11 @@ public function generatePrintData(GridField $gridField) $items = $gridField->getManipulatedList(); $itemRows = new ArrayList(); + // If there's a GridFieldDataColumns component, ensure it doesn't escape raw strings + // as that would result in double escaping when we render out the print template. $gridFieldColumnsComponent = $gridField->getConfig()->getComponentByType(GridFieldDataColumns::class); + $origDoEscapeFields = $gridFieldColumnsComponent?->getDoEscapeFields(); + $gridFieldColumnsComponent?->setDoEscapeFields(false); /** @var ModelData $item */ foreach ($items->limit(null) as $item) { @@ -244,8 +248,13 @@ public function generatePrintData(GridField $gridField) ? strip_tags($gridFieldColumnsComponent->getColumnContent($gridField, $item, $field)) : $gridField->getDataFieldValue($item, $field); + // The value is used in a template, so to prevent XSS attacks we can't allow an HTML field here. + // Getting the raw string here means it will end up being default-casted to DBText which is safe. + if (is_a($value, DBHTMLText::class, false) || is_a($value, DBHTMLVarchar::class, false)) { + $value = $value->__toString(); + } $itemRow->push(new ArrayData([ - "CellString" => $value, + 'CellString' => $value, ])); } @@ -258,6 +267,8 @@ public function generatePrintData(GridField $gridField) } } + $gridFieldColumnsComponent?->setDoEscapeFields($origDoEscapeFields); + $ret = new ArrayData([ "Title" => $this->getTitle($gridField), "Header" => $header, diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 2ed8bb7c78a..6fb7ad2d0e4 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -4377,8 +4377,7 @@ public function provideI18nEntities() $pluralName = $this->plural_name(); $singularName = $this->singular_name(); $conjunction = preg_match('/^[aeiou]/i', $singularName ?? '') ? 'An ' : 'A '; - return [ - static::class . '.CLASS_DESCRIPTION' => $this->classDescription(), + $entities = [ static::class . '.SINGULARNAME' => $singularName, static::class . '.PLURALNAME' => $pluralName, static::class . '.PLURALS' => [ @@ -4386,6 +4385,11 @@ public function provideI18nEntities() 'other' => '{count} ' . $pluralName ] ]; + $classDescription = $this->classDescription(); + if ($classDescription) { + $entities[static::class . '.CLASS_DESCRIPTION'] = $classDescription; + } + return $entities; } /** diff --git a/src/i18n/TextCollection/i18nTextCollector.php b/src/i18n/TextCollection/i18nTextCollector.php index 00ec32d3702..b5b3904ec6c 100644 --- a/src/i18n/TextCollection/i18nTextCollector.php +++ b/src/i18n/TextCollection/i18nTextCollector.php @@ -882,7 +882,7 @@ public function collectFromCode($content, $fileName, Module $module) $inTransFn = false; $inConcat = false; // Ensure key is valid before saving - if (!empty($currentEntity[0])) { + if (!empty($currentEntity[0]) && !str_ends_with($currentEntity[0], '.')) { $key = $currentEntity[0]; $default = $currentEntity[1] ?? ''; $comment = $currentEntity[2] ?? ''; diff --git a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php index 46829944d5b..40f76006b92 100644 --- a/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php +++ b/tests/php/Forms/GridField/GridFieldFilterHeaderTest.php @@ -170,11 +170,17 @@ public function testGetSearchForm() { $searchForm = $this->component->getSearchForm($this->gridField); $this->assertTrue($searchForm instanceof Form); - $fields = $searchForm->Fields()->toArray(); + $fields = $searchForm->Fields()->flattenFields()->toArray(); $this->assertEquals('Search__q', $fields[0]->Name); $this->assertEquals('Search__Name', $fields[1]->Name); $this->assertEquals('Search__City', $fields[2]->Name); $this->assertEquals('Search__Cheerleader__Hat__Colour', $fields[3]->Name); + $this->assertEquals('Search__TestCompositeSingleTestCompositeNestedGroup', $fields[4]->Name); + $this->assertEquals('Search__TestCompositeSingle', $fields[5]->Name); + $this->assertEquals('Search__TestCompositeNestedGroup', $fields[6]->Name); + $this->assertEquals('Search__TestCompositeNested', $fields[7]->Name); + // Make sure there aren't additional fields we're not testing for + $this->assertCount(8, $fields); $this->assertEquals('TeamsSearchForm', $searchForm->Name); $this->assertTrue($searchForm->hasExtraClass('cms-search-form')); foreach ($fields as $field) { diff --git a/tests/php/Forms/GridField/GridFieldFilterHeaderTest/Team.php b/tests/php/Forms/GridField/GridFieldFilterHeaderTest/Team.php index b39eefe5677..b862fdcd579 100644 --- a/tests/php/Forms/GridField/GridFieldFilterHeaderTest/Team.php +++ b/tests/php/Forms/GridField/GridFieldFilterHeaderTest/Team.php @@ -3,6 +3,8 @@ namespace SilverStripe\Forms\Tests\GridField\GridFieldFilterHeaderTest; use SilverStripe\Dev\TestOnly; +use SilverStripe\Forms\CompositeField; +use SilverStripe\Forms\TextField; use SilverStripe\ORM\DataObject; class Team extends DataObject implements TestOnly @@ -29,4 +31,16 @@ public function getMySummaryField() { return 'MY SUMMARY FIELD'; } + + public function scaffoldSearchFields($_params = null) + { + $fields = parent::scaffoldSearchFields($_params); + $fields->add(new CompositeField([ + new TextField('TestCompositeSingle'), + new CompositeField([ + new TextField('TestCompositeNested'), + ]) + ])); + return $fields; + } } diff --git a/tests/php/Forms/GridField/GridFieldPrintButtonTest.php b/tests/php/Forms/GridField/GridFieldPrintButtonTest.php index ed73b6fcbca..e981cb0d156 100644 --- a/tests/php/Forms/GridField/GridFieldPrintButtonTest.php +++ b/tests/php/Forms/GridField/GridFieldPrintButtonTest.php @@ -6,6 +6,7 @@ use ReflectionMethod; use SilverStripe\Dev\SapphireTest; use SilverStripe\Control\Controller; +use SilverStripe\Dev\CSSContentParser; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; use SilverStripe\Forms\GridField\GridFieldPrintButton; @@ -16,6 +17,10 @@ use SilverStripe\Forms\Tests\GridField\GridFieldPrintButtonTest\TestObject; use SilverStripe\Model\List\ArrayList; use SilverStripe\Model\ArrayData; +use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\ORM\FieldType\DBHTMLText; +use SilverStripe\ORM\FieldType\DBHTMLVarchar; +use SilverStripe\ORM\FieldType\DBText; class GridFieldPrintButtonTest extends SapphireTest { @@ -93,6 +98,140 @@ public function testGeneratePrintData() $this->assertSame($names, $foundNames); } + public function provideHandlePrintEscaping(): array + { + return [ + // Without data columns component + 'raw string pre-escaped' => [ + 'value' => 'before<script>alert("hehehe");</script>after&', + 'useGridFieldDataColumns' => false, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'raw string as HTML' => [ + 'value' => 'beforeafter&', + 'useGridFieldDataColumns' => false, + 'expected' => 'before<script>alert("hehehe");</script>after&amp;', + ], + 'DBText pre-escaped' => [ + 'value' => (new DBText('field'))->setValue('before<script>alert("hehehe");</script>after&'), + 'useGridFieldDataColumns' => false, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'DBText as HTML' => [ + 'value' => (new DBText('field'))->setValue('beforeafter&'), + 'useGridFieldDataColumns' => false, + 'expected' => 'before<script>alert("hehehe");</script>after&amp;', + ], + 'DBHTMLText pre-escaped' => [ + 'value' => (new DBHTMLText('field'))->setValue('before<script>alert("hehehe");</script>after&'), + 'useGridFieldDataColumns' => false, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'DBHTMLText as HTML' => [ + 'value' => (new DBHTMLText('field'))->setValue('beforeafter&'), + 'useGridFieldDataColumns' => false, + 'expected' => 'before<script>alert("hehehe");</script>after&amp;', + ], + 'DBHTMLVarchar pre-escaped' => [ + 'value' => (new DBHTMLVarchar('field'))->setValue('before<script>alert("hehehe");</script>after&'), + 'useGridFieldDataColumns' => false, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'DBHTMLVarchar as HTML' => [ + 'value' => (new DBHTMLVarchar('field'))->setValue('beforeafter&'), + 'useGridFieldDataColumns' => false, + 'expected' => 'before<script>alert("hehehe");</script>after&amp;', + ], + // With data columns component + 'raw string pre-escaped with datacolumns' => [ + 'value' => 'before<script>alert("hehehe");</script>after&', + 'useGridFieldDataColumns' => true, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'raw string pre-escaped with datacolumns' => [ + 'value' => 'beforeafter&', + 'useGridFieldDataColumns' => true, + 'expected' => 'beforealert("hehehe");after&amp;', + ], + 'DBText pre-escaped with datacolumns' => [ + 'value' => (new DBText('field'))->setValue('before<script>alert("hehehe");</script>after&'), + 'useGridFieldDataColumns' => true, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'DBText as HTML with datacolumns' => [ + 'value' => (new DBText('field'))->setValue('beforeafter&'), + 'useGridFieldDataColumns' => true, + // Note stripped tags here + 'expected' => 'beforealert("hehehe");after&amp;', + ], + 'DBHTMLText pre-escaped with datacolumns' => [ + 'value' => (new DBHTMLText('field'))->setValue('before<script>alert("hehehe");</script>after&'), + 'useGridFieldDataColumns' => true, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'DBHTMLText as HTML with datacolumns' => [ + 'value' => (new DBHTMLText('field'))->setValue('beforeafter&'), + 'useGridFieldDataColumns' => true, + // Note stripped tags here + 'expected' => 'beforealert("hehehe");after&amp;', + ], + 'DBHTMLVarchar pre-escaped with datacolumns' => [ + 'value' => (new DBHTMLVarchar('field'))->setValue('before<script>alert("hehehe");</script>after&'), + 'useGridFieldDataColumns' => true, + 'expected' => 'before&lt;script&gt;alert("hehehe");&lt;/script&gt;after&amp;', + ], + 'DBHTMLVarchar as HTML with datacolumns' => [ + 'value' => (new DBHTMLVarchar('field'))->setValue('beforeafter&'), + 'useGridFieldDataColumns' => true, + // Note stripped tags here + 'expected' => 'beforealert("hehehe");after&amp;', + ], + ]; + } + + /** + * Explicitly tests that the following are both true: + * - XML entities are not double-escaped + * - XSS attack vectors are not introduced + * + * @dataProvider provideHandlePrintEscaping + */ + public function testHandlePrintEscaping(string|DBField $value, bool $useGridFieldDataColumns, string $expected): void + { + $component = new GridFieldPrintButton(); + $component->getPrintColumns(); + + $list = new ArrayList([new ArrayData(['Name' => $value])]); + + $button = new GridFieldPrintButton(); + $button->setPrintColumns(['Name' => 'My Name']); + + // Get paginated gridfield config + $config = GridFieldConfig::create() + ->addComponent(new GridFieldPaginator(10)) + ->addComponent($button); + if ($useGridFieldDataColumns) { + // If this component is present, GridFieldPrintButton uses it to get the value, + // and that includes some transformation of the value including escaping. + // So we need to check both with and without the component to ensure both scenarios + // present sane results. + $columns = new GridFieldDataColumns(); + $columns->setDisplayFields(['Name' => 'My Name']); + $config->addComponent($columns); + } + $gridField = new GridField('testfield', 'testfield', $list, $config); + new Form(Controller::curr(), 'Form', new FieldList($gridField), new FieldList()); + + // Printed data should ignore pagination limit + $result = $button->handlePrint($gridField); + + $parser = new CSSContentParser($result->__toString()); + $cellContent = $parser->getBySelector('td'); + + $this->assertCount(1, $cellContent); + $this->assertSame("{$expected}", $cellContent[0]->asXML()); + } + public function testGetPrintColumnsForGridFieldThrowsException() { $component = new GridFieldPrintButton(); diff --git a/tests/php/ORM/DataObjectTest.php b/tests/php/ORM/DataObjectTest.php index bca67ffc708..7ed3106a0f0 100644 --- a/tests/php/ORM/DataObjectTest.php +++ b/tests/php/ORM/DataObjectTest.php @@ -2840,4 +2840,56 @@ public function testExceptionForUniqueIndexViolation(array $fieldsRecordOne, arr $this->expectExceptionMessage($expectedMessage); $record2->write(); } + + public static function provideProvideI18nEntities(): array + { + return [ + 'has-class-description' => [ + 'classDescription' => 'A fluffy cloud', + 'expected' => true, + ], + 'no-class-description' => [ + 'classDescription' => null, + 'expected' => false, + ], + ]; + } + + /** + * @dataProvider provideProvideI18nEntities + */ + public function testProvideI18nEntities(?string $classDescription, bool $expected): void + { + $obj = new class extends DataObject { + public $classDescription; + public function singular_name() + { + return 'Cloud'; + } + public function plural_name() + { + return 'Clouds'; + } + public function classDescription() + { + return $this->classDescription; + } + }; + $obj->classDescription = $classDescription; + $entities = $obj->provideI18nEntities(); + // Fix up anonymous class keys + foreach ($entities as $key => $entity) { + unset($entities[$key]); + $newKey = preg_replace('#^.+?\.([A-Z_]+)$#', '$1', $key); + $entities[$newKey] = $entity; + } + $this->assertSame('Cloud', $entities['SINGULARNAME']); + $this->assertSame('Clouds', $entities['PLURALNAME']); + $this->assertSame(['one' => 'A Cloud', 'other' => '{count} Clouds'], $entities['PLURALS']); + if ($expected) { + $this->assertSame('A fluffy cloud', $entities['CLASS_DESCRIPTION']); + } else { + $this->assertFalse(array_key_exists('CLASS_DESCRIPTION', $entities)); + } + } } diff --git a/tests/php/i18n/i18nTextCollectorTest.php b/tests/php/i18n/i18nTextCollectorTest.php index c2f4a2a0640..6112e968967 100644 --- a/tests/php/i18n/i18nTextCollectorTest.php +++ b/tests/php/i18n/i18nTextCollectorTest.php @@ -1034,4 +1034,26 @@ public function testItCanUseVariableAsContext() 'TestEntity.REGULARCONTEXT' => "test {type}", ], $collectedTranslatables); } + + public function testDoesNotCollectInvalidKeys() + { + // From the code below this will previously collect `'.' => 'generic'` + // Code was added to i18nTextCollector::collectFromCode() to ignore keys + // that end with "." + $c = i18nTextCollector::create(); + $mymodule = ModuleLoader::inst()->getManifest()->getModule('i18ntestmodule'); + $php = <<<'PHP' + $data = [ + $foo, + static::get_something($foo), + 'generic' + ]; + _t( + __CLASS__ . '.' . ucfirst($foo) . 'Type', + $hello[$foo] + ); + PHP; + $collectedTranslatables = $c->collectFromCode($php, null, $mymodule); + $this->assertEmpty($collectedTranslatables); + } }