Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.75% covered (warning)
68.75%
66 / 96
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
File
68.75% covered (warning)
68.75%
66 / 96
18.18% covered (danger)
18.18%
2 / 11
106.80
0.00% covered (danger)
0.00%
0 / 1
 buildFilePath
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 buildStoragePath
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 createSubDirs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 isStorageOk
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 save
66.67% covered (warning)
66.67%
18 / 27
0.00% covered (danger)
0.00%
0 / 1
15.48
 delete
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
8.32
 get
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
11.53
 rawOut
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 getFile
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getStorage
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
2.86
 usesCloudStorage
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
1<?php
2
3/**
4 * Attachment handler class for files stored in filesystem.
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    Anatoliy Belsky <ab@php.net>
12 * @copyright 2009-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     2009-08-21
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Attachment;
21
22use phpMyFAQ\Attachment\Filesystem\AbstractFile as FilesystemFile;
23use phpMyFAQ\Attachment\Filesystem\File\EncryptedFile;
24use phpMyFAQ\Attachment\Filesystem\File\FileException;
25use phpMyFAQ\Attachment\Filesystem\File\VanillaFile;
26use phpMyFAQ\Configuration;
27use phpMyFAQ\Storage\StorageException;
28use phpMyFAQ\Storage\StorageFactory;
29use phpMyFAQ\Storage\StorageInterface;
30
31/**
32 * Class File
33 *
34 * @package phpMyFAQ\Attachment
35 */
36class File extends AbstractAttachment implements AttachmentInterface
37{
38    private ?StorageInterface $storage = null;
39
40    /**
41     * Build a file path under which the attachment file is accessible in filesystem
42     *
43     * @throws AttachmentException
44     */
45    protected function buildFilePath(): string
46    {
47        $storagePath = $this->buildStoragePath();
48        $attachmentPath = defined('PMF_ATTACHMENTS_DIR')
49            ? (string) constant('PMF_ATTACHMENTS_DIR')
50            : sys_get_temp_dir();
51
52        return $attachmentPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $storagePath);
53    }
54
55    /**
56     * Build the storage key/path for the current attachment.
57     *
58     * @throws AttachmentException
59     */
60    protected function buildStoragePath(): string
61    {
62        $fsHash = $this->mkVirtualHash();
63        $subDirCount = 3;
64        $subDirNameLength = 5;
65        $segments = [];
66
67        for ($i = 0; $i < $subDirCount; ++$i) {
68            $segments[] = substr((string) $fsHash, $i * $subDirNameLength, $subDirNameLength);
69        }
70
71        $segments[] = substr((string) $fsHash, $i * $subDirNameLength);
72
73        return implode('/', $segments);
74    }
75
76    /**
77     * Create subdirectories to save a file to.
78     *
79     * @param string $filepath filepath to create subdirectories for
80     * @return bool success
81     */
82    public function createSubDirs(string $filepath): bool
83    {
84        clearstatcache();
85        $attDir = dirname($filepath);
86
87        return file_exists($attDir) && is_dir($attDir) || mkdir($attDir, permissions: 0o750, recursive: true);
88    }
89
90    /**
91     * Check whether the file storage is ok.
92     *
93     * @throws AttachmentException
94     */
95    public function isStorageOk(): bool
96    {
97        if ($this->usesCloudStorage()) {
98            return true;
99        }
100
101        if (!defined('PMF_ATTACHMENTS_DIR')) {
102            return false;
103        }
104
105        clearstatcache();
106        $attachmentDir = dirname($this->buildFilePath());
107
108        $attachmentsRoot = (string) PMF_ATTACHMENTS_DIR;
109
110        return (
111            file_exists($attachmentsRoot)
112            && is_dir($attachmentsRoot)
113            && file_exists($attachmentDir)
114            && is_dir($attachmentDir)
115        );
116    }
117
118    /**
119     * Save the current attachment to the appropriate storage.
120     * The filepath given will be processed and moved to the appropriate location.
121     *
122     * @param string $filePath full path to the attachment file
123     * @throws FileException|AttachmentException
124     * @todo rollback if something went wrong
125     */
126    public function save(string $filePath, ?string $filename = null): bool
127    {
128        $success = false;
129
130        if (file_exists($filePath)) {
131            $this->realHash = (string) md5_file($filePath);
132            $this->filesize = (int) filesize($filePath);
133            $this->filename = $filename ?? basename($filePath);
134
135            $this->saveMeta();
136
137            if ($this->linkedRecords()) {
138                $success = true;
139            }
140
141            if (!$success) {
142                try {
143                    if ($this->encrypted) {
144                        $targetFile = $this->buildFilePath();
145                        if ($this->createSubDirs($targetFile)) {
146                            $vanillaFile = new VanillaFile($filePath);
147                            $target = $this->getFile(FilesystemFile::MODE_WRITE);
148                            $success = $vanillaFile->moveTo($target);
149                        }
150                    }
151
152                    if (!$this->encrypted) {
153                        $contents = file_get_contents($filePath);
154                        if ($contents !== false) {
155                            $success = $this->getStorage()->put($this->buildStoragePath(), $contents);
156                        }
157                    }
158                } catch (StorageException $storageException) {
159                    throw new AttachmentException($storageException->getMessage(), 0, $storageException);
160                }
161            }
162
163            if ($success) {
164                $this->postUpdateMeta();
165            }
166
167            if (!$success) {
168                // File wasn't saved
169                $this->delete();
170                $success = false;
171            }
172        }
173
174        return $success;
175    }
176
177    /**
178     * Delete attachment.
179     *
180     * @throws FileException|AttachmentException
181     */
182    public function delete(): bool
183    {
184        $success = true;
185
186        // Won't delete the file if there are still some records hanging on it
187        $hasLinkedRecords = $this->linkedRecords();
188        if (!$hasLinkedRecords && $this->encrypted) {
189            $success = $success && $this->getFile()->delete();
190        }
191
192        if (!$hasLinkedRecords && !$this->encrypted) {
193            try {
194                $this->getStorage()->delete($this->buildStoragePath());
195            } catch (StorageException $storageException) {
196                throw new AttachmentException($storageException->getMessage(), 0, $storageException);
197            }
198        }
199
200        $this->deleteMeta();
201
202        return $success;
203    }
204
205    /**
206     * Retrieve file contents into a variable.
207     *
208     * @throws AttachmentException
209     */
210    public function get(): string
211    {
212        if (!$this->encrypted) {
213            try {
214                return $this->getStorage()->get($this->buildStoragePath());
215            } catch (StorageException $storageException) {
216                throw new AttachmentException($storageException->getMessage(), 0, $storageException);
217            }
218        }
219
220        $file = $this->getFile();
221        $contents = '';
222        while (!$file->eof()) {
223            $contents .= $file->getChunk();
224        }
225
226        return $contents;
227    }
228
229    /**
230     * Output current file to stdout.
231     *
232     * @throws AttachmentException
233     */
234    public function rawOut(): void
235    {
236        if (!$this->encrypted) {
237            echo $this->get();
238            return;
239        }
240
241        $file = $this->getFile();
242        while (!$file->eof()) {
243            echo $file->getChunk();
244        }
245    }
246
247    /**
248     * Factory method to initialize the corresponding file object.
249     *
250     * @param string $mode File mode for file open
251     * @throws AttachmentException
252     */
253    private function getFile(string $mode = FilesystemFile::MODE_READ): EncryptedFile|VanillaFile
254    {
255        if ($this->encrypted) {
256            $encryptionKey = $this->key;
257            if ($encryptionKey === null) {
258                throw new AttachmentException('Cannot open an encrypted attachment without a key.');
259            }
260
261            return new EncryptedFile($this->buildFilePath(), $mode, $encryptionKey);
262        }
263
264        return new VanillaFile($this->buildFilePath(), $mode);
265    }
266
267    /**
268     * @throws AttachmentException
269     */
270    private function getStorage(): StorageInterface
271    {
272        if ($this->storage instanceof StorageInterface) {
273            return $this->storage;
274        }
275
276        $configuration = Configuration::getConfigurationInstance();
277
278        $this->storage = new StorageFactory($configuration)->create();
279
280        return $this->storage;
281    }
282
283    private function usesCloudStorage(): bool
284    {
285        try {
286            $configuration = Configuration::getConfigurationInstance();
287        } catch (\LogicException) {
288            // No configuration bootstrapped (CLI scripts, isolated tests): default to local storage.
289            return false;
290        }
291
292        return strtolower((string) $configuration->get('storage.type')) === 's3';
293    }
294}