Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.83% covered (success)
94.83%
55 / 58
81.82% covered (success)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
FilesystemStorage
94.83% covered (success)
94.83%
55 / 58
81.82% covered (success)
81.82%
9 / 11
31.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 put
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 putStream
86.67% covered (success)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
7.12
 get
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 exists
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 size
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 buildFullPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizePathForUrl
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 normalizePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Filesystem storage implementation.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-08
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Storage;
21
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}