diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 59d2e7f9d..f7b540a6b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,3 +71,11 @@ jobs: - name: Run tests run: ./vendor/bin/phpunit ./tests + + - name: Upload test artifacts on failure + uses: actions/upload-artifact@v3 + if: ${{ failure() }} + with: + name: ResizerTest-${{ matrix.operatingSystem }}-PHP${{ matrix.phpVersion }} + path: tests/artifacts/ResizerTest/ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 50fa968a2..bdee57b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ php_errors.log tests/.phpunit.result.cache .phpunit.result.cache tests/tmp +tests/artifacts/* diff --git a/composer.json b/composer.json index 163a5a278..6d7597aa2 100644 --- a/composer.json +++ b/composer.json @@ -37,6 +37,7 @@ "assetic/framework": "~3.0", "doctrine/dbal": "^2.6", + "enshrined/svg-sanitize": "^0.15", "erusev/parsedown-extra": "~0.7", "laravel/framework": "^9.1", "laravel/tinker": "^2.7", @@ -47,7 +48,8 @@ "symfony/yaml": "^6.0", "twig/twig": "~3.0", "wikimedia/less.php": "~3.0", - "wikimedia/minify": "~2.2" + "wikimedia/minify": "~2.2", + "winter/laravel-config-writer": "^1.0.1" }, "require-dev": { "phpunit/phpunit": "^9.5.8", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index fd99a24a5..b50ce78ce 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -260,11 +260,6 @@ parameters: count: 1 path: src/Database/Relations/BelongsTo.php - - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:getRelationDefinition\\(\\)\\.$#" - count: 3 - path: src/Database/Relations/BelongsTo.php - - message: "#^Call to an undefined method Illuminate\\\\Database\\\\Eloquent\\\\Relations\\\\Relation\\:\\:getForeignKey\\(\\)\\.$#" count: 3 diff --git a/src/Auth/Manager.php b/src/Auth/Manager.php index 12aa75e21..c398386ad 100644 --- a/src/Auth/Manager.php +++ b/src/Auth/Manager.php @@ -1,9 +1,11 @@ ipAddress = Request::ip(); + $this->sessionManager = App::make(SessionManager::class); } // @@ -479,7 +482,19 @@ protected function setPersistCodeInSession($user, $remember = true) Session::put($this->sessionKey, $toPersist); if ($remember) { - Cookie::queue(Cookie::forever($this->sessionKey, json_encode($toPersist))); + $config = $this->sessionManager->getSessionConfig(); + Cookie::queue( + Cookie::forever( + $this->sessionKey, + json_encode($toPersist), + $config['path'], + $config['domain'], + $config['secure'] ?? false, + $config['http_only'] ?? true, + false, + $config['same_site'] ?? null + ) + ); } } diff --git a/src/Config/ConfigWriter.php b/src/Config/ConfigWriter.php index 75f40831a..a609a498a 100644 --- a/src/Config/ConfigWriter.php +++ b/src/Config/ConfigWriter.php @@ -1,7 +1,5 @@ comment(str_repeat('*', $width)); + $this->comment(str_repeat('*', $width), $verbosity); // Alert content foreach ($lines as $line) { @@ -67,74 +69,14 @@ public function alert($string) $this->comment( str_repeat('*', $border) . str_pad($line, $innerLineWidth, ' ', STR_PAD_BOTH) - . str_repeat('*', $border) + . str_repeat('*', $border), + $verbosity ); } // Bottom border - $this->comment(str_repeat('*', $width)); + $this->comment(str_repeat('*', $width), $verbosity); $this->newLine(); } - - /** - * Provide autocompletion for this command's input - */ - public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void - { - $inputs = [ - 'arguments' => $input->getArguments(), - 'options' => $input->getOptions(), - ]; - - foreach ($inputs as $type => $data) { - switch ($type) { - case 'arguments': - $dataType = 'Argument'; - $suggestionType = 'Values'; - break; - case 'options': - $dataType = 'Option'; - $suggestionType = 'Options'; - break; - default: - // This should not be possible to ever be triggered given the type is hardcoded above - throw new \Exception('Invalid input type being parsed during completion'); - } - if (!empty($data)) { - foreach ($data as $name => $value) { - // Skip the command argument since that's handled by Artisan directly - if ( - $type === 'arguments' - && in_array($name, ['command']) - ) { - continue; - } - - $inputRoutingMethod = "mustSuggest{$dataType}ValuesFor"; - $suggestionValuesMethod = Str::camel('suggest ' . $name) . $suggestionType; - $suggestionsMethod = 'suggest' . $suggestionType; - - if ( - method_exists($this, $suggestionValuesMethod) - && $input->{$inputRoutingMethod}($name) - ) { - $values = $this->$suggestionValuesMethod($value, $inputs); - $suggestions->{$suggestionsMethod}($values); - } - } - } - } - } - - /** - * Example implementation of a suggestion method - */ - // public function suggestMyArgumentValues(string $value = null, array $allInput): array - // { - // if ($allInput['arguments']['dependent'] === 'matches') { - // return ['some', 'suggested', 'values']; - // } - // return ['all', 'values']; - // } } diff --git a/src/Console/Traits/ConfirmsWithInput.php b/src/Console/Traits/ConfirmsWithInput.php index 8cc25d3e1..115e6fcf1 100644 --- a/src/Console/Traits/ConfirmsWithInput.php +++ b/src/Console/Traits/ConfirmsWithInput.php @@ -4,7 +4,7 @@ * Console Command Trait that provides confirmation step that requires set * input to be provided in order to act as confirmation for an action * - * @package winter\wn-system-module + * @package winter\storm * @author Luke Towers */ trait ConfirmsWithInput diff --git a/src/Console/Traits/HandlesCleanup.php b/src/Console/Traits/HandlesCleanup.php new file mode 100644 index 000000000..223705436 --- /dev/null +++ b/src/Console/Traits/HandlesCleanup.php @@ -0,0 +1,77 @@ +**NOTE:** This trait requires the implementing class to implement the + * Symfony\Component\Console\Command\SignalableCommandInterface interface + * + * @package winter\storm + * @author Luke Towers + */ +trait HandlesCleanup +{ + /** + * Returns the process signals this command listens to + * @see https://www.php.net/manual/en/pcntl.constants.php + * Used to support the handleCleanup() end-class method + */ + public function getSubscribedSignals(): array + { + $signals = []; + if (method_exists($this, 'handleCleanup')) { + // Handle Windows OS + if (PHP_OS_FAMILY === 'Windows') { + // Attach to Windows Ctrl+C & Ctrl+Break events + if (function_exists('sapi_windows_set_ctrl_handler')) { + sapi_windows_set_ctrl_handler([$this, 'handleWindowsSignal'], true); + } + // Handle Unix-like OS + } else { + $signals = [SIGINT, SIGTERM, SIGQUIT]; + } + } + + return $signals; + } + + /** + * Handle the provided Unix process signal + */ + public function handleSignal(int $signal): void + { + // Handle the signal + if (method_exists($this, 'handleCleanup')) { + $this->handleCleanup(); + } + + // Exit cleanly at this point if this was a user termination + if (in_array($signal, [SIGINT, SIGQUIT])) { + exit(0); + } + } + + /** + * Handle the provided Windows process singal. + */ + public function handleWindowsSignal(int $event): void + { + // Remove the handler + sapi_windows_set_ctrl_handler([$this, 'handleWindowsSignal'], false); + + // Handle the signal + if ( + method_exists($this, 'handleCleanup') + && ( + $event === PHP_WINDOWS_EVENT_CTRL_C + || $event === PHP_WINDOWS_EVENT_CTRL_BREAK + ) + ) { + $this->handleCleanup(); + + // Exit cleanly at this point if this was a user termination + exit(0); + } + } +} diff --git a/src/Console/Traits/ProvidesAutocompletion.php b/src/Console/Traits/ProvidesAutocompletion.php new file mode 100644 index 000000000..c7b230d09 --- /dev/null +++ b/src/Console/Traits/ProvidesAutocompletion.php @@ -0,0 +1,76 @@ + $input->getArguments(), + 'options' => $input->getOptions(), + ]; + + foreach ($inputs as $type => $data) { + switch ($type) { + case 'arguments': + $dataType = 'Argument'; + $suggestionType = 'Values'; + break; + case 'options': + $dataType = 'Option'; + $suggestionType = 'Options'; + break; + default: + // This should not be possible to ever be triggered given the type is hardcoded above + throw new \Exception('Invalid input type being parsed during completion'); + } + if (!empty($data)) { + foreach ($data as $name => $value) { + // Skip the command argument since that's handled by Artisan directly + if ( + $type === 'arguments' + && in_array($name, ['command']) + ) { + continue; + } + + $inputRoutingMethod = "mustSuggest{$dataType}ValuesFor"; + $suggestionValuesMethod = Str::camel('suggest ' . $name) . $suggestionType; + $suggestionsMethod = 'suggest' . $suggestionType; + + if ( + method_exists($this, $suggestionValuesMethod) + && $input->{$inputRoutingMethod}($name) + ) { + $values = $this->$suggestionValuesMethod($value, $inputs); + $suggestions->{$suggestionsMethod}($values); + } + } + } + } + } + + /** + * Example implementation of a suggestion method + */ + // public function suggestMyArgumentValues(string $value = null, array $allInput): array + // { + // if ($allInput['arguments']['dependent'] === 'matches') { + // return ['some', 'suggested', 'values']; + // } + // return ['all', 'values']; + // } +} diff --git a/src/Database/Attach/File.php b/src/Database/Attach/File.php index 9f09cd69f..bec7c6d16 100644 --- a/src/Database/Attach/File.php +++ b/src/Database/Attach/File.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\File as FileObj; +use Winter\Storm\Exception\ApplicationException; /** * File attachment model @@ -123,7 +124,7 @@ public function fromPost($uploadedFile) } /** - * Creates a file object from a file on the disk. + * Creates a file object from a file on the local filesystem. * * @param string $filePath The path to the file. * @return static @@ -141,6 +142,34 @@ public function fromFile($filePath, $filename = null) return $this; } + /** + * Creates a file object from a file on the disk returned by $this->getDisk() + */ + public function fromStorage(string $filePath): static + { + $disk = $this->getDisk(); + + if (!$disk->exists($filePath)) { + throw new \InvalidArgumentException(sprintf('File `%s` was not found on the storage disk', $filePath)); + } + + if (empty($this->file_name)) { + $this->file_name = basename($filePath); + } + if (empty($this->content_type)) { + $this->content_type = $disk->mimeType($filePath); + } + + $this->file_size = $disk->size($filePath); + $this->disk_name = $this->getDiskName(); + + if (!$disk->copy($filePath, $this->getDiskPath())) { + throw new ApplicationException(sprintf('Unable to copy `%s` to `%s`', $filePath, $this->getDiskPath())); + } + + return $this; + } + /** * Creates a file object from raw data. * @@ -185,14 +214,19 @@ public function fromUrl($url, $filename = null) // Attempt to detect the extension from the reported Content-Type, fall back to the original path extension // if not able to guess $mimesToExt = array_flip($this->autoMimeTypes); - if (!empty($data->headers['Content-Type']) && isset($mimesToExt[$data->headers['Content-Type']])) { - $ext = $mimesToExt[$data->headers['Content-Type']]; + $headers = array_change_key_case($data->headers, CASE_LOWER); + if (!empty($headers['content-type']) && isset($mimesToExt[$headers['content-type']])) { + $ext = $mimesToExt[$headers['content-type']]; } else { - $ext = pathinfo($filePath)['extension']; + $ext = pathinfo($filePath)['extension'] ?? ''; + } + + if (!empty($ext)) { + $ext = '.' . $ext; } // Generate the filename - $filename = "{$filename}.{$ext}"; + $filename = "{$filename}{$ext}"; } return $this->fromData($data, $filename); @@ -530,8 +564,10 @@ public function beforeSave() if ($this->data !== null) { if ($this->data instanceof UploadedFile) { $this->fromPost($this->data); - } else { + } elseif (file_exists($this->data)) { $this->fromFile($this->data); + } else { + $this->fromStorage($this->data); } $this->data = null; diff --git a/src/Database/Attach/Resizer.php b/src/Database/Attach/Resizer.php index f0f199496..3851c10c1 100644 --- a/src/Database/Attach/Resizer.php +++ b/src/Database/Attach/Resizer.php @@ -453,7 +453,7 @@ public function save($savePath) } // Determine the image type from the destination file - $extension = pathinfo($savePath, PATHINFO_EXTENSION) ?: $this->extension; + $extension = $this->getExtension($savePath); // Create and save an image based on it's extension switch (strtolower($extension)) { @@ -697,4 +697,12 @@ protected function getSizeByFit($maxWidth, $maxHeight) return [$optimalWidth, $optimalHeight]; } + + /** + * Get the extension from the options, otherwise use the filename extension + */ + protected function getExtension(string $path): string + { + return $this->getOption('extension') ?: (pathinfo($path, PATHINFO_EXTENSION) ?: $this->extension); + } } diff --git a/src/Database/Behaviors/Purgeable.php b/src/Database/Behaviors/Purgeable.php index dd1c84d68..a7fc0e4d3 100644 --- a/src/Database/Behaviors/Purgeable.php +++ b/src/Database/Behaviors/Purgeable.php @@ -1,117 +1,9 @@ model = $parent; - $this->bootPurgeable(); - } - - /** - * @var array List of original attribute values before they were purged. - */ - protected $originalPurgeableValues = []; - - /** - * Boot the purgeable trait for a model. - * @return void - */ - public function bootPurgeable() - { - if (!$this->model->propertyExists('purgeable')) { - $this->model->addDynamicProperty('purgeable', []); - } - - $this->model->purgeable[] = 'purgeable'; - $dynPropNames = array_keys(array_diff_key($this->model->getDynamicProperties(), ['purgeable' => 0])); - $this->model->purgeable = array_merge($this->model->purgeable, $dynPropNames); - - /* - * Remove any purge attributes from the data set - */ - $model = $this->model; - $model->bindEvent('model.saveInternal', function () use ($model) { - $model->purgeAttributes(); - }); - } - - /** - * Adds an attribute to the purgeable attributes list - * @param array|string|null $attributes - * @return \Winter\Storm\Database\Model - */ - public function addPurgeable($attributes = null) - { - $attributes = is_array($attributes) ? $attributes : func_get_args(); - - $this->model->purgeable = array_merge($this->model->purgeable, $attributes); - - return $this->model; - } - - /** - * Removes purged attributes from the dataset, used before saving. - * @param string|array|null $attributesToPurge Attribute(s) to purge. If unspecified, $purgable property is used - * @return array Current attribute set - */ - public function purgeAttributes($attributesToPurge = null) - { - if ($attributesToPurge !== null) { - $purgeable = is_array($attributesToPurge) ? $attributesToPurge : [$attributesToPurge]; - } - else { - $purgeable = $this->getPurgeableAttributes(); - } - - $attributes = $this->model->getAttributes(); - $cleanAttributes = array_diff_key($attributes, array_flip($purgeable)); - $originalAttributes = array_diff_key($attributes, $cleanAttributes); - - $this->originalPurgeableValues = array_merge($this->originalPurgeableValues, $originalAttributes); - - return $this->model->attributes = $cleanAttributes; - } - - /** - * Returns a collection of fields that will be hashed. - */ - public function getPurgeableAttributes() - { - return $this->model->purgeable; - } - - /** - * Returns the original values of any purged attributes. - */ - public function getOriginalPurgeValues() - { - return $this->originalPurgeableValues; - } - - /** - * Returns the original values of any purged attributes. - */ - public function getOriginalPurgeValue($attribute) - { - return $this->originalPurgeableValues[$attribute] ?? null; - } - - /** - * Restores the original values of any purged attributes. - * - * @return \Winter\Storm\Database\Model - */ - public function restorePurgedValues() - { - $this->model->attributes = array_merge($this->model->getAttributes(), $this->originalPurgeableValues); - return $this->model; - } } diff --git a/src/Database/Concerns/HasRelationships.php b/src/Database/Concerns/HasRelationships.php index ecf581b9c..c79e63e1e 100644 --- a/src/Database/Concerns/HasRelationships.php +++ b/src/Database/Concerns/HasRelationships.php @@ -326,6 +326,11 @@ protected function handleRelation($relationName) case 'belongsToMany': $relation = $this->validateRelationArgs($relationName, ['table', 'key', 'otherKey', 'parentKey', 'relatedKey', 'pivot', 'timestamps']); $relationObj = $this->$relationType($relation[0], $relation['table'], $relation['key'], $relation['otherKey'], $relation['parentKey'], $relation['relatedKey'], $relationName); + + if (isset($relation['pivotModel'])) { + $relationObj->using($relation['pivotModel']); + } + break; case 'morphTo': @@ -944,7 +949,7 @@ public function addAttachManyRelation(string $name, array $config): void */ public function addHasOneThroughRelation(string $name, array $config): void { - $this->addRelation('HasOneThrough', $name, $config); + $this->addRelation('hasOneThrough', $name, $config); } /** @@ -954,7 +959,7 @@ public function addHasOneThroughRelation(string $name, array $config): void */ public function addHasManyThroughRelation(string $name, array $config): void { - $this->addRelation('HasManyThrough', $name, $config); + $this->addRelation('hasManyThrough', $name, $config); } /** diff --git a/src/Database/Model.php b/src/Database/Model.php index 05403aa2a..210590eb8 100644 --- a/src/Database/Model.php +++ b/src/Database/Model.php @@ -23,8 +23,11 @@ class Model extends EloquentModel implements ModelInterface use Concerns\GuardsAttributes; use Concerns\HasRelationships; use Concerns\HidesAttributes; + use Traits\Purgeable; use \Winter\Storm\Support\Traits\Emitter; - use \Winter\Storm\Extension\ExtendableTrait; + use \Winter\Storm\Extension\ExtendableTrait { + addDynamicProperty as protected extendableAddDynamicProperty; + } use \Winter\Storm\Database\Traits\DeferredBinding; /** @@ -47,6 +50,11 @@ class Model extends EloquentModel implements ModelInterface */ protected $dates = []; + /** + * @var array List of attributes which should not be saved to the database. + */ + protected $purgeable = []; + /** * @var bool Indicates if duplicate queries from this model should be cached in memory. */ @@ -670,6 +678,26 @@ public function newCollection(array $models = []) // Magic // + /** + * Programmatically adds a property to the extendable class + * + * @param string $dynamicName The name of the property to add + * @param mixed $value The value of the property + * @return void + */ + public function addDynamicProperty($dynamicName, $value = null) + { + if (array_key_exists($dynamicName, $this->getDynamicProperties())) { + return; + } + + // Ensure that dynamic properties are automatically purged + $this->addPurgeable($dynamicName); + + // Add the dynamic property + return $this->extendableAddDynamicProperty($dynamicName, $value); + } + public function __get($name) { return $this->extendableGet($name); diff --git a/src/Database/Relations/BelongsTo.php b/src/Database/Relations/BelongsTo.php index 798906eac..1a133f6b1 100644 --- a/src/Database/Relations/BelongsTo.php +++ b/src/Database/Relations/BelongsTo.php @@ -6,9 +6,11 @@ /** * @phpstan-property \Winter\Storm\Database\Model $child + * @phpstan-property \Winter\Storm\Database\Model $parent */ class BelongsTo extends BelongsToBase { + use Concerns\BelongsOrMorphsTo; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; diff --git a/src/Database/Relations/Concerns/AttachOneOrMany.php b/src/Database/Relations/Concerns/AttachOneOrMany.php index 441e6b218..22e761e44 100644 --- a/src/Database/Relations/Concerns/AttachOneOrMany.php +++ b/src/Database/Relations/Concerns/AttachOneOrMany.php @@ -167,6 +167,21 @@ public function add(Model $model, $sessionKey = null) } if ($sessionKey === null) { + /** + * @event model.relation.beforeAdd + * Called before adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAdd', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAdd', [$this->relationName, $model]); + // Delete siblings for single attachments if ($this instanceof AttachOne) { $this->delete(); @@ -186,6 +201,21 @@ public function add(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterAdd + * Called after adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAdd', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was added as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAdd', [$this->relationName, $model]); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey); @@ -210,6 +240,21 @@ public function addMany($models, $sessionKey = null) public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeRemove + * Called before removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeRemove', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeRemove', [$this->relationName, $model]); + $options = $this->parent->getRelationDefinition($this->relationName); if (array_get($options, 'delete', false)) { @@ -234,6 +279,21 @@ public function remove(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterRemove + * Called after removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterRemove', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was removed from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterRemove', [$this->relationName, $model]); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); diff --git a/src/Database/Relations/Concerns/BelongsOrMorphsTo.php b/src/Database/Relations/Concerns/BelongsOrMorphsTo.php new file mode 100644 index 000000000..0a1033df1 --- /dev/null +++ b/src/Database/Relations/Concerns/BelongsOrMorphsTo.php @@ -0,0 +1,88 @@ +bindEvent('model.relation.beforeAssociate', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAssociate', [$this->relationName, $model]); + + $result = parent::associate($model); + + /** + * @event model.relation.afterAssociate + * Called after associating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAssociate', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was associated as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAssociate', [$this->relationName, $model]); + + return $result; + } + + /** + * Dissociate previously associated model from the given parent. + * + * @return \Illuminate\Database\Eloquent\Model + */ + public function dissociate() + { + /** + * @event model.relation.beforeDissociate + * Called before dissociating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeDissociate', function (string $relationName, Model $relatedModel) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeDissociate', [$this->relationName, $this->getRelated()]); + + $result = parent::dissociate(); + + /** + * @event model.relation.afterDissociate + * Called after dissociating a relation to the model (only for BelongsTo/MorphTo relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterDissociate', function (string $relationName, Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $modelClass = get_class($model); + * traceLog("{$relationName} was dissociated from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterDissociate', [$this->relationName, $this->getRelated()]); + + return $result; + } +} diff --git a/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php b/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php index 7e346d3d6..7b92bf0bf 100644 --- a/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php +++ b/src/Database/Relations/Concerns/BelongsOrMorphsToMany.php @@ -122,6 +122,14 @@ public function attach($id, array $attributes = [], $touch = true) $insertData = $this->formatAttachRecords($this->parseIds($id), $attributes); $attachedIdList = array_pluck($insertData, $this->relatedPivotKey); + $eventArgs = [$this->relationName]; + + if ($this->using) { + $eventArgs = [...$eventArgs, $id, $attributes]; + } else { + $eventArgs = [...$eventArgs, $attachedIdList, $insertData]; + } + /** * @event model.relation.beforeAttach * Called before creating a new relation between models (only for BelongsToMany relation) @@ -135,15 +143,21 @@ public function attach($id, array $attributes = [], $touch = true) * } * }); * + * >**NOTE:** If a custom pivotModel is being used the parameters will actually be `string $relationName, mixed $id, array $attributes` + * */ - if ($this->parent->fireEvent('model.relation.beforeAttach', [$this->relationName, $attachedIdList, $insertData], true) === false) { + if ($this->parent->fireEvent('model.relation.beforeAttach', $eventArgs, true) === false) { return; } // Here we will insert the attachment records into the pivot table. Once we have // inserted the records, we will touch the relationships if necessary and the // function will return. We can parse the IDs before inserting the records. - $this->newPivotStatement()->insert($insertData); + if ($this->using) { + $this->attachUsingCustomClass($id, $attributes); + } else { + $this->newPivotStatement()->insert($insertData); + } if ($touch) { $this->touchIfTouching(); @@ -159,8 +173,10 @@ public function attach($id, array $attributes = [], $touch = true) * traceLog("New relation {$relationName} was created", $attachedIdList); * }); * + * >**NOTE:** If a custom pivotModel is being used the parameters will actually be `string $relationName, mixed $id, array $attributes` + * */ - $this->parent->fireEvent('model.relation.afterAttach', [$this->relationName, $attachedIdList, $insertData]); + $this->parent->fireEvent('model.relation.afterAttach', $eventArgs); } /** diff --git a/src/Database/Relations/Concerns/HasOneOrMany.php b/src/Database/Relations/Concerns/HasOneOrMany.php index 24cb271e8..918604cb9 100644 --- a/src/Database/Relations/Concerns/HasOneOrMany.php +++ b/src/Database/Relations/Concerns/HasOneOrMany.php @@ -57,6 +57,21 @@ public function create(array $attributes = [], $sessionKey = null) public function add(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeAdd + * Called before adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAdd', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAdd', [$this->relationName, $model]); + $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); if (!$model->exists || $model->isDirty()) { @@ -72,6 +87,21 @@ public function add(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterAdd + * Called after adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAdd', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was added as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAdd', [$this->relationName, $model]); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey); @@ -96,6 +126,21 @@ public function addMany($models, $sessionKey = null) public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeRemove + * Called before removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeRemove', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeRemove', [$this->relationName, $model]); + $model->setAttribute($this->getForeignKeyName(), null); $model->save(); @@ -108,6 +153,20 @@ public function remove(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + /** + * @event model.relation.afterRemove + * Called after removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterRemove', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was removed from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterRemove', [$this->relationName, $model]); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); diff --git a/src/Database/Relations/Concerns/MorphOneOrMany.php b/src/Database/Relations/Concerns/MorphOneOrMany.php index b7166aeea..6add0dcb4 100644 --- a/src/Database/Relations/Concerns/MorphOneOrMany.php +++ b/src/Database/Relations/Concerns/MorphOneOrMany.php @@ -45,6 +45,21 @@ public function create(array $attributes = [], $sessionKey = null) public function add(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeAdd + * Called before adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeAdd', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'dummyRelation') { + * throw new \Exception("Invalid relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeAdd', [$this->relationName, $model]); + $model->setAttribute($this->getForeignKeyName(), $this->getParentKey()); $model->setAttribute($this->getMorphType(), $this->morphClass); $model->save(); @@ -58,6 +73,21 @@ public function add(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterAdd + * Called after adding a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterAdd', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was added as {$relationName} to {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterAdd', [$this->relationName, $model]); } else { $this->parent->bindDeferred($this->relationName, $model, $sessionKey); @@ -70,6 +100,21 @@ public function add(Model $model, $sessionKey = null) public function remove(Model $model, $sessionKey = null) { if ($sessionKey === null) { + /** + * @event model.relation.beforeRemove + * Called before removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.beforeRemove', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * if ($relationName === 'permanentRelation') { + * throw new \Exception("Cannot dissociate a permanent relation!"); + * } + * }); + * + */ + $this->parent->fireEvent('model.relation.beforeRemove', [$this->relationName, $model]); + $options = $this->parent->getRelationDefinition($this->relationName); if (array_get($options, 'delete', false)) { @@ -93,6 +138,21 @@ public function remove(Model $model, $sessionKey = null) else { $this->parent->reloadRelations($this->relationName); } + + /** + * @event model.relation.afterRemove + * Called after removing a relation to the model (for AttachOneOrMany, HasOneOrMany & MorphOneOrMany relations) + * + * Example usage: + * + * $model->bindEvent('model.relation.afterRemove', function (string $relationName, \Winter\Storm\Database\Model $relatedModel) use (\Winter\Storm\Database\Model $model) { + * $relatedClass = get_class($relatedModel); + * $modelClass = get_class($model); + * traceLog("{$relatedClass} was removed from {$modelClass}."); + * }); + * + */ + $this->parent->fireEvent('model.relation.afterRemove', [$this->relationName, $model]); } else { $this->parent->unbindDeferred($this->relationName, $model, $sessionKey); diff --git a/src/Database/Relations/MorphTo.php b/src/Database/Relations/MorphTo.php index 661cdc337..229c70efb 100644 --- a/src/Database/Relations/MorphTo.php +++ b/src/Database/Relations/MorphTo.php @@ -9,6 +9,7 @@ */ class MorphTo extends MorphToBase { + use Concerns\BelongsOrMorphsTo; use Concerns\DeferOneOrMany; use Concerns\DefinedConstraints; diff --git a/src/Database/Relations/MorphToMany.php b/src/Database/Relations/MorphToMany.php index e8eb53b91..2487139c4 100644 --- a/src/Database/Relations/MorphToMany.php +++ b/src/Database/Relations/MorphToMany.php @@ -45,12 +45,6 @@ public function __construct( $relationName = null, $inverse = false ) { - $this->inverse = $inverse; - - $this->morphType = $name.'_type'; - - $this->morphClass = $inverse ? $query->getModel()->getMorphClass() : $parent->getMorphClass(); - parent::__construct( $query, $parent, @@ -60,7 +54,8 @@ public function __construct( $otherKey, $parentKey, $relatedKey, - $relationName + $relationName, + $inverse ); $this->addDefinedConstraints(); diff --git a/src/Database/Traits/ArraySource.php b/src/Database/Traits/ArraySource.php index 2dc241354..3c2ff5c83 100644 --- a/src/Database/Traits/ArraySource.php +++ b/src/Database/Traits/ArraySource.php @@ -100,11 +100,13 @@ protected static function arraySourceSetDbConnection(string $database): void */ protected function arraySourceCreateDb(): void { - if (File::exists($this->arraySourceGetDbPath())) { - File::delete($this->arraySourceGetDbPath()); + if ($this->arraySourceCanStoreDb()) { + if (File::exists($this->arraySourceGetDbPath())) { + File::delete($this->arraySourceGetDbPath()); + } + // Create SQLite file + File::put($this->arraySourceGetDbPath(), ''); } - // Create SQLite file - File::put($this->arraySourceGetDbPath(), ''); $records = $this->getRecords(); diff --git a/src/Database/Traits/HasSortableRelations.php b/src/Database/Traits/HasSortableRelations.php new file mode 100644 index 000000000..c3a65ba3b --- /dev/null +++ b/src/Database/Traits/HasSortableRelations.php @@ -0,0 +1,138 @@ + 'sort_order_column']; + * + * To set orders: + * + * $model->setSortableRelationOrder($relationName, $recordIds, $recordOrders); + * + */ +trait HasSortableRelations +{ + /** + * @var array The array of all sortable relations with their sort_order pivot column. + * + * public $sortableRelations = ['related_model' => 'sort_order']; + */ + + /** + * Initialize the HasSortableRelations trait for this model. + * Sets the sort_order value if a related model has been attached. + */ + public function initializeHasSortableRelations() : void + { + $sortableRelations = $this->getSortableRelations(); + + $this->bindEvent('model.relation.afterAttach', function ($relationName, $attached, $data) use ($sortableRelations) { + // Only for pivot-based relations + if (array_key_exists($relationName, $sortableRelations)) { + $column = $this->getRelationSortOrderColumn($relationName); + + foreach ($attached as $id) { + $this->updateRelationOrder($relationName, $id, $column); + } + } + }); + + $this->bindEvent('model.relation.afterAdd', function ($relationName, $relatedModel) use ($sortableRelations) { + // Only for non pivot-based relations + if (array_key_exists($relationName, $sortableRelations)) { + $column = $this->getRelationSortOrderColumn($relationName); + + $this->updateRelationOrder($relationName, $relatedModel->id, $column); + } + }); + + foreach ($sortableRelations as $relationName => $column) { + $relation = $this->$relationName(); + if (method_exists($relation, 'updateExistingPivot')) { + // Make sure all pivot-based defined sortable relations load the sort_order column as pivot data. + $definition = $this->getRelationDefinition($relationName); + $pivot = array_wrap(array_get($definition, 'pivot', [])); + + if (!in_array($column, $pivot)) { + $pivot[] = $column; + $definition['pivot'] = $pivot; + + $relationType = $this->getRelationType($relationName); + $this->$relationType[$relationName] = $definition; + } + } + } + } + + /** + * Set the sort order of records to the specified orders. If the orders is + * undefined, the record identifier is used. + */ + public function setRelationOrder(string $relationName, string|int|array $itemIds, array $itemOrders = []) : void + { + if (!is_array($itemIds)) { + $itemIds = [$itemIds]; + } + + if (empty($itemOrders)) { + $itemOrders = $itemIds; + } + + if (count($itemIds) != count($itemOrders)) { + throw new Exception('Invalid setRelationOrder call - count of itemIds do not match count of itemOrders'); + } + + $column = $this->getRelationSortOrderColumn($relationName); + + foreach ($itemIds as $index => $id) { + $order = (int)$itemOrders[$index]; + $this->updateRelationOrder($relationName, $id, $column, $order); + } + } + + /** + * Update relation record sort_order. + */ + protected function updateRelationOrder(string $relationName, int $id, string $column, int $order = 0) : void + { + $relation = $this->{$relationName}(); + + if (!$order) { + $order = $relation->count(); + } + if (method_exists($relation, 'updateExistingPivot')) { + $relation->updateExistingPivot($id, [ $column => (int)$order ]); + } else { + $record = $relation->find($id); + $record->{$column} = (int)$order; + $record->save(); + } + } + + /** + * Get the name of the "sort_order" column. + */ + public function getRelationSortOrderColumn(string $relationName) : string + { + return $this->getSortableRelations()[$relationName] ?? 'sort_order'; + } + + /** + * Return all configured sortable relations. + */ + protected function getSortableRelations() : array + { + if (property_exists($this, 'sortableRelations')) { + return $this->sortableRelations; + } + return []; + } +} diff --git a/src/Database/Traits/Sluggable.php b/src/Database/Traits/Sluggable.php index 3d072f924..cee887b6a 100644 --- a/src/Database/Traits/Sluggable.php +++ b/src/Database/Traits/Sluggable.php @@ -94,10 +94,7 @@ protected function getSluggableUniqueAttributeValue($name, $value) $counter = 1; $separator = $this->getSluggableSeparator(); $_value = $value; - while (($this->methodExists('withTrashed') && $this->allowTrashedSlugs) ? - $this->newSluggableQuery()->where($name, $_value)->withTrashed()->count() > 0 : - $this->newSluggableQuery()->where($name, $_value)->count() > 0 - ) { + while ($this->newSluggableQuery()->where($name, $_value)->exists()) { $counter++; $_value = $value . $separator . $counter; } @@ -107,13 +104,22 @@ protected function getSluggableUniqueAttributeValue($name, $value) /** * Returns a query that excludes the current record if it exists + * Supports SoftDelete trait * @return Builder */ protected function newSluggableQuery() { - return $this->exists - ? $this->newQuery()->where($this->getKeyName(), '<>', $this->getKey()) - : $this->newQuery(); + $query = $this->newQuery(); + + if ($this->exists) { + $query->where($this->getKeyName(), '<>', $this->getKey()); + } + + if ($this->methodExists('withTrashed') && $this->allowTrashedSlugs) { + $query->withTrashed(); + } + + return $query; } /** diff --git a/src/Database/Updater.php b/src/Database/Updater.php index da497fd64..10f5b488e 100644 --- a/src/Database/Updater.php +++ b/src/Database/Updater.php @@ -13,6 +13,11 @@ */ class Updater { + /** + * @var array Local cache of migration file paths to support anonymous migrations [$path => $anonInstance || $className] + */ + protected static $migrationCache = []; + /** * Sets up a migration or seed file. */ @@ -69,20 +74,29 @@ public function packDown($file) * @param string $file * @return object|null */ - public function resolve($file) + public function resolve($file): ?object { if (!File::isFile($file)) { return null; } + if (isset(static::$migrationCache[$file])) { + return is_object(static::$migrationCache[$file]) + ? static::$migrationCache[$file] + : new static::$migrationCache[$file]; + } + $instance = require_once $file; if (is_object($instance)) { - return $instance; + return static::$migrationCache[$file] = $instance; } + if ($class = $this->getClassFromFile($file)) { - return new $class; + return new (static::$migrationCache[$file] = $class); } + + return null; } /** diff --git a/src/Events/Dispatcher.php b/src/Events/Dispatcher.php index 788606829..9cca58a08 100644 --- a/src/Events/Dispatcher.php +++ b/src/Events/Dispatcher.php @@ -265,4 +265,31 @@ protected function callQueueMethodOnHandler($class, $method, $arguments) 'class' => $class, 'method' => $method, 'data' => serialize($arguments), ]); } + + /** + * Create the class based event callable. + * + * @param array|string $listener + * @return callable + */ + protected function createClassCallable($listener) + { + [$class, $method] = is_array($listener) + ? $listener + : $this->parseClassCallable($listener); + + $listener = $this->container->make($class); + + if (! method_exists($listener, $method)) { + $method = '__invoke'; + } + + if ($this->handlerShouldBeQueued($class)) { + return $this->createQueuedHandlerCallable($class, $method); + } + + return $this->handlerShouldBeDispatchedAfterDatabaseTransactions($listener) + ? $this->createCallbackForListenerRunningAfterCommits($listener, $method) + : [$listener, $method]; + } } diff --git a/src/Filesystem/Zip.php b/src/Filesystem/Zip.php index d8ca7a62c..835a5e44f 100644 --- a/src/Filesystem/Zip.php +++ b/src/Filesystem/Zip.php @@ -152,7 +152,19 @@ public function add(string $source, array $options = []): self $folders = []; $recursive = false; } else { - $files = glob($source, GLOB_BRACE); + // Workaround for systems that do not support GLOB_BRACE (ie. Solaris, Alpine Linux) + if (!defined('GLOB_BRACE') && $includeHidden) { + $files = array_merge( + glob(dirname($source) . '/*'), + glob(dirname($source) . '/.[!.]*'), + glob(dirname($source) . '/..?*') + ); + } elseif (defined('GLOB_BRACE')) { + $files = glob($source, GLOB_BRACE); + } else { + $files = glob($source); + } + $folders = glob(dirname($source) . '/*', GLOB_ONLYDIR); } diff --git a/src/Foundation/Application.php b/src/Foundation/Application.php index 568888f1c..92ee21442 100644 --- a/src/Foundation/Application.php +++ b/src/Foundation/Application.php @@ -470,6 +470,7 @@ public function registerCoreContainerAliases() 'hash' => [\Illuminate\Contracts\Hashing\Hasher::class], 'translator' => [\Illuminate\Translation\Translator::class, \Illuminate\Contracts\Translation\Translator::class], 'log' => [\Illuminate\Log\Logger::class, \Psr\Log\LoggerInterface::class], + 'mail.manager' => [\Illuminate\Mail\MailManager::class, \Illuminate\Contracts\Mail\Factory::class], 'mailer' => [\Illuminate\Mail\Mailer::class, \Illuminate\Contracts\Mail\Mailer::class, \Illuminate\Contracts\Mail\MailQueue::class], 'queue' => [\Illuminate\Queue\QueueManager::class, \Illuminate\Contracts\Queue\Factory::class, \Illuminate\Contracts\Queue\Monitor::class], 'queue.connection' => [\Illuminate\Contracts\Queue\Queue::class], diff --git a/src/Foundation/Bootstrap/RegisterWinter.php b/src/Foundation/Bootstrap/RegisterWinter.php index c9ff12699..073597b83 100644 --- a/src/Foundation/Bootstrap/RegisterWinter.php +++ b/src/Foundation/Bootstrap/RegisterWinter.php @@ -25,6 +25,9 @@ public function bootstrap(Application $app): void $app->singleton('string', function () { return new \Winter\Storm\Support\Str; }); + $app->singleton('svg', function () { + return new \Winter\Storm\Support\Svg; + }); /* * Change paths based on config diff --git a/src/Foundation/Http/Middleware/CheckForTrustedProxies.php b/src/Foundation/Http/Middleware/CheckForTrustedProxies.php index d8f445d05..b4efdf887 100644 --- a/src/Foundation/Http/Middleware/CheckForTrustedProxies.php +++ b/src/Foundation/Http/Middleware/CheckForTrustedProxies.php @@ -98,6 +98,12 @@ protected function setTrustedProxies(Request $request) ]); return; } + + // If all proxies are allowed, open the floodgates + if ($proxies === '**') { + $this->allowProxies($request, ['0.0.0.0/0', '2000:0:0:0:0:0:0:0/3']); + return; + } // Support comma-separated strings as well as arrays $proxies = (is_string($proxies)) diff --git a/src/Halcyon/Datasource/DbDatasource.php b/src/Halcyon/Datasource/DbDatasource.php index a91de342f..5d83c4b04 100644 --- a/src/Halcyon/Datasource/DbDatasource.php +++ b/src/Halcyon/Datasource/DbDatasource.php @@ -95,7 +95,7 @@ public function getQuery(bool $ignoreDeleted = true): \Winter\Storm\Database\Que */ protected function makeFilePath(string $dirName, string $fileName, string $extension): string { - return $dirName . '/' . $fileName . '.' . $extension; + return ltrim($dirName . '/' . $fileName . '.' . $extension, '/'); } /** diff --git a/src/Mail/MailManager.php b/src/Mail/MailManager.php index ade48d425..45ee036d9 100644 --- a/src/Mail/MailManager.php +++ b/src/Mail/MailManager.php @@ -7,6 +7,7 @@ * Overrides the Laravel MailManager * - Replaces the Laravel Mailer class with the Winter Mailer class * - Fires mailer.beforeRegister & mailer.register events + * - Uses another method to determine old vs. new mail configs */ class MailManager extends BaseMailManager { @@ -71,4 +72,29 @@ protected function resolve($name) return $mailer; } + + /** + * @inheritDoc + */ + protected function getConfig(string $name) + { + // Here we will check if the "mailers" key exists and if it does, we will use that to + // determine the applicable config. Laravel checks if the "drivers" key exists, and while + // that does work for Laravel, it doesn't work in Winter when someone uses the Backend to + // populate mail settings, as these mail settings are populated into the "mailers" key. + return $this->app['config']['mail.mailers'] + ? ($this->app['config']["mail.mailers.{$name}"] ?? $this->app['config']['mail']) + : $this->app['config']['mail']; + } + + /** + * @inheritDoc + */ + public function getDefaultDriver() + { + // We will do the reverse of what Laravel does and check for "default" first, which is + // populated by the Backend or the new "mail" config, before searching for the "driver" + // key that was present in older version of Winter (<1.2). + return $this->app['config']['mail.default'] ?? $this->app['config']['mail.driver']; + } } diff --git a/src/Parse/EnvFile.php b/src/Parse/EnvFile.php index dd590d625..14fcc200b 100644 --- a/src/Parse/EnvFile.php +++ b/src/Parse/EnvFile.php @@ -1,241 +1,7 @@ -filePath = $filePath; - - list($this->env, $this->map) = $this->parse($filePath); - } - - /** - * Return a new instance of `EnvFile` ready for modification of the file. - */ - public static function open(?string $filePath = null): static - { - if (!$filePath) { - $filePath = base_path('.env'); - } - - return new static($filePath); - } - - /** - * Set a property within the env. Passing an array as param 1 is also supported. - * - * ```php - * $env->set('APP_PROPERTY', 'example'); - * // or - * $env->set([ - * 'APP_PROPERTY' => 'example', - * 'DIF_PROPERTY' => 'example' - * ]); - * ``` - */ - public function set(array|string $key, $value = null): static - { - if (is_array($key)) { - foreach ($key as $item => $value) { - $this->set($item, $value); - } - return $this; - } - - if (!isset($this->map[$key])) { - $this->env[] = [ - 'type' => 'var', - 'key' => $key, - 'value' => $value - ]; - - $this->map[$key] = count($this->env) - 1; - - return $this; - } - - $this->env[$this->map[$key]]['value'] = $value; - - return $this; - } - - /** - * Push a newline onto the end of the env file - */ - public function addEmptyLine(): EnvFile - { - $this->env[] = [ - 'type' => 'nl' - ]; - - return $this; - } - - /** - * Write the current env lines to a fileh - */ - public function write(string $filePath = null): void - { - if (!$filePath) { - $filePath = $this->filePath; - } - - file_put_contents($filePath, $this->render()); - } - - /** - * Get the env lines data as a string - */ - public function render(): string - { - $out = ''; - foreach ($this->env as $env) { - switch ($env['type']) { - case 'comment': - $out .= $env['value']; - break; - case 'var': - $out .= $env['key'] . '=' . $this->escapeValue($env['value']); - break; - } - - $out .= PHP_EOL; - } - - return $out; - } - - /** - * Wrap a value in quotes if needed - * - * @param mixed $value - */ - protected function escapeValue($value): string - { - if (is_numeric($value)) { - return $value; - } - - if ($value === true) { - return 'true'; - } - - if ($value === false) { - return 'false'; - } - - if ($value === null) { - return 'null'; - } - - switch ($value) { - case 'true': - case 'false': - case 'null': - return $value; - default: - // addslashes() wont work as it'll escape single quotes and they will be read literally - return '"' . Str::replace('"', '\"', $value) . '"'; - } - } - - /** - * Parse a .env file, returns an array of the env file data and a key => position map - */ - protected function parse(string $filePath): array - { - if (!is_file($filePath)) { - return [[], []]; - } - - $contents = file($filePath); - if (empty($contents)) { - return [[], []]; - } - - $env = []; - $map = []; - - foreach ($contents as $line) { - $type = !($line = trim($line)) - ? 'nl' - : ( - Str::startsWith($line, '#') - ? 'comment' - : 'var' - ); - - $entry = [ - 'type' => $type - ]; - - if ($type === 'var') { - if (strpos($line, '=') === false) { - // if we cannot split the string, handle it the same as a comment - // i.e. inject it back into the file as is - $entry['type'] = $type = 'comment'; - } else { - list($key, $value) = explode('=', $line); - $entry['key'] = trim($key); - $entry['value'] = trim($value, '"'); - } - } - - if ($type === 'comment') { - $entry['value'] = $line; - } - - $env[] = $entry; - } - - foreach ($env as $index => $item) { - if ($item['type'] !== 'var') { - continue; - } - $map[$item['key']] = $index; - } - - return [$env, $map]; - } - - /** - * Get the variables from the current env lines data as an associative array - */ - public function getVariables(): array - { - $env = []; - - foreach ($this->env as $item) { - if ($item['type'] !== 'var') { - continue; - } - $env[$item['key']] = $item['value']; - } - - return $env; - } } diff --git a/src/Parse/PHP/ArrayFile.php b/src/Parse/PHP/ArrayFile.php index c0b2e7a18..e211e9f9c 100644 --- a/src/Parse/PHP/ArrayFile.php +++ b/src/Parse/PHP/ArrayFile.php @@ -1,435 +1,9 @@ astReturnIndex = $this->getAstReturnIndex($ast); - - if (is_null($this->astReturnIndex)) { - throw new \InvalidArgumentException('ArrayFiles must start with a return statement'); - } - - $this->ast = $ast; - $this->lexer = $lexer; - $this->filePath = $filePath; - $this->printer = $printer ?? new ArrayPrinter(); - } - - /** - * Return a new instance of `ArrayFile` ready for modification of the file. - * - * @throws \InvalidArgumentException if the provided path doesn't exist and $throwIfMissing is true - * @throws SystemException if the provided path is unable to be parsed - */ - public static function open(string $filePath, bool $throwIfMissing = false): static - { - $exists = file_exists($filePath); - - if (!$exists && $throwIfMissing) { - throw new \InvalidArgumentException('file not found'); - } - - $lexer = new Lexer\Emulative([ - 'usedAttributes' => [ - 'comments', - 'startTokenPos', - 'startLine', - 'endTokenPos', - 'endLine' - ] - ]); - $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7, $lexer); - - try { - $ast = $parser->parse( - $exists - ? file_get_contents($filePath) - : sprintf('set('property.key.value', 'example'); - * // or - * $config->set([ - * 'property.key1.value' => 'example', - * 'property.key2.value' => 'example' - * ]); - * ``` - */ - public function set(string|array $key, $value = null): static - { - if (is_array($key)) { - foreach ($key as $name => $value) { - $this->set($name, $value); - } - - return $this; - } - - // try to find a reference to ast object - list($target, $remaining) = $this->seek(explode('.', $key), $this->ast[$this->astReturnIndex]->expr); - - $valueType = $this->getType($value); - - // part of a path found - if ($target && $remaining) { - $target->value->items[] = $this->makeArrayItem(implode('.', $remaining), $valueType, $value); - return $this; - } - - // path to not found - if (is_null($target)) { - $this->ast[$this->astReturnIndex]->expr->items[] = $this->makeArrayItem($key, $valueType, $value); - return $this; - } - - if (!isset($target->value)) { - return $this; - } - - // special handling of function objects - if (get_class($target->value) === FuncCall::class && $valueType !== 'function') { - if ($target->value->name->parts[0] !== 'env' || !isset($target->value->args[0])) { - return $this; - } - if (isset($target->value->args[0]) && !isset($target->value->args[1])) { - $target->value->args[1] = new Arg($this->makeAstNode($valueType, $value)); - } - $target->value->args[1]->value = $this->makeAstNode($valueType, $value); - return $this; - } - - // default update in place - $target->value = $this->makeAstNode($valueType, $value); - - return $this; - } - - /** - * Creates either a simple array item or a recursive array of items - */ - protected function makeArrayItem(string $key, string $valueType, $value): ArrayItem - { - return (str_contains($key, '.')) - ? $this->makeAstArrayRecursive($key, $valueType, $value) - : new ArrayItem( - $this->makeAstNode($valueType, $value), - $this->makeAstNode($this->getType($key), $key) - ); - } - - /** - * Generate an AST node, using `PhpParser` classes, for a value - * - * @throws \RuntimeException If $type is not one of 'string', 'boolean', 'integer', 'function', 'const', 'null', or 'array' - * @return ConstFetch|LNumber|String_|Array_|FuncCall - */ - protected function makeAstNode(string $type, $value) - { - switch (strtolower($type)) { - case 'string': - return new String_($value); - case 'boolean': - return new ConstFetch(new Name($value ? 'true' : 'false')); - case 'integer': - return new LNumber($value); - case 'function': - return new FuncCall( - new Name($value->getName()), - array_map(function ($arg) { - return new Arg($this->makeAstNode($this->getType($arg), $arg)); - }, $value->getArgs()) - ); - case 'const': - return new ConstFetch(new Name($value->getName())); - case 'null': - return new ConstFetch(new Name('null')); - case 'array': - return $this->castArray($value); - default: - throw new \RuntimeException("An unimlemented replacement type ($type) was encountered"); - } - } - - /** - * Cast an array to AST - */ - protected function castArray(array $array): Array_ - { - return ($caster = function ($array, $ast) use (&$caster) { - $useKeys = []; - foreach (array_keys($array) as $i => $key) { - $useKeys[$key] = (!is_numeric($key) || $key !== $i); - } - foreach ($array as $key => $item) { - if (is_array($item)) { - $ast->items[] = new ArrayItem( - $caster($item, new Array_()), - ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) - ); - continue; - } - $ast->items[] = new ArrayItem( - $this->makeAstNode($this->getType($item), $item), - ($useKeys[$key] ? $this->makeAstNode($this->getType($key), $key) : null) - ); - } - - return $ast; - })($array, new Array_()); - } - - /** - * Returns type of var passed - * - * @param mixed $var - */ - protected function getType($var): string - { - if ($var instanceof PHPFunction) { - return 'function'; - } - - if ($var instanceof PHPConstant) { - return 'const'; - } - - return gettype($var); - } - - /** - * Returns an ArrayItem generated from a dot notation path - * - * @param string $key - * @param string $valueType - * @param mixed $value - */ - protected function makeAstArrayRecursive(string $key, string $valueType, $value): ArrayItem - { - $path = array_reverse(explode('.', $key)); - - $arrayItem = $this->makeAstNode($valueType, $value); - - foreach ($path as $index => $pathKey) { - if (is_numeric($pathKey)) { - $pathKey = (int) $pathKey; - } - $arrayItem = new ArrayItem($arrayItem, $this->makeAstNode($this->getType($pathKey), $pathKey)); - - if ($index !== array_key_last($path)) { - $arrayItem = new Array_([$arrayItem]); - } - } - - return $arrayItem; - } - - /** - * Find the return position within the ast, returns null on encountering an unsupported ast stmt. - * - * @param array $ast - * @return int|null - */ - protected function getAstReturnIndex(array $ast): ?int - { - foreach ($ast as $index => $item) { - switch (get_class($item)) { - case Stmt\Use_::class: - case Stmt\Expression::class: - break; - case Stmt\Return_::class: - return $index; - default: - return null; - } - } - - return null; - } - - /** - * Attempt to find the parent object of the targeted path. - * If the path cannot be found completely, return the nearest parent and the remainder of the path - * - * @param array $path - * @param mixed $pointer - * @param int $depth - * @throws SystemException if trying to set a position that is already occupied by a value - */ - protected function seek(array $path, &$pointer, int $depth = 0): array - { - if (!$pointer) { - return [null, $path]; - } - - $key = array_shift($path); - - if (isset($pointer->value) && !($pointer->value instanceof ArrayItem || $pointer->value instanceof Array_)) { - throw new SystemException(sprintf( - 'Illegal offset, you are trying to set a position occupied by a value (%s)', - get_class($pointer->value) - )); - } - - foreach (($pointer->items ?? $pointer->value->items) as $index => &$item) { - // loose checking to allow for int keys - if ($item->key->value == $key) { - if (!empty($path)) { - return $this->seek($path, $item, ++$depth); - } - - return [$item, []]; - } - } - - array_unshift($path, $key); - - return [($depth > 0) ? $pointer : null, $path]; - } - - /** - * Sort the config, supports: ArrayFile::SORT_ASC, ArrayFile::SORT_DESC, callable - * - * @param string|callable $mode - * @throws \InvalidArgumentException if the provided sort type is not a callable or one of static::SORT_ASC or static::SORT_DESC - */ - public function sort($mode = self::SORT_ASC): ArrayFile - { - if (is_callable($mode)) { - usort($this->ast[0]->expr->items, $mode); - return $this; - } - - switch ($mode) { - case static::SORT_ASC: - case static::SORT_DESC: - $this->sortRecursive($this->ast[0]->expr->items, $mode); - break; - default: - throw new \InvalidArgumentException('Requested sort type is invalid'); - } - - return $this; - } - - /** - * Recursive sort an Array_ item array - */ - protected function sortRecursive(array &$array, string $mode): void - { - foreach ($array as &$item) { - if (isset($item->value) && $item->value instanceof Array_) { - $this->sortRecursive($item->value->items, $mode); - } - } - - usort($array, function ($a, $b) use ($mode) { - return $mode === static::SORT_ASC - ? $a->key->value <=> $b->key->value - : $b->key->value <=> $a->key->value; - }); - } - - /** - * Write the current config to a file - */ - public function write(string $filePath = null): void - { - if (!$filePath && $this->filePath) { - $filePath = $this->filePath; - } - - file_put_contents($filePath, $this->render()); - } - - /** - * Returns a new instance of PHPFunction - */ - public function function(string $name, array $args): PHPFunction - { - return new PHPFunction($name, $args); - } - - /** - * Returns a new instance of PHPConstant - */ - public function constant(string $name): PHPConstant - { - return new PHPConstant($name); - } - - /** - * Get the printed AST as PHP code - */ - public function render(): string - { - return $this->printer->render($this->ast, $this->lexer) . "\n"; - } - - /** - * Get currently loaded AST - * - * @return Stmt[]|null - */ - public function getAst() - { - return $this->ast; - } } diff --git a/src/Parse/PHP/ArrayPrinter.php b/src/Parse/PHP/ArrayPrinter.php deleted file mode 100644 index 81f967daf..000000000 --- a/src/Parse/PHP/ArrayPrinter.php +++ /dev/null @@ -1,316 +0,0 @@ -lexer = $lexer; - - $p = "prettyPrint($stmts); - - if ($stmts[0] instanceof Stmt\InlineHTML) { - $p = preg_replace('/^<\?php\s+\?>\n?/', '', $p); - } - if ($stmts[count($stmts) - 1] instanceof Stmt\InlineHTML) { - $p = preg_replace('/<\?php$/', '', rtrim($p)); - } - - $this->lexer = null; - - return $p; - } - - /** - * @param array $nodes - * @param bool $trailingComma - * @return string - */ - protected function pMaybeMultiline(array $nodes, bool $trailingComma = false) - { - if ($this->hasNodeWithComments($nodes) || (isset($nodes[0]) && $nodes[0] instanceof Expr\ArrayItem)) { - return $this->pCommaSeparatedMultiline($nodes, $trailingComma) . $this->nl; - } else { - return $this->pCommaSeparated($nodes); - } - } - - /** - * Pretty prints a comma-separated list of nodes in multiline style, including comments. - * - * The result includes a leading newline and one level of indentation (same as pStmts). - * - * @param array $nodes Array of Nodes to be printed - * @param bool $trailingComma Whether to use a trailing comma - * - * @return string Comma separated pretty printed nodes in multiline style - */ - protected function pCommaSeparatedMultiline(array $nodes, bool $trailingComma): string - { - $this->indent(); - - $result = ''; - $lastIdx = count($nodes) - 1; - foreach ($nodes as $idx => $node) { - if ($node !== null) { - $comments = $node->getComments(); - - if ($comments) { - $result .= $this->pComments($comments); - } - - $result .= $this->nl . $this->p($node); - } else { - $result = trim($result) . "\n"; - } - if ($trailingComma || $idx !== $lastIdx) { - $result .= ','; - } - } - - $this->outdent(); - return $result; - } - - /** - * Render an array expression - * - * @param Expr\Array_ $node Array expression node - * - * @return string Comma separated pretty printed nodes in multiline style - */ - protected function pExpr_Array(Expr\Array_ $node): string - { - $default = $this->options['shortArraySyntax'] - ? Expr\Array_::KIND_SHORT - : Expr\Array_::KIND_LONG; - - $ops = $node->getAttribute('kind', $default) === Expr\Array_::KIND_SHORT - ? ['[', ']'] - : ['array(', ')']; - - if (!count($node->items) && $comments = $this->getNodeComments($node)) { - // the array has no items, we can inject whatever we want - return sprintf( - '%s%s%s%s%s', - // opening control char - $ops[0], - // indent and add nl string - $this->indent(), - // join all comments with nl string - implode($this->nl, $comments), - // outdent and add nl string - $this->outdent(), - // closing control char - $ops[1] - ); - } - - if ($comments = $this->getCommentsNotInArray($node)) { - // array has items, we have detected comments not included within the array, therefore we have found - // trailing comments and must append them to the end of the array - return sprintf( - '%s%s%s%s%s%s', - // opening control char - $ops[0], - // render the children - $this->pMaybeMultiline($node->items, true), - // add 1 level of indentation - str_repeat(' ', 4), - // join all comments with the current indentation - implode($this->nl . str_repeat(' ', 4), $comments), - // add a trailing nl - $this->nl, - // closing control char - $ops[1] - ); - } - - // default return - return $ops[0] . $this->pMaybeMultiline($node->items, true) . $ops[1]; - } - - /** - * Increase indentation level. - * Proxied to allow for nl return - * - * @return string - */ - protected function indent(): string - { - $this->indentLevel += 4; - $this->nl .= ' '; - return $this->nl; - } - - /** - * Decrease indentation level. - * Proxied to allow for nl return - * - * @return string - */ - protected function outdent(): string - { - assert($this->indentLevel >= 4); - $this->indentLevel -= 4; - $this->nl = "\n" . str_repeat(' ', $this->indentLevel); - return $this->nl; - } - - /** - * Get all comments that have not been attributed to a node within a node array - * - * @param Expr\Array_ $nodes Array of nodes - * - * @return array Comments found - */ - protected function getCommentsNotInArray(Expr\Array_ $nodes): array - { - if (!$comments = $this->getNodeComments($nodes)) { - return []; - } - - return array_filter($comments, function ($comment) use ($nodes) { - return !$this->commentInNodeList($nodes->items, $comment); - }); - } - - /** - * Recursively check if a comment exists in an array of nodes - * - * @param Node[] $nodes Array of nodes - * @param string $comment The comment to search for - * - * @return bool - */ - protected function commentInNodeList(array $nodes, string $comment): bool - { - foreach ($nodes as $node) { - if ($node->value instanceof Expr\Array_ && $this->commentInNodeList($node->value->items, $comment)) { - return true; - } - if ($nodeComments = $node->getAttribute('comments')) { - foreach ($nodeComments as $nodeComment) { - if ($nodeComment->getText() === $comment) { - return true; - } - } - } - } - - return false; - } - - /** - * Check the lexer tokens for comments within the node's start & end position - * - * @param Node $node Node to check - * - * @return ?array - */ - protected function getNodeComments(Node $node): ?array - { - $tokens = $this->lexer->getTokens(); - $pos = $node->getAttribute('startTokenPos'); - $end = $node->getAttribute('endTokenPos'); - $endLine = $node->getAttribute('endLine'); - $content = []; - - while (++$pos < $end) { - if (!isset($tokens[$pos]) || (!is_array($tokens[$pos]) && $tokens[$pos] !== ',')) { - break; - } - - if ($tokens[$pos][0] === T_WHITESPACE || $tokens[$pos] === ',') { - continue; - } - - list($type, $string, $line) = $tokens[$pos]; - - if ($line > $endLine) { - break; - } - - if ($type === T_COMMENT || $type === T_DOC_COMMENT) { - $content[] = $string; - } elseif ($content) { - break; - } - } - - return empty($content) ? null : $content; - } - - /** - * Prints reformatted text of the passed comments. - * - * @param array $comments List of comments - * - * @return string Reformatted text of comments - */ - protected function pComments(array $comments): string - { - $formattedComments = []; - - foreach ($comments as $comment) { - $formattedComments[] = str_replace("\n", $this->nl, $comment->getReformattedText()); - } - - $padding = $comments[0]->getStartLine() !== $comments[count($comments) - 1]->getEndLine() ? $this->nl : ''; - - return "\n" . $this->nl . trim($padding . implode($this->nl, $formattedComments)) . "\n"; - } - - protected function pExpr_Include(Expr\Include_ $node) - { - static $map = [ - Expr\Include_::TYPE_INCLUDE => 'include', - Expr\Include_::TYPE_INCLUDE_ONCE => 'include_once', - Expr\Include_::TYPE_REQUIRE => 'require', - Expr\Include_::TYPE_REQUIRE_ONCE => 'require_once', - ]; - - return $map[$node->type] . '(' . $this->p($node->expr) . ')'; - } -} diff --git a/src/Parse/PHP/PHPConstant.php b/src/Parse/PHP/PHPConstant.php index 887a9eac5..c05431afc 100644 --- a/src/Parse/PHP/PHPConstant.php +++ b/src/Parse/PHP/PHPConstant.php @@ -1,25 +1,7 @@ name = $name; - } +use Winter\LaravelConfigWriter\Parser\PHPConstant as BasePHPConstant; - /** - * Get the const name - */ - public function getName(): string - { - return $this->name; - } +class PHPConstant extends BasePHPConstant +{ } diff --git a/src/Parse/PHP/PHPFunction.php b/src/Parse/PHP/PHPFunction.php index 1874b1e26..60abb5c84 100644 --- a/src/Parse/PHP/PHPFunction.php +++ b/src/Parse/PHP/PHPFunction.php @@ -1,47 +1,7 @@ name = $name; - $this->args = $args; - } +use Winter\LaravelConfigWriter\Parser\PHPFunction as BasePHPFunction; - /** - * Get the function name - * - * @return string - */ - public function getName(): string - { - return $this->name; - } - - /** - * Get the function arguments - * - * @return array - */ - public function getArgs(): array - { - return $this->args; - } +class PHPFunction extends BasePHPFunction +{ } diff --git a/src/Support/Arr.php b/src/Support/Arr.php index ea94efd45..29d05ed71 100644 --- a/src/Support/Arr.php +++ b/src/Support/Arr.php @@ -1,22 +1,19 @@ input($key, $default); } - /** - * Gets all input data items. - * - * This method is used for all request verbs (GET, POST, PUT, and DELETE) - * - * @return array|null - */ - public static function all() - { - return static::$app['request']->input(); - } - /** * Get the registered name of the component. * diff --git a/src/Support/Facades/Svg.php b/src/Support/Facades/Svg.php new file mode 100644 index 000000000..c6e97a49e --- /dev/null +++ b/src/Support/Facades/Svg.php @@ -0,0 +1,21 @@ + winter_3 + * winter, [winter_1, winter_3] -> winter */ - public static function getPrecedingSymbols($string, $symbol) + public static function unique(string $str, array $items, string $separator = '_', int $step = 1): string { - return strlen($string) - strlen(ltrim($string, $symbol)); + $indexes = []; + + if (!in_array($str, $items)) { + return $str; + } else { + $indexes[] = 0; + } + + foreach ($items as $item) { + if (!preg_match('/(.*?)' . $str . $separator . '(\d*$)/', $item, $matches)) { + continue; + } + + $indexes[] = (int) $matches[2]; + } + + return empty($indexes) + ? $str + : $str . $separator . (max($indexes) + $step); } } diff --git a/src/Support/Svg.php b/src/Support/Svg.php new file mode 100644 index 000000000..be9c232ce --- /dev/null +++ b/src/Support/Svg.php @@ -0,0 +1,60 @@ +removeRemoteReferences(true); + $sanitizer->removeXMLTag(true); + + if ($minify) { + $sanitizer->minify(true); + } + + return trim($sanitizer->sanitize($svg)); + } +} diff --git a/tests/Database/Attach/ResizerTest.php b/tests/Database/Attach/ResizerTest.php index b2a14d95c..e9dbb4abe 100644 --- a/tests/Database/Attach/ResizerTest.php +++ b/tests/Database/Attach/ResizerTest.php @@ -15,6 +15,7 @@ class ResizerTest extends TestCase const FIXTURE_SRC_BASE_PATH = self::FIXTURE_PATH . 'resizer/source/'; const FIXTURE_TARGET_PATH = self::FIXTURE_PATH . 'resizer/target/'; const TMP_TEST_FILE_PATH = self::FIXTURE_PATH . 'tmp/'; + const ARTIFACTS_PATH = __DIR__ . '/../../artifacts/ResizerTest/'; // Source image filenames const SRC_LANDSCAPE_ROTATED = 'landscape_rotated.jpg'; @@ -29,7 +30,7 @@ class ResizerTest extends TestCase */ const COMMON_FIXTURES = [ 'reset' => 'testReset_testResize0x0_testResizeAutoLandscape1x1', - 'square' => 'testResizeAutoSquare25x50_testResizeAutoSquare50x25_testResizeAutoSquare50x50_testResizeFitSquare50x50' + 'square' => 'testResizeAutoSquare50x100_testResizeAutoSquare100x50_testResizeAutoSquare100x100_testResizeFitSquare100x100' ]; /** @var string The path to the source image */ @@ -52,6 +53,14 @@ class ResizerTest extends TestCase */ protected function tearDown(): void { + if (!is_dir(self::ARTIFACTS_PATH)) { + @mkdir(self::ARTIFACTS_PATH, 0777, true); + } + + if (!empty($this->tmpTarget)) { + copy($this->tmpTarget, self::ARTIFACTS_PATH . basename($this->tmpTarget)); + } + @unlink($this->tmpTarget); @rmdir(self::TMP_TEST_FILE_PATH); parent::tearDown(); @@ -67,11 +76,34 @@ public function testReset() { $this->setSource(self::SRC_LANDSCAPE_TRANSPARENT); $this->createFixtureResizer(); - $this->resizer->resize(5, 5, ['mode' => 'crop']); + $this->resizer->resize(200, 200, ['mode' => 'crop']); $this->resizer->reset(); $this->assertImageSameAsFixture(self::COMMON_FIXTURES['reset']); } + /** + * Given a Resizer with any image + * Test to see if the getExtension() method properly returns the filename extension for the provided filename + */ + public function testGetExtension() + { + $this->setSource(self::SRC_PORTRAIT); // gif extension + $this->createFixtureResizer(); + + // no extension provided in path, no extension provided in options, should return source extension. + $extension = $this->callProtectedMethod($this->resizer, 'getExtension', ['dummy']); + $this->assertEquals('gif', $extension); + + // no extension provided in options, extension provided in path, should return path extension + $extension = $this->callProtectedMethod($this->resizer, 'getExtension', ['dummy.jpg']); + $this->assertEquals('jpg', $extension); + + // extension provided in options and in path, should return extension from options + $this->resizer->setOptions(['extension' => 'png']); + $extension = $this->callProtectedMethod($this->resizer, 'getExtension', ['dummy.jpg']); + $this->assertEquals('png', $extension); + } + /** * Given a Resizer with any image * When the resize method is called with 0x0 @@ -88,29 +120,29 @@ public function testResize0x0() /** * Given a Resizer with any image - * When the resize method is called with 20x0 - * Then the saved image should have a width of 20 and its height set automatically + * When the resize method is called with 50x0 + * Then the saved image should have a width of 50 and its height set automatically * @throws Exception */ - public function testResize20x0() + public function testResize50x0() { $this->setSource(self::SRC_PORTRAIT); $this->createFixtureResizer(); - $this->resizer->resize(20, 0); + $this->resizer->resize(50, 0); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with any image - * When the resize method is called with 0x20 - * Then the saved image should have a height of 20 and its width set automatically + * When the resize method is called with 0x50 + * Then the saved image should have a height of 50 and its width set automatically * @throws Exception */ - public function testResize0x20() + public function testResize0x50() { $this->setSource(self::SRC_PORTRAIT); $this->createFixtureResizer(); - $this->resizer->resize(0, 20); + $this->resizer->resize(0, 50); $this->assertImageSameAsFixture(__METHOD__); } @@ -130,57 +162,57 @@ public function testResizeAutoPortrait50() /** * Given a Resizer with a landscape image - * When the resize method is called with the auto parameter and 25x50 dimensions - * Then the saved image should have a width of 25 and its height set automatically + * When the resize method is called with the auto parameter and 125x50 dimensions + * Then the saved image should have a width of 125 and its height set automatically * @throws Exception */ - public function testResizeAutoLandscape25() + public function testResizeAutoLandscape125() { $this->setSource(self::SRC_LANDSCAPE_TRANSPARENT); $this->createFixtureResizer(); - $this->resizer->resize(25, 50, ['mode' => 'auto']); + $this->resizer->resize(125, 50, ['mode' => 'auto']); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with a square image - * When the resize method is called with the auto parameter and a 25x50 dimension - * Then the saved image should have be 50x50 (largest dimension takes over) + * When the resize method is called with the auto parameter and a 50x100 dimension + * Then the saved image should have be 100x100 (largest dimension takes over) * @throws Exception */ - public function testResizeAutoSquare25x50() + public function testResizeAutoSquare50x100() { $this->setSource(self::SRC_SQUARE); $this->createFixtureResizer(); - $this->resizer->resize(25, 50, ['mode' => 'auto']); + $this->resizer->resize(50, 100, ['mode' => 'auto']); $this->assertImageSameAsFixture(self::COMMON_FIXTURES['square']); } /** * Given a Resizer with a square image - * When the resize method is called with the auto parameter and a 50x25 dimension - * Then the saved image should have be 50x50 (largest dimension takes over) + * When the resize method is called with the auto parameter and a 100x50 dimension + * Then the saved image should have be 100x100 (largest dimension takes over) * @throws Exception */ - public function testResizeAutoSquare50x25() + public function testResizeAutoSquare100x50() { $this->setSource(self::SRC_SQUARE); $this->createFixtureResizer(); - $this->resizer->resize(50, 25, ['mode' => 'auto']); + $this->resizer->resize(100, 50, ['mode' => 'auto']); $this->assertImageSameAsFixture(self::COMMON_FIXTURES['square']); } /** * Given a Resizer with a square image - * When the resize method is called with the auto parameter and a 50x50 dimension - * Then the saved image should have be 50x50 + * When the resize method is called with the auto parameter and a 100x100 dimension + * Then the saved image should have be 100x100 * @throws Exception */ - public function testResizeAutoSquare50x50() + public function testResizeAutoSquare100x100() { $this->setSource(self::SRC_SQUARE); $this->createFixtureResizer(); - $this->resizer->resize(50, 50, ['mode' => 'auto']); + $this->resizer->resize(100, 100, ['mode' => 'auto']); $this->assertImageSameAsFixture(self::COMMON_FIXTURES['square']); } @@ -200,86 +232,86 @@ public function testResizeAutoLandscape1x1() /** * Given a Resizer with a transparent landscape image - * When the resize method is called with the auto parameter and 1x5 dimensions - * Then the saved image should be have a height of 5 and an automatic width + * When the resize method is called with the auto parameter and 1x50 dimensions + * Then the saved image should be have a height of 50 and an automatic width * @throws Exception */ - public function testResizeAutoLandscape1x5() + public function testResizeAutoLandscape1x50() { $this->setSource(self::SRC_LANDSCAPE_TRANSPARENT); $this->createFixtureResizer(); - $this->resizer->resize(1, 5); + $this->resizer->resize(1, 50); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with a transparent landscape image - * When the resize method is called with the auto parameter and 50x1 dimensions - * Then the saved image should have a width a 25 and an automatic height + * When the resize method is called with the auto parameter and 100x1 dimensions + * Then the saved image should have a width a 100 and an automatic height * @throws Exception */ - public function testResizeAutoLandscape25x1() + public function testResizeAutoLandscape100x1() { $this->setSource(self::SRC_LANDSCAPE_TRANSPARENT); $this->createFixtureResizer(); - $this->resizer->resize(25, 1); + $this->resizer->resize(100, 1); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with a square image - * When the resize method is called with the exact mode and 10x1=15 dimensions - * Then the saved image should be 10x15 and distorted + * When the resize method is called with the exact mode and 50x75 dimensions + * Then the saved image should be 50x75 and distorted * @throws Exception */ - public function testResizeExact10x15() + public function testResizeExact50x75() { $this->setSource(self::SRC_SQUARE); $this->createFixtureResizer(); - $this->resizer->resize(10, 15, ['mode' => 'exact']); + $this->resizer->resize(50, 75, ['mode' => 'exact']); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with any image - * When the resize method is called with the landscape mode and 10x1 dimensions - * Then the saved image should have a width of 10 and an automatic height + * When the resize method is called with the landscape mode and 100x1 dimensions + * Then the saved image should have a width of 100 and an automatic height * @throws Exception */ - public function testResizeLandscape10x1() + public function testResizeLandscape100x1() { $this->setSource(self::SRC_PORTRAIT); $this->createFixtureResizer(); - $this->resizer->resize(10, 1, ['mode' => 'landscape']); + $this->resizer->resize(100, 1, ['mode' => 'landscape']); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with any image * When the resize method is called with the portrait mode and 1x10 dimensions - * Then the saved image should have a width of 10 and an automatic height + * Then the saved image should have a width of 100 and an automatic height * @throws Exception */ - public function testResizePortrait1x10() + public function testResizePortrait1x100() { $this->setSource(self::SRC_PORTRAIT); $this->createFixtureResizer(); - $this->resizer->resize(1, 10, ['mode' => 'portrait']); + $this->resizer->resize(1, 100, ['mode' => 'portrait']); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with a white background gif image - * When the resize method is called with the auto parameter and 32x32 dimensions - * Then the saved image should have be 32x32 and with white background + * When the resize method is called with the auto parameter and 75x75 dimensions + * Then the saved image should have be 75x75 and with white background * Tests if white color is preserved/saved after resize operation * @throws Exception */ - public function testResizeSaveBackgroundColor32x32() + public function testResizeSaveBackgroundColor75x75() { $this->setSource(self::SRC_GIF_BG); $this->createFixtureResizer(); - $this->resizer->resize(32, 32); + $this->resizer->resize(75, 75); $this->assertImageSameAsFixture(__METHOD__); } @@ -299,64 +331,46 @@ public function testResizeIndex300x255() /** * Given a Resizer with a landscape image - * When the resize method is called with the fit mode and 30x30 dimensions - * Then the saved image should have a width of 30 and an automatic height + * When the resize method is called with the fit mode and 150x150 dimensions + * Then the saved image should have a width of 150 and an automatic height * @throws Exception */ - public function testResizeFitLandscape30x30() + public function testResizeFitLandscape150x150() { $this->setSource(self::SRC_LANDSCAPE_TRANSPARENT); $this->createFixtureResizer(); - $this->resizer->resize(30, 30, ['mode' => 'fit']); + $this->resizer->resize(150, 150, ['mode' => 'fit']); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with a landscape image - * When the resize method is called with the fit mode and 30x30 dimensions - * Then the saved image should have a height of 30 and an automatic width + * When the resize method is called with the fit mode and 150x150 dimensions + * Then the saved image should have a height of 150 and an automatic width * @throws Exception */ - public function testResizeFitPortrait30x30() + public function testResizeFitPortrait150x150() { $this->setSource(self::SRC_PORTRAIT); $this->createFixtureResizer(); - $this->resizer->resize(30, 30, ['mode' => 'fit']); + $this->resizer->resize(150, 150, ['mode' => 'fit']); $this->assertImageSameAsFixture(__METHOD__); } /** * Given a Resizer with a square image - * When the resize method is called with the fit mode and 50x50 dimensions - * Then the saved image should have be 50x50 + * When the resize method is called with the fit mode and 100x100 dimensions + * Then the saved image should have be 100x100 * @throws Exception */ - public function testResizeFitSquare50x50() + public function testResizeFitSquare100x100() { $this->setSource(self::SRC_SQUARE); $this->createFixtureResizer(); - $this->resizer->resize(50, 50, ['mode' => 'fit']); + $this->resizer->resize(100, 100, ['mode' => 'fit']); $this->assertImageSameAsFixture(self::COMMON_FIXTURES['square']); } - /** - * Given a Resizer with a JPG image which has an EXIF tag (Rotation=8) - * When the resize method is called with the auto mode and 30x30 dimensions - * Then the saved image should have the EXIF rotation applied and - * @throws Exception - */ - public function testResizeAutoExifRotated30x30() - { - if (!function_exists('exif_read_data')) { - $this->markTestSkipped('Missing exif extension'); - } - - $this->setSource(self::SRC_LANDSCAPE_ROTATED); - $this->createFixtureResizer(); - $this->resizer->resize(30, 30); - $this->assertImageSameAsFixture(__METHOD__); - } - /** * Given a Resizer with any image * When the sharpen method is called with a valid value @@ -367,7 +381,7 @@ public function testSharpen() { $this->setSource(self::SRC_SQUARE); $this->createFixtureResizer(); - $this->resizer->resize(25, 25, ['sharpen' => 50]); + $this->resizer->resize(100, 100, ['sharpen' => 50]); $this->assertImageSameAsFixture(__METHOD__); } @@ -377,11 +391,11 @@ public function testSharpen() * Then the saved image should be cropped as expected * @throws Exception */ - public function testCrop10x15() + public function testCrop30x45() { $this->setSource(self::SRC_PORTRAIT); $this->createFixtureResizer(); - $this->resizer->crop(3, 5, 10, 15); + $this->resizer->crop(10, 50, 30, 45); $this->assertImageSameAsFixture(__METHOD__); } @@ -438,12 +452,13 @@ protected function assertImageSameAsFixture(string $methodName) // Save resizer result to temp file $this->resizer->save($this->tmpTarget); - // Assert file is the same as expected output with 1% error permitted to account for library updates and whatnot + // Assert file is the same as expected output with 5% margin of error permitted to account for library + // updates and differences between OS image manipulation libraries. $this->assertSimilarGD( $this->tmpTarget, $this->target, $methodName . ' result did not match ' . $this->target, - 0.01 + 0.05 ); } } diff --git a/tests/Database/Behaviors/PurgeableTest.php b/tests/Database/Behaviors/PurgeableTest.php deleted file mode 100644 index 7b9a34c38..000000000 --- a/tests/Database/Behaviors/PurgeableTest.php +++ /dev/null @@ -1,77 +0,0 @@ -assertEquals(['Winter.Storm.Database.Behaviors.Purgeable'], $model->implement); - $this->assertEquals(['purgeable'], $model->purgeable); - } - - public function testDirectImplementationWithoutProperty() - { - $model = new TestModelDirectWithoutProperty(); - $this->assertEquals(['Winter.Storm.Database.Behaviors.Purgeable'], $model->implement); - $this->assertEquals(['purgeable'], $model->purgeable); - } - - public function testDynamicImplementation() - { - TestModelDynamic::extend(function ($model) { - $model->implement[] = 'Winter.Storm.Database.Behaviors.Purgeable'; - $model->addDynamicProperty('purgeable', []); - }); - $model = new TestModelDynamic(); - $this->assertEquals(['Winter.Storm.Database.Behaviors.Purgeable'], $model->implement); - $this->assertEquals(['purgeable'], $model->purgeable); - } - - public function testDynamicImplementationWithoutProperty() - { - TestModelDynamicWithoutProperty::extend(function ($model) { - $model->implement[] = 'Winter.Storm.Database.Behaviors.Purgeable'; - }); - $model = new TestModelDynamicWithoutProperty(); - $this->assertEquals(['Winter.Storm.Database.Behaviors.Purgeable'], $model->implement); - $this->assertEquals(['purgeable'], $model->purgeable); - } -} - -/* - * Class with implementation in the class itself - */ -class TestModelDirect extends Model -{ - public $implement = [ - 'Winter.Storm.Database.Behaviors.Purgeable' - ]; - - public $purgeable = []; -} - -/* - * Class with implementation in the class itself but without property - */ -class TestModelDirectWithoutProperty extends Model -{ - public $implement = [ - 'Winter.Storm.Database.Behaviors.Purgeable' - ]; -} - - -/* - * Class with no implementation that can be extended - */ -class TestModelDynamic extends Model -{ - -} - -class TestModelDynamicWithoutProperty extends Model -{ - -} diff --git a/tests/Events/DispatcherTest.php b/tests/Events/DispatcherTest.php index 2cf08f419..8cbb8648d 100644 --- a/tests/Events/DispatcherTest.php +++ b/tests/Events/DispatcherTest.php @@ -1,4 +1,4 @@ -dispatch(new EventTest()); $this->assertTrue($magic_value); } + + /** + * Test [$classInstance, 'method'] event listener format + */ + public function testInstanceMethodListen() + { + $dispatcher = new Dispatcher(); + $classInstance = new TestClass; + + $dispatcher->listen('test.test', [$classInstance, 'instanceMethodHandler']); + $dispatcher->fire('test.test'); + + $this->assertTrue($classInstance->getMagicValue()); + } + + /** + * Test 'ClassName@method' event listener format + */ + public function testClassMethodListen() + { + $magic_value = false; + $this->app->bind('TestClass', TestClass::class); + + Event::listen('test.test', 'TestClass@classMethodHandler'); + Event::fire('test.test', [&$magic_value]); + + $this->assertTrue($magic_value); + } +} + +class TestClass +{ + protected $magic_value = false; + + public function instanceMethodHandler() + { + $this->magic_value = true; + } + + public function classMethodHandler(&$value) + { + $value = true; + } + + public function getMagicValue() + { + return $this->magic_value; + } } diff --git a/tests/Foundation/Http/Middleware/CheckForTrustedProxiesTest.php b/tests/Foundation/Http/Middleware/CheckForTrustedProxiesTest.php index 3c6b76e40..cbde74f1f 100644 --- a/tests/Foundation/Http/Middleware/CheckForTrustedProxiesTest.php +++ b/tests/Foundation/Http/Middleware/CheckForTrustedProxiesTest.php @@ -20,7 +20,7 @@ public function testUntrusted() { $request = $this->createProxiedRequest(); - $this->assertEquals('173.174.200.38', $request->getClientIp()); + $this->assertEquals('192.168.10.10', $request->getClientIp()); $this->assertEquals('http', $request->getScheme()); $this->assertEquals('root.host', $request->getHost()); $this->assertEquals(8000, $request->getPort()); @@ -34,9 +34,9 @@ public function testUntrusted() public function testTrustedProxy() { $request = $this->createProxiedRequest(); - $request->setTrustedProxies(['173.174.200.38'], Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO); + $request->setTrustedProxies(['192.168.10.10'], Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO); - $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('173.174.200.38', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('proxy.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); @@ -53,7 +53,7 @@ public function testTrustedProxyMiddlewareWithWildcard() $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('173.174.200.38', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('proxy.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); @@ -67,15 +67,26 @@ public function testTrustedProxyMiddlewareWithWildcard() */ public function testTrustedProxyMiddlewareWithStringIp() { - $middleware = $this->createTrustedProxyMock('173.174.200.38', 'HEADER_X_FORWARDED_ALL'); + $middleware = $this->createTrustedProxyMock('192.168.10.10', 'HEADER_X_FORWARDED_ALL'); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('173.174.200.38', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('proxy.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); }); + + // Failure state + $middleware = $this->createTrustedProxyMock('192.168.12.12', 'HEADER_X_FORWARDED_ALL'); + $request = $this->createProxiedRequest(); + + $middleware->handle($request, function ($request) { + $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('http', $request->getScheme()); + $this->assertEquals('root.host', $request->getHost()); + $this->assertEquals(8000, $request->getPort()); + }); } /** @@ -85,15 +96,26 @@ public function testTrustedProxyMiddlewareWithStringIp() */ public function testTrustedProxyMiddlewareWithStringCsv() { - $middleware = $this->createTrustedProxyMock('173.174.200.38, 173.174.200.38', 'HEADER_X_FORWARDED_ALL'); + $middleware = $this->createTrustedProxyMock('192.168.10.10, 192.168.11.11', 'HEADER_X_FORWARDED_ALL'); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('173.174.200.38', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('proxy.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); }); + + // Failure state + $middleware = $this->createTrustedProxyMock('192.168.12.12, 192.168.13.13', 'HEADER_X_FORWARDED_ALL'); + $request = $this->createProxiedRequest(); + + $middleware->handle($request, function ($request) { + $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('http', $request->getScheme()); + $this->assertEquals('root.host', $request->getHost()); + $this->assertEquals(8000, $request->getPort()); + }); } /** @@ -103,29 +125,22 @@ public function testTrustedProxyMiddlewareWithStringCsv() */ public function testTrustedProxyMiddlewareWithArray() { - $middleware = $this->createTrustedProxyMock(['173.174.200.38', '173.174.200.38'], 'HEADER_X_FORWARDED_ALL'); + $middleware = $this->createTrustedProxyMock(['192.168.10.10', '192.168.11.11'], 'HEADER_X_FORWARDED_ALL'); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('173.174.200.38', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('proxy.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); }); - } - /** - * Test an untrusted connection through a proxy through middleware with an array of IP addresses. - * - * @return void - */ - public function testUntrustedProxyMiddlewareWithArray() - { - $middleware = $this->createTrustedProxyMock(['173.174.100.1', '173.174.100.2'], 'HEADER_X_FORWARDED_ALL'); + // Failure state + $middleware = $this->createTrustedProxyMock(['192.168.12.12', '192.168.13.13'], 'HEADER_X_FORWARDED_ALL'); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('173.174.200.38', $request->getClientIp()); + $this->assertEquals('192.168.10.10', $request->getClientIp()); $this->assertEquals('http', $request->getScheme()); $this->assertEquals('root.host', $request->getHost()); $this->assertEquals(8000, $request->getPort()); @@ -139,11 +154,11 @@ public function testUntrustedProxyMiddlewareWithArray() */ public function testUntrustedHeaders() { - $middleware = $this->createTrustedProxyMock('173.174.200.38', Request::HEADER_FORWARDED); + $middleware = $this->createTrustedProxyMock('192.168.10.10', Request::HEADER_FORWARDED); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('173.174.200.38', $request->getClientIp()); + $this->assertEquals('192.168.10.10', $request->getClientIp()); $this->assertEquals('http', $request->getScheme()); $this->assertEquals('root.host', $request->getHost()); $this->assertEquals(8000, $request->getPort()); @@ -157,11 +172,11 @@ public function testUntrustedHeaders() */ public function testTrustOnlyForwardedFor() { - $middleware = $this->createTrustedProxyMock('173.174.200.38', Request::HEADER_X_FORWARDED_FOR); + $middleware = $this->createTrustedProxyMock('192.168.10.10', Request::HEADER_X_FORWARDED_FOR); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('192.168.10.10', $request->getClientIp()); + $this->assertEquals('173.174.200.38', $request->getClientIp()); $this->assertEquals('http', $request->getScheme()); $this->assertEquals('root.host', $request->getHost()); $this->assertEquals(8000, $request->getPort()); @@ -175,11 +190,11 @@ public function testTrustOnlyForwardedFor() */ public function testTrustOnlyForwardedPort() { - $middleware = $this->createTrustedProxyMock('173.174.200.38', Request::HEADER_X_FORWARDED_PORT); + $middleware = $this->createTrustedProxyMock('192.168.10.10', Request::HEADER_X_FORWARDED_PORT); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('173.174.200.38', $request->getClientIp()); + $this->assertEquals('192.168.10.10', $request->getClientIp()); $this->assertEquals('http', $request->getScheme()); $this->assertEquals('root.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); @@ -195,11 +210,11 @@ public function testTrustOnlyForwardedPort() */ public function testTrustOnlyForwardedProto() { - $middleware = $this->createTrustedProxyMock('173.174.200.38', 'HEADER_X_FORWARDED_PROTO'); + $middleware = $this->createTrustedProxyMock('192.168.10.10', Request::HEADER_X_FORWARDED_PROTO); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('173.174.200.38', $request->getClientIp()); + $this->assertEquals('192.168.10.10', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('root.host', $request->getHost()); $this->assertEquals(8000, $request->getPort()); @@ -214,19 +229,165 @@ public function testTrustOnlyForwardedProto() public function testTrustOnlyForwardedHostPortAndProto() { $middleware = $this->createTrustedProxyMock( - '173.174.200.38', + '192.168.10.10', Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO ); $request = $this->createProxiedRequest(); $middleware->handle($request, function ($request) { - $this->assertEquals('173.174.200.38', $request->getClientIp()); + $this->assertEquals('192.168.10.10', $request->getClientIp()); $this->assertEquals('https', $request->getScheme()); $this->assertEquals('proxy.host', $request->getHost()); $this->assertEquals(443, $request->getPort()); }); } + /** + * Tests getting client IP from the "X-Forwarded-For" header with multiple IP addresses + * + * @return void + */ + public function testGetClientIps() + { + $middleware = $this->createTrustedProxyMock( + '192.168.10.10', + Request::HEADER_X_FORWARDED_FOR + ); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.2, 192.0.2.199', + '192.0.2.2, 192.0.2.199, 99.99.99.99', + '192.0.2.2,192.0.2.199', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $middleware->handle($request, function ($request) { + $ips = $request->getClientIps(); + $this->assertEquals('192.0.2.2', end($ips)); + }); + } + } + + /** + * Tests getting client IP from the "X-Forwarded-For" header with multiple IP addresses where some + * proxies are trusted. + * + * @return void + */ + public function testGetClientIpSomeProxiesTrusted() + { + $middleware = $this->createTrustedProxyMock( + ['192.168.10.10', '192.0.2.199'], + Request::HEADER_X_FORWARDED_FOR + ); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.2, 192.0.2.199', + '99.99.99.99, 192.0.2.2, 192.0.2.199', + '192.0.2.2,192.0.2.199', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $middleware->handle($request, function ($request) { + $this->assertEquals('192.0.2.2', $request->getClientIp()); + }); + } + } + + /** + * Tests getting client IP from the "X-Forwarded-For" header with multiple IP addresses where all + * proxies are trusted through the wildcard character. + * + * @return void + */ + public function testGetClientIpAllProxiesTrusted() + { + $middleware = $this->createTrustedProxyMock( + '*', + Request::HEADER_X_FORWARDED_FOR + ); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.199, 192.0.2.2', + '192.0.2.199,192.0.2.2', + '99.99.99.99,192.0.2.199,192.0.2.2', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $middleware->handle($request, function ($request) { + $this->assertEquals('192.0.2.2', $request->getClientIp()); + }); + } + } + + /** + * Tests getting client IP from the "X-Forwarded-For" header with multiple IP addresses where all + * proxies in the chain are trusted through the double-wildcard ('**') characters. + * + * @return void + */ + public function testGetClientIpAllChainedProxiesTrusted() + { + $middleware = $this->createTrustedProxyMock( + '**', + Request::HEADER_X_FORWARDED_FOR + ); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.2, 192.0.2.199', + '192.0.2.2, 99.99.99.99, 192.0.2.199', + '192.0.2.2, 2001:0db8:0a0b:12f0:0000:0000:0000:0001, 192.0.2.199', + '192.0.2.2, 2c01:0db8:0a0b:12f0:0000:0000:0000:0001, 192.0.2.199', + '192.0.2.2,192.0.2.199', + ]; + + foreach ($forwardedFor as $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $middleware->handle($request, function ($request) { + $this->assertEquals('192.0.2.2', $request->getClientIp()); + }); + } + + // Failure state + $middleware = $this->createTrustedProxyMock( + '*', + Request::HEADER_X_FORWARDED_FOR + ); + + $forwardedFor = [ + '192.0.2.2', + '192.0.2.2, 192.0.2.199', + '192.0.2.2, 99.99.99.99, 192.0.2.199', + '192.0.2.2, 2001:0db8:0a0b:12f0:0000:0000:0000:0001, 192.0.2.199', + '192.0.2.2, 2c01:0db8:0a0b:12f0:0000:0000:0000:0001, 192.0.2.199', + '192.0.2.2,192.0.2.199', + ]; + + // Items 1 should pass. The rest should fail. + foreach ($forwardedFor as $i => $forwardedForHeader) { + $request = $this->createProxiedRequest(['HTTP_X_FORWARDED_FOR' => $forwardedForHeader]); + + $middleware->handle($request, function ($request) use ($i) { + if ($i === 0) { + $this->assertEquals('192.0.2.2', $request->getClientIp(), 'Index ' . $i . ' failed when it should pass'); + } else { + $this->assertNotEquals('192.0.2.2', $request->getClientIp(), 'Index ' . $i . ' passed when it should fail'); + } + }); + } + } + /** * Create a proxied request for testing. * @@ -236,13 +397,13 @@ public function testTrustOnlyForwardedHostPortAndProto() protected function createProxiedRequest(array $overrides = []) { $defaults = [ - 'HTTP_X_FORWARDED_FOR' => '192.168.10.10', + 'HTTP_X_FORWARDED_FOR' => '173.174.200.38', 'HTTP_X_FORWARDED_HOST' => 'proxy.host', 'HTTP_X_FORWARDED_PORT' => '443', 'HTTP_X_FORWARDED_PROTO' => 'https', 'SERVER_PORT' => 8000, 'HTTP_HOST' => 'root.host', - 'REMOTE_ADDR' => '173.174.200.38', + 'REMOTE_ADDR' => '192.168.10.10', ]; $request = Request::create( diff --git a/tests/Parse/ArrayFileTest.php b/tests/Parse/ArrayFileTest.php index bb7a85090..231a22cbd 100644 --- a/tests/Parse/ArrayFileTest.php +++ b/tests/Parse/ArrayFileTest.php @@ -438,7 +438,7 @@ public function testWriteIllegalOffset() $file = __DIR__ . '/../fixtures/parse/arrayfile/empty.php'; $arrayFile = ArrayFile::open($file); - $this->expectException(\Winter\Storm\Exception\SystemException::class); + $this->expectException(\Winter\LaravelConfigWriter\Exceptions\ConfigWriterException::class); $arrayFile->set([ 'w.i.n.t.e.r' => 'Winter CMS', diff --git a/tests/Support/ArrTest.php b/tests/Support/ArrTest.php index b01a37bdb..2acde7bf1 100644 --- a/tests/Support/ArrTest.php +++ b/tests/Support/ArrTest.php @@ -4,6 +4,59 @@ class ArrTest extends TestCase { + public function testMoveKeyToIndex() + { + $array = [ + 'one' => 'a', + 'two' => 'b', + 'three' => 'c', + ]; + + // 0 based index means that index 1 is the second element + $this->assertSame([ + 'one' => 'a', + 'three' => 'c', + 'two' => 'b', + ], Arr::moveKeyToIndex($array, 'three', 1)); + + // 0 index inserts at start + $this->assertSame([ + 'two' => 'b', + 'one' => 'a', + 'three' => 'c', + ], Arr::moveKeyToIndex($array, 'two', 0)); + + // Index out of range inserts at end + $this->assertSame([ + 'one' => 'a', + 'three' => 'c', + 'two' => 'b', + ], Arr::moveKeyToIndex($array, 'two', 10)); + + // Negative index works backwards + $this->assertSame([ + 'two' => 'b', + 'one' => 'a', + 'three' => 'c', + ], Arr::moveKeyToIndex($array, 'two', -2)); + + // Negative index beyond bounds inserting as first element + $this->assertSame([ + 'two' => 'b', + 'one' => 'a', + 'three' => 'c', + ], Arr::moveKeyToIndex($array, 'two', -10)); + + // Elements with null values are correctly able to be sorted + $nullValueArray = $array; + $nullValueArray['two'] = null; + $this->assertSame([ + 'one' => 'a', + 'three' => 'c', + 'two' => null, + ], Arr::moveKeyToIndex($nullValueArray, 'two', 2)); + } + public function testArrClass() { $array = [ diff --git a/tests/Support/StrTest.php b/tests/Support/StrTest.php new file mode 100644 index 000000000..3abd67793 --- /dev/null +++ b/tests/Support/StrTest.php @@ -0,0 +1,35 @@ +assertSame('', Str::join([])); + $this->assertSame('bob', Str::join(['bob'])); + $this->assertSame('bob and joe', Str::join(['bob', 'joe'])); + $this->assertSame('bob, joe, and sally', Str::join(['bob', 'joe', 'sally'])); + $this->assertSame('bob, joe and sally', Str::join(['bob', 'joe', 'sally'], ', ', ' and ')); + $this->assertSame('bob, joe, and sally', Str::join(['bob', 'joe', 'sally'])); + $this->assertSame('bob or joe', Str::join(['bob', 'joe'], ', ', ', or ', ' or ')); + $this->assertSame('bob; joe; or sally', Str::join(['bob', 'joe', 'sally'], '; ', '; or ')); + } + + public function testUnique() + { + // Original returned unmodified when already unique + $this->assertSame('winter_cms', Str::unique('winter_cms', [])); + $this->assertSame('winter_cms', Str::unique('winter_cms', ['winter_cms_1', 'winter_cms_2'])); + + // // String modified to be the default step higher than the highest index identified + $this->assertSame('winter_cms_1', Str::unique('winter_cms', ['winter_cms'])); + $this->assertSame('winter_cms_4', Str::unique('winter_cms', ['winter_cms', 'winter_cms_1', 'test_5', 'winter_cms_3'])); + + // String modified to be the default step higher than the highest index identified with reversed order of items + $this->assertSame('winter_cms_98', Str::unique('winter_cms', ['winter_cms', 'winter_cms_97', 'test_5', 'winter_cms_3'])); + + // String modified to be the provided step higher than the highest index identified with the provided separator + $this->assertSame('winter_cms 5', Str::unique('winter_cms', ['winter_cms', 'winter_cms 1', 'test_5', 'winter_cms 3'], ' ', 2)); + } +} diff --git a/tests/Support/SvgTest.php b/tests/Support/SvgTest.php new file mode 100644 index 000000000..7bc723271 --- /dev/null +++ b/tests/Support/SvgTest.php @@ -0,0 +1,22 @@ +assertEquals($fixture, $svg); + } + + public function testDirtySvg() + { + $svg = Svg::extract(dirname(__DIR__) . '/fixtures/svg/winter-dirty.svg'); + $fixture = trim(file_get_contents(dirname(__DIR__) . '/fixtures/svg/extracted/winter-dirty.svg')); + + $this->assertEquals($fixture, $svg); + } +} diff --git a/tests/fixtures/resizer/source/landscape_rotated.jpg b/tests/fixtures/resizer/source/landscape_rotated.jpg index cd28b252a..6145aa9c2 100644 Binary files a/tests/fixtures/resizer/source/landscape_rotated.jpg and b/tests/fixtures/resizer/source/landscape_rotated.jpg differ diff --git a/tests/fixtures/resizer/source/landscape_transparent.png b/tests/fixtures/resizer/source/landscape_transparent.png index c9a202a82..77db38b6b 100644 Binary files a/tests/fixtures/resizer/source/landscape_transparent.png and b/tests/fixtures/resizer/source/landscape_transparent.png differ diff --git a/tests/fixtures/resizer/source/portrait.gif b/tests/fixtures/resizer/source/portrait.gif index 9de1b0980..5d2876350 100644 Binary files a/tests/fixtures/resizer/source/portrait.gif and b/tests/fixtures/resizer/source/portrait.gif differ diff --git a/tests/fixtures/resizer/source/square.jpg b/tests/fixtures/resizer/source/square.jpg index eea37bc1e..6940483eb 100644 Binary files a/tests/fixtures/resizer/source/square.jpg and b/tests/fixtures/resizer/source/square.jpg differ diff --git a/tests/fixtures/resizer/target/testCrop10x15.gif b/tests/fixtures/resizer/target/testCrop10x15.gif deleted file mode 100644 index 3227ab701..000000000 Binary files a/tests/fixtures/resizer/target/testCrop10x15.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testCrop30x45.gif b/tests/fixtures/resizer/target/testCrop30x45.gif new file mode 100644 index 000000000..79f1abd46 Binary files /dev/null and b/tests/fixtures/resizer/target/testCrop30x45.gif differ diff --git a/tests/fixtures/resizer/target/testReset_testResize0x0_testResizeAutoLandscape1x1.png b/tests/fixtures/resizer/target/testReset_testResize0x0_testResizeAutoLandscape1x1.png index 14893fd8e..77db38b6b 100644 Binary files a/tests/fixtures/resizer/target/testReset_testResize0x0_testResizeAutoLandscape1x1.png and b/tests/fixtures/resizer/target/testReset_testResize0x0_testResizeAutoLandscape1x1.png differ diff --git a/tests/fixtures/resizer/target/testResize0x20.gif b/tests/fixtures/resizer/target/testResize0x20.gif deleted file mode 100644 index 8b6014585..000000000 Binary files a/tests/fixtures/resizer/target/testResize0x20.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResize0x50.gif b/tests/fixtures/resizer/target/testResize0x50.gif new file mode 100644 index 000000000..163dc8656 Binary files /dev/null and b/tests/fixtures/resizer/target/testResize0x50.gif differ diff --git a/tests/fixtures/resizer/target/testResize20x0.gif b/tests/fixtures/resizer/target/testResize20x0.gif deleted file mode 100644 index 9540a3514..000000000 Binary files a/tests/fixtures/resizer/target/testResize20x0.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResize50x0.gif b/tests/fixtures/resizer/target/testResize50x0.gif new file mode 100644 index 000000000..a1881a317 Binary files /dev/null and b/tests/fixtures/resizer/target/testResize50x0.gif differ diff --git a/tests/fixtures/resizer/target/testResizeAutoExifRotated30x30.jpg b/tests/fixtures/resizer/target/testResizeAutoExifRotated30x30.jpg deleted file mode 100644 index 96a901283..000000000 Binary files a/tests/fixtures/resizer/target/testResizeAutoExifRotated30x30.jpg and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeAutoLandscape100x1.png b/tests/fixtures/resizer/target/testResizeAutoLandscape100x1.png new file mode 100644 index 000000000..b13ff268f Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeAutoLandscape100x1.png differ diff --git a/tests/fixtures/resizer/target/testResizeAutoLandscape125.png b/tests/fixtures/resizer/target/testResizeAutoLandscape125.png new file mode 100644 index 000000000..71ef06b67 Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeAutoLandscape125.png differ diff --git a/tests/fixtures/resizer/target/testResizeAutoLandscape1x5.png b/tests/fixtures/resizer/target/testResizeAutoLandscape1x5.png deleted file mode 100644 index 7104eae66..000000000 Binary files a/tests/fixtures/resizer/target/testResizeAutoLandscape1x5.png and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeAutoLandscape1x50.png b/tests/fixtures/resizer/target/testResizeAutoLandscape1x50.png new file mode 100644 index 000000000..be0ed2b7f Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeAutoLandscape1x50.png differ diff --git a/tests/fixtures/resizer/target/testResizeAutoLandscape25.png b/tests/fixtures/resizer/target/testResizeAutoLandscape25.png deleted file mode 100644 index 009b6ec22..000000000 Binary files a/tests/fixtures/resizer/target/testResizeAutoLandscape25.png and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeAutoLandscape25x1.png b/tests/fixtures/resizer/target/testResizeAutoLandscape25x1.png deleted file mode 100644 index 009b6ec22..000000000 Binary files a/tests/fixtures/resizer/target/testResizeAutoLandscape25x1.png and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeAutoPortrait50.gif b/tests/fixtures/resizer/target/testResizeAutoPortrait50.gif index 74b53003e..163dc8656 100644 Binary files a/tests/fixtures/resizer/target/testResizeAutoPortrait50.gif and b/tests/fixtures/resizer/target/testResizeAutoPortrait50.gif differ diff --git a/tests/fixtures/resizer/target/testResizeAutoSquare25x50_testResizeAutoSquare50x25_testResizeAutoSquare50x50_testResizeFitSquare50x50.jpg b/tests/fixtures/resizer/target/testResizeAutoSquare25x50_testResizeAutoSquare50x25_testResizeAutoSquare50x50_testResizeFitSquare50x50.jpg deleted file mode 100644 index d2c6fd58c..000000000 Binary files a/tests/fixtures/resizer/target/testResizeAutoSquare25x50_testResizeAutoSquare50x25_testResizeAutoSquare50x50_testResizeFitSquare50x50.jpg and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeAutoSquare50x100_testResizeAutoSquare100x50_testResizeAutoSquare100x100_testResizeFitSquare100x100.jpg b/tests/fixtures/resizer/target/testResizeAutoSquare50x100_testResizeAutoSquare100x50_testResizeAutoSquare100x100_testResizeFitSquare100x100.jpg new file mode 100644 index 000000000..1f5941b86 Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeAutoSquare50x100_testResizeAutoSquare100x50_testResizeAutoSquare100x100_testResizeFitSquare100x100.jpg differ diff --git a/tests/fixtures/resizer/target/testResizeExact10x15.jpg b/tests/fixtures/resizer/target/testResizeExact10x15.jpg deleted file mode 100644 index b18745587..000000000 Binary files a/tests/fixtures/resizer/target/testResizeExact10x15.jpg and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeExact50x75.jpg b/tests/fixtures/resizer/target/testResizeExact50x75.jpg new file mode 100644 index 000000000..4fd8bd080 Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeExact50x75.jpg differ diff --git a/tests/fixtures/resizer/target/testResizeFitLandscape150x150.png b/tests/fixtures/resizer/target/testResizeFitLandscape150x150.png new file mode 100644 index 000000000..be0ed2b7f Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeFitLandscape150x150.png differ diff --git a/tests/fixtures/resizer/target/testResizeFitLandscape30x30.png b/tests/fixtures/resizer/target/testResizeFitLandscape30x30.png deleted file mode 100644 index 7104eae66..000000000 Binary files a/tests/fixtures/resizer/target/testResizeFitLandscape30x30.png and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeFitPortrait150x150.gif b/tests/fixtures/resizer/target/testResizeFitPortrait150x150.gif new file mode 100644 index 000000000..134aae42b Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeFitPortrait150x150.gif differ diff --git a/tests/fixtures/resizer/target/testResizeFitPortrait30x30.gif b/tests/fixtures/resizer/target/testResizeFitPortrait30x30.gif deleted file mode 100644 index 62ea09c0c..000000000 Binary files a/tests/fixtures/resizer/target/testResizeFitPortrait30x30.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeLandscape100x1.gif b/tests/fixtures/resizer/target/testResizeLandscape100x1.gif new file mode 100644 index 000000000..e3fc1a208 Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeLandscape100x1.gif differ diff --git a/tests/fixtures/resizer/target/testResizeLandscape10x1.gif b/tests/fixtures/resizer/target/testResizeLandscape10x1.gif deleted file mode 100644 index 30565a41e..000000000 Binary files a/tests/fixtures/resizer/target/testResizeLandscape10x1.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizePortrait1x10.gif b/tests/fixtures/resizer/target/testResizePortrait1x10.gif deleted file mode 100644 index 9055138bd..000000000 Binary files a/tests/fixtures/resizer/target/testResizePortrait1x10.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizePortrait1x100.gif b/tests/fixtures/resizer/target/testResizePortrait1x100.gif new file mode 100644 index 000000000..8f4387207 Binary files /dev/null and b/tests/fixtures/resizer/target/testResizePortrait1x100.gif differ diff --git a/tests/fixtures/resizer/target/testResizeSaveBackgroundColor32x32.gif b/tests/fixtures/resizer/target/testResizeSaveBackgroundColor32x32.gif deleted file mode 100644 index 2ac7b69b4..000000000 Binary files a/tests/fixtures/resizer/target/testResizeSaveBackgroundColor32x32.gif and /dev/null differ diff --git a/tests/fixtures/resizer/target/testResizeSaveBackgroundColor75x75.gif b/tests/fixtures/resizer/target/testResizeSaveBackgroundColor75x75.gif new file mode 100644 index 000000000..1917e9163 Binary files /dev/null and b/tests/fixtures/resizer/target/testResizeSaveBackgroundColor75x75.gif differ diff --git a/tests/fixtures/resizer/target/testSharpen.jpg b/tests/fixtures/resizer/target/testSharpen.jpg index c7c93eb51..630b009d4 100644 Binary files a/tests/fixtures/resizer/target/testSharpen.jpg and b/tests/fixtures/resizer/target/testSharpen.jpg differ diff --git a/tests/fixtures/svg/extracted/winter-dirty.svg b/tests/fixtures/svg/extracted/winter-dirty.svg new file mode 100644 index 000000000..31a09caba --- /dev/null +++ b/tests/fixtures/svg/extracted/winter-dirty.svg @@ -0,0 +1 @@ + link diff --git a/tests/fixtures/svg/extracted/winter.svg b/tests/fixtures/svg/extracted/winter.svg new file mode 100644 index 000000000..6386f7eb1 --- /dev/null +++ b/tests/fixtures/svg/extracted/winter.svg @@ -0,0 +1 @@ + diff --git a/tests/fixtures/svg/winter-dirty.svg b/tests/fixtures/svg/winter-dirty.svg new file mode 100644 index 000000000..55656a44c --- /dev/null +++ b/tests/fixtures/svg/winter-dirty.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + shouldn't be here + + + + + + link + + + diff --git a/tests/fixtures/svg/winter.svg b/tests/fixtures/svg/winter.svg new file mode 100644 index 000000000..6c7ddced0 --- /dev/null +++ b/tests/fixtures/svg/winter.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/tmp/.gitignore b/tests/tmp/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/tests/tmp/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore