Lines 94.82% 55 / 58
Methods 81.81% 9 / 11
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 put 90.00% 9 / 10 0.00% 0 / 1 5.03
 putStream 86.66% 13 / 15 0.00% 0 / 1 7.12
 get 100.00% 5 / 5 100.00% 1 / 1 2
 delete 100.00% 7 / 7 100.00% 1 / 1 3
 exists 100.00% 1 / 1 100.00% 1 / 1 1
 url 100.00% 4 / 4 100.00% 1 / 1 3
 size 100.00% 5 / 5 100.00% 1 / 1 2
 buildFullPath 100.00% 1 / 1 100.00% 1 / 1 1
 normalizePathForUrl 100.00% 8 / 8 100.00% 1 / 1 5
 normalizePath 100.00% 1 / 1 100.00% 1 / 1 1
22final readonly class FilesystemStorage implements StorageInterface
23{
24    private string $rootPath;
25
26    public function __construct(
27        string $rootPath,
28        private ?string $publicBaseUrl = null,
29    ) {
30        $this->rootPath = rtrim(string: $rootPath, characters: '/\\');
31    }
32
33    public function put(string $path, string $contents): bool
34    {
35        $fullPath = $this->buildFullPath($path);
36        $directory = dirname($fullPath);
37        if (
38            !is_dir($directory)
39            && !mkdir(directory: $directory, permissions: 0o775, recursive: true)
40            && !is_dir($directory)
41        ) {
42            throw new StorageException('Unable to create directory for storage path: ' . $directory);
43        }
44
45        $result = file_put_contents(filename: $fullPath, data: $contents, flags: LOCK_EX);
46        if ($result === false) {
47            throw new StorageException('Unable to write file to storage path: ' . $path);
48        }
49
50        return true;
51    }
52
53    public function putStream(string $path, mixed $stream): bool
54    {
55        if (!is_resource($stream)) {
56            throw new StorageException('Stream must be a valid resource.');
57        }
58
59        $fullPath = $this->buildFullPath($path);
60        $directory = dirname($fullPath);
61        if (
62            !is_dir($directory)
63            && !mkdir(directory: $directory, permissions: 0o775, recursive: true)
64            && !is_dir($directory)
65        ) {
66            throw new StorageException('Unable to create directory for storage path: ' . $directory);
67        }
68
69        $target = fopen(filename: $fullPath, mode: 'wb');
70        if ($target === false) {
71            throw new StorageException('Unable to open file for writing: ' . $path);
72        }
73
74        try {
75            if (stream_copy_to_stream($stream, $target) === false) {
76                throw new StorageException('Unable to write stream contents: ' . $path);
77            }
78        } finally {
79            fclose($target);
80        }
81
82        return true;
83    }
84
85    public function get(string $path): string
86    {
87        $fullPath = $this->buildFullPath($path);
88        $contents = file_get_contents($fullPath);
89
90        if ($contents === false) {
91            throw new StorageException('Unable to read file from storage path: ' . $path);
92        }
93
94        return $contents;
95    }
96
97    public function delete(string $path): bool
98    {
99        $fullPath = $this->buildFullPath($path);
100        if (!file_exists($fullPath)) {
101            return false;
102        }
103
104        $result = unlink($fullPath);
105        if ($result === false) {
106            throw new StorageException('Unable to delete file from storage path: ' . $path);
107        }
108
109        return true;
110    }
111
112    public function exists(string $path): bool
113    {
114        return file_exists($this->buildFullPath($path));
115    }
116
117    public function url(string $path): string
118    {
119        $normalizedPath = $this->normalizePathForUrl($path);
120        if ($this->publicBaseUrl !== null && $this->publicBaseUrl !== '') {
121            return rtrim(string: $this->publicBaseUrl, characters: '/') . '/' . $normalizedPath;
122        }
123
124        return str_replace(search: '\\', replace: '/', subject: $this->buildFullPath($path));
125    }
126
127    public function size(string $path): int
128    {
129        $fullPath = $this->buildFullPath($path);
130        $size = filesize($fullPath);
131
132        if ($size === false) {
133            throw new StorageException('Unable to fetch file size for storage path: ' . $path);
134        }
135
136        return $size;
137    }
138
139    private function buildFullPath(string $path): string
140    {
141        return $this->rootPath . DIRECTORY_SEPARATOR . $this->normalizePath($path);
142    }
143
144    private function normalizePathForUrl(string $path): string
145    {
146        $normalizedPath = ltrim(string: str_replace(search: '\\', replace: '/', subject: trim($path)), characters: '/');
147        if ($normalizedPath === '') {
148            throw new StorageException('Invalid storage path.');
149        }
150
151        $segments = explode('/', $normalizedPath);
152        foreach ($segments as $segment) {
153            if ($segment === '..' || $segment === '') {
154                throw new StorageException('Invalid storage path.');
155            }
156        }
157
158        return $normalizedPath;
159    }
160
161    private function normalizePath(string $path): string
162    {
163        return str_replace('/', DIRECTORY_SEPARATOR, $this->normalizePathForUrl($path));
164    }
165}