Compare commits

..

No commits in common. "main" and "tests" have entirely different histories.
main ... tests

23 changed files with 183 additions and 851 deletions

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
workspace.code-workspace
vendor
node_modules/
.vscode
auth.json
composer.lock

View file

@ -1,99 +1,80 @@
# Changelog
All notable changes to this project will be documented in this file.
All notable changes to `admin-secure-laravel` will be documented in this file.
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
- Commits respect [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) & use [Gitmoji](https://gitmoji.dev/).
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
- Commits respect [Conventionnal commits](https://www.conventionalcommits.org/en/v1.0.0/) & use [Gitmoji](https://gitmoji.dev/).
## [1.0.3] - 2026-11-04
## **[v5.1.0] - 17/04/2026**
### 🚀 Features
### Added
- *(StorageServiceConfig)* Support scoped driver with boot-time underlying driver resolution
- chore: :arrow_up: Added Laravel 13 compatibility
### 🎨 Styling
## **[v5.0.0] - 09/01/2026**
- *(tests)* Fix spacing in docblock for allowedExtensions
### Added
## [1.0.2] - 2026-11-04
- feat: ✨ Remove LDC Web API dependency
### 🚀 Features
## **[v4.0.0] - 07/04/2025**
- *(StorageServiceConfig)* Add prefix handling and validation
### Added
### ⚙️ Miscellaneous Tasks
- feat: ✨ add Laravel 12 compatibility
- *(cliff.toml)* Add spacing before release headings in changelog
## **[v3.0.0] - 03/09/2024**
## [1.0.1] - 2026-11-04
### Added
### 🚀 Features
- Add Laravel 11 compatibility
- Bump deps for Laravel 11
- *(StorageFilePath)* Implement JsonSerializable interface
### Changed
### ⚙️ Miscellaneous Tasks
- Change namespace for UWIL complience
- Update .gitignore and add bun.lock and cliff.toml
- *(cliff.toml)* Skip changelog commits in parser
## **[v2.0.3] - 04/04/2024**
## [1.0.0] - 2026-11-03
### Added
### 🚀 Features
- Add staging to environnements where sync command should be executed
- *(Facades)* Add StorageService facade with methods for file operations
- *(FileSanitizerImpl)* Add mimeType and size to SanitizedFile
- *(StorageFilePath)* Implement Stringable interface
- *(StorageService)* Add methods to store files using SplFileObject
- *(MimeTypeChecker)* Add unsupported MIME types for SVG and XML
- *(tests)* Add initial test structure and example tests
### Changed
### 🐛 Bug Fixes
- PHPCS and PHPSTAN fixes
- *(FileStream)* Improve stream validation in constructor
- *(FileStream)* Handle resource creation failure in constructor
## **[v2.0.2] - 19/09/2024**
### 🚜 Refactor
### Changed
- *(DiskName)* Implement Stringable interface
- *(StorageGatewayImpl)* Specify return type for getStorageForDisk
- *(Exceptions)* Format exception classes for consistency
- *(Utils, DiskName)* Improve disk handling with enum support
- *(SanitizedFile)* Remove unused Utils methods
- *(StorageService)* Change remove methods to return bool
- *(FileStream)* Ensure resource is closed in destructor
- *(exceptions)* Replace StorageException with UnexpectedStorageException
- *(StorageServiceConfig)* Rename getEligiblesDisks to getEligibleDisks
- *(StorageServiceProvider)* Use Arr::string for driver assignment
- *(StorageServiceProvider)* Improve disk configuration handling
- *(Utils)* Remove unused Facade import and redundant check
- fixed artisan command to fetch list
### 🎨 Styling
## **[v2.0.1] - 19/09/2023**
- *(PathGenerators)* Format code for consistency
- *(StorageService)* Disable line length check for facade comments
- *(StorageService)* Update phpcs line length disable comment
### Changed
### 🧪 Testing
- Delete API key
- Artisan config cache fix
- Cache was ignored
- Add unit tests for DiskName, FileStream, and SanitizedFile
- *(DateHashPathGenerator)* Add unit tests for path generation
- *(FileStream)* Skip test for chmod 0000 in superuser environments
- *(StorageServiceConfig)* Add unit tests for configuration validation
- *(FileSanitizerImpl)* Add tests for file sanitization errors
- *(Utils)* Add unit tests for disk existence and extension validation
- *(MimeTypeCheckerImpl)* Ignore type argument for PHPStan
- *(FileSanitizerImpl)* Add PHPDoc for allowedExtensions parameter
- *(MimeTypeCheckerImpl)* Ignore type argument for PHPStan
- *(StorageGatewayImpl)* Add unit tests for file storage operations
- *(StorageServiceImpl)* Add unit tests for file operations
## **[v2.0.0] - 05/06/2023**
### ⚙️ Miscellaneous Tasks
### Added
- Remove FileStorageHelper class
- Update PHP CodeSniffer and add Pint configuration
- Add .gitattributes for line endings and export-ignore rules
- Update phpcs and phpstan configurations
- *(tests)* Add .gitkeep files for Feature and Integration directories
- *(dependencies)* Update phpstan-mockery version in composer.json
- Bump laravel 10
- Add manual Cird possibility
### Changed
- Allows connections from private ip ranges
- Add posibility to config manual CIDR exceptions
## **[v1.0.1] - 04/07/2022**
- Run scheduled only on production envirronement
## **[v1.0.0] - 24/06/2022**
- Initial release

View file

@ -1,73 +0,0 @@
{
"lockfileVersion": 2,
"configVersion": 1,
"workspaces": {
"": {
"name": "storage-laravel",
"devDependencies": {
"git-cliff": "^2.14.2",
},
},
},
"packages": {
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
"git-cliff": ["git-cliff@2.14.2", "", { "dependencies": { "execa": "^9.6.0" }, "optionalDependencies": { "git-cliff-darwin-arm64": "2.14.2", "git-cliff-darwin-x64": "2.14.2", "git-cliff-linux-arm64": "2.14.2", "git-cliff-linux-x64": "2.14.2", "git-cliff-windows-arm64": "2.14.2", "git-cliff-windows-x64": "2.14.2" }, "bin": "lib/cli/cli.js" }, "sha512-hlXIq4k2Tdj9C59WsD7FsLE0xRGFDozTUuoeqg9toMyVohaVxXiSiLnhM/IQyCmhOh5M4zuNckr2o8qMeuffbg=="],
"git-cliff-darwin-arm64": ["git-cliff-darwin-arm64@2.14.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Ylk39hv5flEUrXeoGvH6M3WjHBNERFH7a9DVEcGU2a8Q7a7fPtVasNk1fMuJ7CVbZCRIzUNyQV0ejRpxBdFnag=="],
"git-cliff-darwin-x64": ["git-cliff-darwin-x64@2.14.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-3FZNm0BGCIim/3G6uHSWAakrCaGmC47lcI+Cob9CZPMBYHKrenYk0i6B8MlObSXr2Lr7Ve1BpGbd/foQg4NOiA=="],
"git-cliff-linux-arm64": ["git-cliff-linux-arm64@2.14.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3oMDpoyfsy2angD9ulcCBtyWCkzZqN3spmmtb0jic8OKAu1p+CALS8oep7J8AdgkDojuLo6bdHiSZIM9PeTxmQ=="],
"git-cliff-linux-x64": ["git-cliff-linux-x64@2.14.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4EaBYwBiCupECZ2rzC6CelNSGYvkvtJeja4wXLDGtfKnRhCN/WNoqbM+hvrtm32uf5qovfoNoDA906F8itw+oQ=="],
"git-cliff-windows-arm64": ["git-cliff-windows-arm64@2.14.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-0oc0xD/6A+BvInz8imBJulXVOlR9M0nL+d3ibL0lwJvxUVTMQzvoCnsn9OxpsdzXrEisnTvaf+GExoKVJkUcDw=="],
"git-cliff-windows-x64": ["git-cliff-windows-x64@2.14.2", "", { "os": "win32", "cpu": "x64" }, "sha512-RFmCMiwGMU8O+rF//wxT7L10ssoDtzI+QFxlbjpA8E1BAEafkmLTQkKalT88ipEweD46P+Sfc20v8RXuE7V/UA=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
}
}

View file

@ -1,102 +0,0 @@
# git-cliff ~ configuration file
# https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog
All notable changes to this project will be documented in this file.
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
- Commits respect [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) & use [Gitmoji](https://gitmoji.dev/).
"""
# Used by `--prepend` to remove the previous header.
header_marker = "<!-- git-cliff: end of header -->"
# A Tera template to be rendered for each release in the changelog.
# See https://keats.github.io/tera/#getting-started
body = """\
{% if version %}\
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for entry in commits | commit_groups(groups=commit_parsers_groups) %}
### {{ entry.group | striptags | trim | upper_first }}
{% for commit in entry.commits %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}[**breaking**] {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}
"""
# Remove leading and trailing whitespaces from the changelog's body.
trim = false
# Render body even when there are no releases to process.
render_always = true
postprocessors = []
# render body even when there are no releases to process
# render_always = true
# output file path
# output = "test.md"
[git]
# Parse commits according to the conventional commits specification.
# See https://www.conventionalcommits.org
conventional_commits = true
# Exclude commits that do not match the conventional commits specification.
filter_unconventional = true
# Require all commits to be conventional.
# Takes precedence over filter_unconventional.
require_conventional = false
# Split commits on newlines, treating each line as an individual commit.
split_commits = false
# An array of regex based parsers to modify commit messages prior to further processing.
commit_preprocessors = [
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit message using https://github.com/crate-ci/typos.
# If the spelling is incorrect, it will be fixed automatically.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# Prevent commits that are breaking from being excluded by commit parsers.
protect_breaking_commits = false
# An array of regex based parsers for extracting data from the commit message.
# Assigns commits to groups.
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", skip = true },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore\\(changelog\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
{ message = ".*", group = "<!-- 10 -->💼 Other" },
]
# Exclude commits that are not matched by any commit parser.
filter_commits = false
# Fail on a commit that is not matched by any commit parser.
fail_on_unmatched_commit = false
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
link_parsers = []
# Include only the tags that belong to the current branch.
use_branch_tags = false
# Order releases topologically instead of chronologically.
topo_order = false
# Order commits topologically instead of chronologically.
topo_order_commits = true
# Order of commits in each group/release within the changelog.
# Allowed values: newest, oldest
sort_commits = "oldest"
# Process submodules commits
recurse_submodules = false

View file

@ -2,7 +2,7 @@
"name": "uwil/storage-laravel",
"description": "Handles file storage",
"license": "MIT",
"version": "1.0.3",
"version": "1.0.0",
"readme": "https://lab.s-web.fr/ldc-web/projets/storage-laravel/-/blob/main/README.md",
"support": {
"email": "digital@ldc.fr",

View file

@ -9,7 +9,5 @@ return [
'local',
],
'prefix' => env('STORAGE_SERVICE_PREFIX', 'media'),
'allowedExtensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'doc', 'docx', 'csv', 'mp4', 'webm'],
];

View file

@ -1,12 +0,0 @@
{
"name": "file-storage-service",
"version": "1.0.0",
"private": true,
"scripts": {
"changelog": "bunx git-cliff -o CHANGELOG.md",
"changelog:conventional": "bunx conventional-changelog-cli -p conventionalcommits -i CHANGELOG.md -s -r 0"
},
"devDependencies": {
"git-cliff": "^2.14.2"
}
}

View file

@ -17,11 +17,6 @@ final class StorageServiceConfig
/** @var DiskName[] */
private array $eligibles;
private ?string $prefix = null;
/** @var array<string, string> Laravel disk name => physical driver (scoped disks resolved to their parent) */
private array $realDrivers = [];
/**
* @param string[] $eligibleDisks
* @param object{'disk':string,'driver':string}[] $laravelExistingDisks
@ -31,61 +26,8 @@ final class StorageServiceConfig
private string $defaultDisk,
private array $eligibleDisks,
private array $laravelExistingDisks,
private array $allowedExtensions,
?string $prefix = null
private array $allowedExtensions
) {
$this->validateAndSetDisks();
$this->validateExtensions();
$this->prefix = $this->parsePrefix($prefix);
}
private function parsePrefix(?string $prefix): ?string
{
if ($prefix === null) {
return null;
}
$trimmed = mb_trim($prefix);
$clean = trim($trimmed, '/');
if ($clean === '') {
return null;
}
if (str_contains($clean, '..')) {
throw new UnexpectedStorageException('Prefix must not contain "..".');
}
if (str_contains($clean, '\\')) {
throw new UnexpectedStorageException('Prefix must not contain "\\".');
}
if (str_contains($clean, '//')) {
throw new UnexpectedStorageException('Prefix must not contain "//".');
}
return $clean;
}
private function validateExtensions(): void
{
if (empty($this->allowedExtensions)) {
throw new UnexpectedStorageException('Allowed extensions cannot be empty');
}
foreach ($this->allowedExtensions as $extension) {
if (! is_string($extension) || empty(mb_trim($extension))) {
throw new UnexpectedStorageException("Allowed extension {$extension} is not valid");
}
if (in_array(strtolower(ltrim($extension, '.')), self::UNSUPPORTED_EXTENSIONS, true)) {
throw new UnexpectedStorageException("Extension '{$extension}' is forbidden: XML/SVG sanitization is currently not supported.");
}
}
}
private function validateAndSetDisks(): void
{
if (Utils::diskExists($this->defaultDisk) === false) {
throw new UnexpectedStorageException('Default disk does not exist');
}
@ -144,19 +86,19 @@ final class StorageServiceConfig
$disk->driver
), $intersectWithDrivers);
$this->resolveRealDrivers();
}
private function resolveRealDrivers(): void
{
foreach ([$this->default, ...$this->eligibles] as $disk) {
$this->realDrivers[$disk->value] = Utils::resolveDriver($disk->value, $disk->driver);
if (empty($this->allowedExtensions)) {
throw new UnexpectedStorageException('Allowed extensions cannot be empty');
}
}
public function getPrefix(): ?string
{
return $this->prefix;
foreach ($this->allowedExtensions as $extension) {
if (! is_string($extension) || empty(mb_trim($extension))) {
throw new UnexpectedStorageException("Allowed extension {$extension} is not valid");
}
if (in_array(strtolower(ltrim($extension, '.')), self::UNSUPPORTED_EXTENSIONS, true)) {
throw new UnexpectedStorageException("Extension '{$extension}' is forbidden: XML/SVG sanitization is currently not supported.");
}
}
}
public function getDefaultDisk(): DiskName
@ -203,15 +145,4 @@ final class StorageServiceConfig
{
return $this->allowedExtensions;
}
public function getRealDriver(DiskName $disk): string
{
return $this->realDrivers[$disk->value]
?? throw new UnexpectedStorageException("Disk {$disk->value} is neither the default nor an eligible disk");
}
public function isS3Driver(DiskName $disk): bool
{
return $this->getRealDriver($disk) === 's3';
}
}

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Uwil\Storage\Lib\PathGenerators;
use Illuminate\Support\Str;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Contracts\PathGenerator;
use Uwil\Storage\Lib\ValueObjects\DiskName;
use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
@ -13,13 +12,8 @@ use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
class DateHashPathGenerator implements PathGenerator
{
public function __construct(
private ?StorageServiceConfig $config = null
) {
}
#[\Override]
public function generate(DiskName $disk, SanitizedFile $file): StorageFilePath
public function generate(DiskName $disk, SanitizedFile $file, ?string $directory = null): StorageFilePath
{
$extension = pathinfo($file->sanitizedName, PATHINFO_EXTENSION);
$filename = pathinfo($file->sanitizedName, PATHINFO_FILENAME);
@ -36,7 +30,7 @@ class DateHashPathGenerator implements PathGenerator
);
$pathSegments = array_filter([
$this->config?->getPrefix(),
$directory ? trim($directory, '/') : null,
gmdate('Y'),
gmdate('m'),
gmdate('d'),

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Uwil\Storage\Lib\PathGenerators;
use Illuminate\Support\Str;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Contracts\PathGenerator;
use Uwil\Storage\Lib\ValueObjects\DiskName;
use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
@ -13,13 +12,8 @@ use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
class UuidPathGenerator implements PathGenerator
{
public function __construct(
private ?StorageServiceConfig $config = null
) {
}
#[\Override]
public function generate(DiskName $disk, SanitizedFile $file): StorageFilePath
public function generate(DiskName $disk, SanitizedFile $file, ?string $directory = null): StorageFilePath
{
$extension = pathinfo($file->sanitizedName, PATHINFO_EXTENSION);
$uuid = Str::uuid()->toString();
@ -27,7 +21,7 @@ class UuidPathGenerator implements PathGenerator
$finalFilename = $uuid . (! empty($extension) ? '.' . $extension : '');
$pathSegments = array_filter([
$this->config?->getPrefix(),
$directory ? trim($directory, '/') : null,
$finalFilename,
]);

View file

@ -7,7 +7,6 @@ namespace Uwil\Storage\Lib;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Psr\Log\LoggerInterface;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Contracts\PathGenerator;
use Uwil\Storage\Contracts\StorageGateway;
use Uwil\Storage\Exceptions\StorageException;
@ -22,7 +21,6 @@ use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
readonly class StorageGatewayImpl implements StorageGateway
{
public function __construct(
private StorageServiceConfig $config,
private LoggerInterface $logger,
private DateHashPathGenerator $datePathGenerator,
private UuidPathGenerator $uuidPathGenerator
@ -125,12 +123,12 @@ readonly class StorageGatewayImpl implements StorageGateway
private function pathGeneratorFor(DiskName $disk): PathGenerator
{
$realDriver = $this->config->getRealDriver($disk);
return match ($realDriver) {
'local', 'ftp', 'sftp' => $this->datePathGenerator,
's3' => $this->uuidPathGenerator,
default => throw new UnexpectedStorageException("Unsupported disk driver: {$realDriver} on disk {$disk->value}"),
return match ($disk->driver) {
'local' => $this->datePathGenerator,
'ftp' => $this->datePathGenerator,
'sftp' => $this->datePathGenerator,
's3' => $this->uuidPathGenerator,
default => throw new UnexpectedStorageException("Unsupported disk driver: {$disk->driver} on disk {$disk->value}"),
};
}

View file

@ -4,10 +4,8 @@ declare(strict_types=1);
namespace Uwil\Storage\Lib;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
class Utils
{
@ -16,45 +14,6 @@ class Utils
// Abstract class.
}
public static function resolveDriver(string $disk, ?string $driver = null): string
{
$currentDisk = $disk;
if ($driver === null) {
/** @var mixed $raw */
$raw = Config::get("filesystems.disks.{$currentDisk}.driver");
$driver = is_string($raw) ? $raw : '';
}
$visited = [$currentDisk];
while ($driver === 'scoped') {
/** @var mixed $parentDisk */
$parentDisk = Config::get("filesystems.disks.{$currentDisk}.disk");
if (! is_string($parentDisk) || $parentDisk === '') {
throw new UnexpectedStorageException("Scoped disk '{$currentDisk}' is missing a valid parent 'disk' configuration.");
}
if (in_array($parentDisk, $visited, true)) {
throw new UnexpectedStorageException("Circular reference detected in scoped disk '{$currentDisk}'.");
}
/** @var mixed $parentDriver */
$parentDriver = Config::get("filesystems.disks.{$parentDisk}.driver");
if (! is_string($parentDriver) || $parentDriver === '') {
throw new UnexpectedStorageException("Parent disk '{$parentDisk}' of scoped disk '{$currentDisk}' has no valid driver configured.");
}
$visited[] = $parentDisk;
$driver = $parentDriver;
$currentDisk = $parentDisk;
}
return $driver;
}
public static function diskExists(string | \UnitEnum $disk): bool
{
try {

View file

@ -9,7 +9,7 @@ use Uwil\Storage\Lib\Utils;
final readonly class DiskName implements \Stringable
{
public const array ALLOWED_DRIVERS = ['local', 'ftp', 'sftp', 's3', 'scoped'];
public const array ALLOWED_DRIVERS = ['local', 'ftp', 'sftp', 's3'];
public string $value;

View file

@ -6,7 +6,7 @@ namespace Uwil\Storage\Lib\ValueObjects;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
final readonly class StorageFilePath implements \JsonSerializable, \Stringable
final readonly class StorageFilePath implements \Stringable
{
public function __construct(
public string $value
@ -36,11 +36,6 @@ final readonly class StorageFilePath implements \JsonSerializable, \Stringable
}
}
public function jsonSerialize(): mixed
{
return $this->value;
}
public function __toString(): string
{
return $this->value;

View file

@ -51,9 +51,6 @@ class StorageServiceProvider extends ServiceProvider
fn ($ext) => is_string($ext)
);
$rawPrefix = Config::get('storage-service.prefix');
$prefix = is_string($rawPrefix) ? $rawPrefix : null;
$disks = [];
foreach ($rawDisks as $name => $diskConf) {
$disks[] = (object) [
@ -66,8 +63,7 @@ class StorageServiceProvider extends ServiceProvider
Config::string('storage-service.default', 'local'),
$eligibleDisks,
$disks,
$allowedExtensions,
$prefix
$allowedExtensions
);
}
);
@ -75,24 +71,9 @@ class StorageServiceProvider extends ServiceProvider
$this->app->bind(MimeTypeChecker::class, MimeTypeCheckerImpl::class);
$this->app->bind(FileSanitizer::class, FileSanitizerImpl::class);
$this->app->bind(
DateHashPathGenerator::class,
fn (Application $app) => new DateHashPathGenerator(
$app->make(StorageServiceConfig::class)
)
);
$this->app->bind(
UuidPathGenerator::class,
fn (Application $app) => new UuidPathGenerator(
$app->make(StorageServiceConfig::class)
)
);
$this->app->bind(
StorageGateway::class,
fn (Application $app) => new StorageGatewayImpl(
$app->make(StorageServiceConfig::class),
$app->make(LoggerInterface::class),
$app->make(DateHashPathGenerator::class),
$app->make(UuidPathGenerator::class),

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Config;
use Illuminate\Support\Facades\Storage;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
use Uwil\Storage\Lib\ValueObjects\DiskName;
@ -355,158 +354,3 @@ describe('StorageServiceConfig isExtensionAllowed', function (): void {
'.svg' => '.svg',
]);
});
describe('StorageServiceConfig prefix validation and retrieval', function (): void {
it('returns null when prefix is omitted or null', function (?string $prefix): void {
// GIVEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['pdf'],
prefix: $prefix
);
// THEN
expect($config->getPrefix())->toBeNull();
})->with([
'null' => null,
'empty' => '',
'spaces' => ' ',
'slashes' => '///',
]);
it('trims leading and trailing slashes and spaces from prefix', function (string $input, string $expected): void {
// GIVEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['pdf'],
prefix: $input
);
// THEN
expect($config->getPrefix())->toBe($expected);
})->with([
'simple' => ['media', 'media'],
'leading slash' => ['/media', 'media'],
'trailing slash' => ['media/', 'media'],
'both slashes' => ['/media/', 'media'],
'nested' => ['/uploads/images/', 'uploads/images'],
'with spaces' => [' media ', 'media'],
]);
it('throws exception if prefix contains invalid sequences', function (string $invalidPrefix): void {
// GIVEN
$action = fn () => new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['pdf'],
prefix: $invalidPrefix
);
// THEN
expect($action)->toThrow(UnexpectedStorageException::class);
})->with([
'dot dot' => '../media',
'nested dot dot' => 'media/../sub',
'backslash' => 'media\\sub',
'double slash' => 'media//sub',
]);
});
describe('StorageServiceConfig real driver resolution', function (): void {
it('resolves direct drivers of default and eligible disks', function (): void {
// GIVEN
Storage::fake('s3');
// WHEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local', 's3'],
laravelExistingDisks: [
(object) ['disk' => 'local', 'driver' => 'local'],
(object) ['disk' => 's3', 'driver' => 's3'],
],
allowedExtensions: ['pdf']
);
$localDisk = new DiskName('local', 'local');
$s3Disk = new DiskName('s3', 's3');
// THEN
expect($config->getRealDriver($localDisk))->toBe('local')
->and($config->isS3Driver($localDisk))->toBeFalse()
->and($config->getRealDriver($s3Disk))->toBe('s3')
->and($config->isS3Driver($s3Disk))->toBeTrue();
});
it('resolves the underlying driver of a scoped disk at boot', function (): void {
// GIVEN
config()->set('filesystems.disks.s3_parent', ['driver' => 's3']);
config()->set('filesystems.disks.media', [
'driver' => 'scoped',
'disk' => 's3_parent',
'prefix' => 'media',
]);
Storage::fake('media');
// WHEN
$config = new StorageServiceConfig(
defaultDisk: 'media',
eligibleDisks: ['media'],
laravelExistingDisks: [(object) ['disk' => 'media', 'driver' => 'scoped']],
allowedExtensions: ['pdf']
);
$media = new DiskName('media', 'scoped');
// THEN
expect($config->getRealDriver($media))->toBe('s3')
->and($config->isS3Driver($media))->toBeTrue();
});
it('fails at boot when a scoped disk is misconfigured', function (): void {
// GIVEN
config()->set('filesystems.disks.broken', ['driver' => 'scoped']);
Storage::fake('broken');
// WHEN
$action = fn () => new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local', 'broken'],
laravelExistingDisks: [
(object) ['disk' => 'local', 'driver' => 'local'],
(object) ['disk' => 'broken', 'driver' => 'scoped'],
],
allowedExtensions: ['pdf']
);
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Scoped disk 'broken' is missing a valid parent 'disk' configuration."
);
});
it('throws when asking the driver of a disk that is neither default nor eligible', function (): void {
// GIVEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['pdf']
);
Storage::fake('other');
$other = new DiskName('other', 'local');
// WHEN
$action = fn () => $config->getRealDriver($other);
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
'Disk other is neither the default nor an eligible disk'
);
});
});

View file

@ -14,7 +14,7 @@ use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
covers(FileSanitizerImpl::class);
/**
* @param array<string> $allowedExtensions
* @param array<string> $allowedExtensions
*/
function createConfig(array $allowedExtensions = ['jpg', 'png', 'pdf', 'txt']): StorageServiceConfig
{

View file

@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib\PathGenerators;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Lib\PathGenerators\DateHashPathGenerator;
use Uwil\Storage\Lib\ValueObjects\DiskName;
use Uwil\Storage\Lib\ValueObjects\FileStream;
@ -92,26 +91,20 @@ describe('DateHashPathGenerator generation without directory', function (): void
});
});
describe('DateHashPathGenerator generation with prefix', function (): void {
it('prepends the configured prefix to the path', function (): void {
describe('DateHashPathGenerator generation with directory', function (): void {
it('prepends the given directory to the path', function (): void {
// GIVEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['png'],
prefix: 'avatars/users'
);
$generator = new DateHashPathGenerator($config);
$generator = new DateHashPathGenerator();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'avatar.png',
sanitizedName: 'avatar.png',
contents: FileStream::fromString('image data')
);
$directory = 'avatars/users';
// WHEN
$result = $generator->generate($disk, $sanitizedFile);
$result = $generator->generate($disk, $sanitizedFile, $directory);
// THEN
$expectedHash = substr(hash('sha256', 'avatar.png'), 0, 2);
@ -124,6 +117,49 @@ describe('DateHashPathGenerator generation with prefix', function (): void {
);
expect($result->value)->toMatch($pattern);
});
it('trims leading and trailing slashes from the directory', function (string $directory): void {
// GIVEN
$generator = new DateHashPathGenerator();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'file.txt',
sanitizedName: 'file.txt',
contents: FileStream::fromString('txt')
);
// WHEN
$result = $generator->generate($disk, $sanitizedFile, $directory);
// THEN
expect($result->value)->toStartWith('uploads/archive/');
})->with([
'leading slash' => '/uploads/archive',
'trailing slash' => 'uploads/archive/',
'both slashes' => '/uploads/archive/',
'multiple outer slashes' => '///uploads/archive///',
]);
it('ignores empty or slash-only directory', function (string $directory): void {
// GIVEN
$generator = new DateHashPathGenerator();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'report.pdf',
sanitizedName: 'report.pdf',
contents: FileStream::fromString('pdf')
);
// WHEN
$result = $generator->generate($disk, $sanitizedFile, $directory);
// THEN
expect($result->value)->toStartWith(gmdate('Y') . '/');
})->with([
'empty string' => '',
'single slash' => '/',
'multi slashes' => '///',
]);
});
describe('DateHashPathGenerator filename slugging', function (): void {

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib\PathGenerators;
use Illuminate\Support\Str;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Lib\PathGenerators\UuidPathGenerator;
use Uwil\Storage\Lib\ValueObjects\DiskName;
use Uwil\Storage\Lib\ValueObjects\FileStream;
@ -75,30 +74,67 @@ describe('UuidPathGenerator generation without directory', function (): void {
});
});
describe('UuidPathGenerator generation with prefix', function (): void {
it('prepends the configured prefix to the generated UUID filename', function (): void {
describe('UuidPathGenerator generation with directory', function (): void {
it('prepends the given directory to the generated UUID filename', function (): void {
// GIVEN
$config = new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local'],
laravelExistingDisks: [(object) ['disk' => 'local', 'driver' => 'local']],
allowedExtensions: ['pdf'],
prefix: 'billing/invoices'
);
$generator = new UuidPathGenerator($config);
$generator = new UuidPathGenerator();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'invoice.pdf',
sanitizedName: 'invoice.pdf',
contents: FileStream::fromString('PDF DATA')
);
$directory = 'billing/invoices';
// WHEN
$result = $generator->generate($disk, $sanitizedFile);
$result = $generator->generate($disk, $sanitizedFile, $directory);
// THEN
expect($result)->toBeInstanceOf(StorageFilePath::class)
->and($result->value)->toStartWith('billing/invoices/')
->and($result->value)->toMatch('#^billing/invoices/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.pdf$#');
});
it('trims leading and trailing slashes from the directory', function (string $directory): void {
// GIVEN
$generator = new UuidPathGenerator();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'file.txt',
sanitizedName: 'file.txt',
contents: FileStream::fromString('text')
);
// WHEN
$result = $generator->generate($disk, $sanitizedFile, $directory);
// THEN
expect($result->value)->toStartWith('custom/path/');
})->with([
'leading slash' => '/custom/path',
'trailing slash' => 'custom/path/',
'both slashes' => '/custom/path/',
'multiple outer slashes' => '///custom/path///',
]);
it('ignores empty or slash-only directory', function (string $directory): void {
// GIVEN
$generator = new UuidPathGenerator();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'file.txt',
sanitizedName: 'file.txt',
contents: FileStream::fromString('text')
);
// WHEN
$result = $generator->generate($disk, $sanitizedFile, $directory);
// THEN
expect($result->value)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.txt$#');
})->with([
'empty string' => '',
'single slash' => '/',
'multi slashes' => '///',
]);
});

View file

@ -4,11 +4,8 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
use Uwil\Storage\Lib\PathGenerators\DateHashPathGenerator;
use Uwil\Storage\Lib\PathGenerators\UuidPathGenerator;
@ -17,36 +14,14 @@ use Uwil\Storage\Lib\ValueObjects\DiskName;
use Uwil\Storage\Lib\ValueObjects\FileStream;
use Uwil\Storage\Lib\ValueObjects\SanitizedFile;
use Uwil\Storage\Lib\ValueObjects\StorageFilePath;
use Illuminate\Contracts\Filesystem\Filesystem;
use Psr\Log\LoggerInterface;
covers(StorageGatewayImpl::class);
/**
* @param array<string, string> $extraDisks eligible disks to declare in addition to `local` (name => driver)
*/
function createGatewayConfig(array $extraDisks = []): StorageServiceConfig
{
$existing = [(object) ['disk' => 'local', 'driver' => 'local']];
foreach ($extraDisks as $name => $driver) {
Storage::fake($name);
$existing[] = (object) ['disk' => $name, 'driver' => $driver];
}
return new StorageServiceConfig(
defaultDisk: 'local',
eligibleDisks: ['local', ...array_keys($extraDisks)],
laravelExistingDisks: $existing,
allowedExtensions: ['txt']
);
}
/**
* @param array<string, string> $extraDisks
*/
function createGateway(array $extraDisks = []): StorageGatewayImpl
function createGateway(): StorageGatewayImpl
{
return new StorageGatewayImpl(
config: createGatewayConfig($extraDisks),
logger: new NullLogger(),
datePathGenerator: new DateHashPathGenerator(),
uuidPathGenerator: new UuidPathGenerator()
@ -77,7 +52,7 @@ describe('StorageGatewayImpl storeFile', function (): void {
it('stores a file onto an s3 disk using UuidPathGenerator', function (): void {
// GIVEN
Storage::fake('s3');
$gateway = createGateway(['s3' => 's3']);
$gateway = createGateway();
$disk = new DiskName('s3', 's3');
$sanitizedFile = new SanitizedFile(
originalName: 'picture.png',
@ -99,9 +74,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(false);
$gateway = createGateway();
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -121,9 +96,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andThrow(new \RuntimeException('Network drop'));
$gateway = createGateway();
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -144,9 +119,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andThrow($directException);
$gateway = createGateway();
Storage::shouldReceive('disk')->with('local')->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName('local', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -168,9 +143,9 @@ describe('StorageGatewayImpl storeFile', function (): void {
config()->set("filesystems.disks.{$driver}", ['driver' => $driver]);
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(true);
$gateway = createGateway([$driver => $driver]);
Storage::shouldReceive('disk')->with($driver)->andReturn($mockStorage);
$gateway = createGateway();
$disk = new DiskName($driver, $driver);
$sanitizedFile = new SanitizedFile(
originalName: 'test.txt',
@ -188,83 +163,6 @@ describe('StorageGatewayImpl storeFile', function (): void {
'ftp driver' => 'ftp',
'sftp driver' => 'sftp',
]);
it('stores a file onto a scoped disk resolving underlying s3 driver', function (): void {
// GIVEN
config()->set('filesystems.disks.s3', ['driver' => 's3']);
config()->set('filesystems.disks.scoped_s3', [
'driver' => 'scoped',
'disk' => 's3',
'prefix' => 'media',
]);
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(true);
$gateway = createGateway(['scoped_s3' => 'scoped']);
Storage::shouldReceive('disk')->with('scoped_s3')->andReturn($mockStorage);
$disk = new DiskName('scoped_s3', 'scoped');
$sanitizedFile = new SanitizedFile(
originalName: 'photo.jpg',
sanitizedName: 'photo.jpg',
contents: FileStream::fromString('image bytes')
);
// WHEN
$path = $gateway->storeFile($disk, $sanitizedFile);
// THEN
expect($path)->toBeInstanceOf(StorageFilePath::class)
->and($path->value)->toMatch('#^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$#');
});
it('stores a file onto a scoped disk resolving underlying local driver', function (): void {
// GIVEN
config()->set('filesystems.disks.local_parent', ['driver' => 'local']);
config()->set('filesystems.disks.scoped_local', [
'driver' => 'scoped',
'disk' => 'local_parent',
'prefix' => 'uploads',
]);
$mockStorage = \Mockery::mock(Filesystem::class);
$mockStorage->shouldReceive('put')->andReturn(true);
$gateway = createGateway(['scoped_local' => 'scoped']);
Storage::shouldReceive('disk')->with('scoped_local')->andReturn($mockStorage);
$disk = new DiskName('scoped_local', 'scoped');
$sanitizedFile = new SanitizedFile(
originalName: 'doc.pdf',
sanitizedName: 'doc.pdf',
contents: FileStream::fromString('pdf data')
);
// WHEN
$path = $gateway->storeFile($disk, $sanitizedFile);
// THEN
expect($path)->toBeInstanceOf(StorageFilePath::class)
->and($path->value)->toStartWith(gmdate('Y') . '/');
});
it('rejects a disk that is neither default nor eligible', function (): void {
// GIVEN
$gateway = createGateway();
Storage::fake('unknown');
$disk = new DiskName('unknown', 'local');
$sanitizedFile = new SanitizedFile(
originalName: 'doc.pdf',
sanitizedName: 'doc.pdf',
contents: FileStream::fromString('data')
);
// WHEN
$action = fn () => $gateway->storeFile($disk, $sanitizedFile);
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
'Disk unknown is neither the default nor an eligible disk'
);
});
});
describe('StorageGatewayImpl getFile', function (): void {
@ -422,7 +320,6 @@ describe('StorageGatewayImpl logging', function (): void {
$logger->shouldReceive('info')->with(\Mockery::pattern('/^File deletion result for disk:.*: success$/'))->once();
$gateway = new StorageGatewayImpl(
config: createGatewayConfig(),
logger: $logger,
datePathGenerator: new DateHashPathGenerator(),
uuidPathGenerator: new UuidPathGenerator()
@ -449,7 +346,6 @@ describe('StorageGatewayImpl logging', function (): void {
$logger->shouldReceive('debug')->with('File not found on disk: local, path: not_found.txt')->once();
$gateway = new StorageGatewayImpl(
config: createGatewayConfig(),
logger: $logger,
datePathGenerator: new DateHashPathGenerator(),
uuidPathGenerator: new UuidPathGenerator()

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Uwil\Storage\Tests\Unit\Lib;
use Uwil\Storage\Config\StorageServiceConfig;
use Uwil\Storage\Exceptions\UnexpectedStorageException;
use Uwil\Storage\Lib\Utils;
covers(Utils::class);
@ -100,108 +99,3 @@ describe('Utils isExtensionAllowed', function (): void {
expect($fallback)->toBeTrue();
});
});
describe('Utils resolveDriver', function (): void {
it('returns direct driver for non-scoped disk', function (): void {
// GIVEN
config()->set('filesystems.disks.my_s3', ['driver' => 's3']);
// WHEN
$driver = Utils::resolveDriver('my_s3');
// THEN
expect($driver)->toBe('s3');
});
it('resolves parent driver for scoped disk from string', function (): void {
// GIVEN
config()->set('filesystems.disks.parent_local', ['driver' => 'local']);
config()->set('filesystems.disks.scoped_disk', [
'driver' => 'scoped',
'disk' => 'parent_local',
]);
// WHEN
$driver = Utils::resolveDriver('scoped_disk');
// THEN
expect($driver)->toBe('local');
});
it('resolves parent driver for scoped disk when driver is given explicitly', function (): void {
// GIVEN
config()->set('filesystems.disks.parent_s3', ['driver' => 's3']);
config()->set('filesystems.disks.scoped_s3', [
'driver' => 'scoped',
'disk' => 'parent_s3',
]);
// WHEN
$driver = Utils::resolveDriver('scoped_s3', 'scoped');
// THEN
expect($driver)->toBe('s3');
});
it('returns explicit non-scoped driver without reading config', function (): void {
// WHEN
$driver = Utils::resolveDriver('not_configured', 'sftp');
// THEN
expect($driver)->toBe('sftp');
});
it('throws exception when parent disk has no driver configured', function (): void {
// GIVEN
config()->set('filesystems.disks.scoped_orphan', [
'driver' => 'scoped',
'disk' => 'missing_parent',
]);
// WHEN
$action = fn () => Utils::resolveDriver('scoped_orphan');
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Parent disk 'missing_parent' of scoped disk 'scoped_orphan' has no valid driver configured."
);
});
it('throws exception when scoped disk has missing parent disk', function (): void {
// GIVEN
config()->set('filesystems.disks.scoped_invalid', [
'driver' => 'scoped',
]);
// WHEN
$action = fn () => Utils::resolveDriver('scoped_invalid');
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Scoped disk 'scoped_invalid' is missing a valid parent 'disk' configuration."
);
});
it('throws exception when scoped disk has circular reference', function (): void {
// GIVEN
config()->set('filesystems.disks.scoped_x', [
'driver' => 'scoped',
'disk' => 'scoped_y',
]);
config()->set('filesystems.disks.scoped_y', [
'driver' => 'scoped',
'disk' => 'scoped_x',
]);
// WHEN
$action = fn () => Utils::resolveDriver('scoped_x');
// THEN
expect($action)->toThrow(
UnexpectedStorageException::class,
"Circular reference detected in scoped disk 'scoped_y'."
);
});
});

View file

@ -22,11 +22,10 @@ describe('DiskName instantiation', function (): void {
->and($vo->value)->toBe('local')
->and($vo->driver)->toBe($driver);
})->with([
'local driver' => 'local',
'ftp driver' => 'ftp',
'sftp driver' => 'sftp',
's3 driver' => 's3',
'scoped driver' => 'scoped',
'local driver' => 'local',
'ftp driver' => 'ftp',
'sftp driver' => 'sftp',
's3 driver' => 's3',
]);
it('can instantiate with a BackedEnum value', function (): void {

View file

@ -145,22 +145,6 @@ describe('StorageFilePath validation', function (): void {
]);
});
describe('StorageFilePath json serialization', function (): void {
it('returns the path when json serialized', function (): void {
// GIVEN
$path = 'documents/archive/2026.zip';
$vo = new StorageFilePath($path);
// WHEN
$jsonSerialized = $vo->jsonSerialize();
$jsonEncoded = json_encode($vo);
// THEN
expect($jsonSerialized)->toBe($path)
->and($jsonEncoded)->toBe('"documents\/archive\/2026.zip"');
});
});
describe('StorageFilePath string representation', function (): void {
it('returns the path when cast to string or via __toString', function (): void {
// GIVEN