Lines 83.87% 156 / 186
Methods 70.96% 22 / 31
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 buildFilePath 80.00% 4 / 5 0.00% 0 / 1 2.03
 buildStoragePath 100.00% 8 / 8 100.00% 1 / 1 2
 createSubDirs 100.00% 3 / 3 100.00% 1 / 1 3
 isStorageOk 92.30% 12 / 13 0.00% 0 / 1 6.02
 save 66.66% 18 / 27 0.00% 0 / 1 15.48
 delete 70.00% 7 / 10 0.00% 0 / 1 8.32
 get 22.22% 2 / 9 0.00% 0 / 1 11.53
 rawOut 50.00% 3 / 6 0.00% 0 / 1 4.12
 getFile 83.33% 5 / 6 0.00% 0 / 1 3.04
 getStorage 40.00% 2 / 5 0.00% 0 / 1 2.86
 usesCloudStorage 50.00% 2 / 4 0.00% 0 / 1 2.50
 [phpMyFAQ\Attachment\AbstractAttachment] __construct 100.00% 4 / 4 100.00% 1 / 1 2
 [phpMyFAQ\Attachment\AbstractAttachment] getMeta 100.00% 17 / 17 100.00% 1 / 1 4
 [phpMyFAQ\Attachment\AbstractAttachment] buildUrl 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] setKey 100.00% 2 / 2 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] setRecordLang 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] getId 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] setId 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] getRecordId 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] setRecordId 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] saveMeta 100.00% 20 / 20 100.00% 1 / 1 3
 [phpMyFAQ\Attachment\AbstractAttachment] getTenantQuotaEnforcer 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] getFilename 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] getMimeType 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] getFilesize 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] getRealHash 100.00% 1 / 1 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] postUpdateMeta 100.00% 8 / 8 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] readMimeType 100.00% 3 / 3 100.00% 1 / 1 1
 [phpMyFAQ\Attachment\AbstractAttachment] mkVirtualHash 100.00% 11 / 11 100.00% 1 / 1 7
 [phpMyFAQ\Attachment\AbstractAttachment] linkedRecords 100.00% 12 / 12 100.00% 1 / 1 4
 [phpMyFAQ\Attachment\AbstractAttachment] deleteMeta 100.00% 2 / 2 100.00% 1 / 1 1
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}

Inherited from phpMyFAQ\Attachment\AbstractAttachment

103    public function __construct(?int $attachmentId = null)
104    {
105        $this->databaseDriver = Database::getInstance();
106
107        if (null !== $attachmentId) {
108            $this->id = $attachmentId;
109            $this->getMeta();
110        }
111    }
116    protected function getMeta(): bool
117    {
118        $hasMeta = false;
119
120        $sql = sprintf('
121            SELECT
122                record_id, record_lang, real_hash, virtual_hash,
123                filename, filesize, encrypted, mime_type
124            FROM
125                %sfaqattachment
126            WHERE
127                id = %d', Database::getTablePrefix(), $this->id);
128
129        $result = $this->databaseDriver->query($sql);
130
131        if ($result) {
132            $assoc = $this->databaseDriver->fetchArray($result);
133            if (is_array($assoc) && $assoc !== []) {
134                $this->recordId = (int) $assoc['record_id'];
135                $this->recordLang = (string) $assoc['record_lang'];
136                $this->realHash = (string) $assoc['real_hash'];
137                $this->virtualHash = (string) $assoc['virtual_hash'];
138                $this->filename = (string) $assoc['filename'];
139                $this->filesize = (int) $assoc['filesize'];
140                $this->encrypted = (bool) $assoc['encrypted'];
141                $this->mimeType = (string) $assoc['mime_type'];
142
143                $hasMeta = true;
144            }
145        }
146
147        return $hasMeta;
148    }
150    public function buildUrl(): string
151    {
152        return sprintf('attachment/%d', $this->id);
153    }
160    public function setKey(?string $key): void
161    {
162        $this->key = $key;
163        $this->encrypted = null !== $key;
164    }
171    public function setRecordLang(string $lang): void
172    {
173        $this->recordLang = $lang;
174    }
179    public function getId(): int
180    {
181        return $this->id;
182    }
187    public function setId(int $attachmentId): void
188    {
189        $this->id = $attachmentId;
190    }
195    public function getRecordId(): int
196    {
197        return $this->recordId ?? 0;
198    }
205    public function setRecordId(int $recordId): void
206    {
207        $this->recordId = $recordId;
208    }
217    public function saveMeta(): int
218    {
219        $attachmentTableName = sprintf('%sfaqattachment', Database::getTablePrefix());
220
221        if (0 === $this->id) {
222            $this->getTenantQuotaEnforcer()->assertCanStoreAttachment($this->filesize);
223            $this->id = $this->databaseDriver->nextId($attachmentTableName, 'id');
224
225            $sql = sprintf(
226                "
227                INSERT INTO
228                    %s
229                (id, record_id, record_lang, real_hash, virtual_hash,
230                filename, filesize, encrypted, mime_type)
231                    VALUES
232                (%d, %d, '%s', '%s', '%s', '%s', %d, %d, '%s')",
233                $attachmentTableName,
234                $this->id,
235                $this->recordId,
236                $this->databaseDriver->escape($this->recordLang),
237                $this->databaseDriver->escape($this->realHash),
238                $this->databaseDriver->escape($this->virtualHash),
239                $this->databaseDriver->escape($this->filename),
240                $this->filesize,
241                $this->encrypted ? 1 : 0,
242                $this->databaseDriver->escape($this->mimeType),
243            );
244
245            $this->databaseDriver->query($sql);
246        }
247
248        return $this->id;
249    }
251    protected function getTenantQuotaEnforcer(): TenantQuotaEnforcer
252    {
253        return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->databaseDriver);
254    }
256    public function getFilename(): string
257    {
258        return $this->filename;
259    }
261    public function getMimeType(): string
262    {
263        return $this->mimeType;
264    }
266    public function getFilesize(): int
267    {
268        return $this->filesize;
269    }
271    public function getRealHash(): string
272    {
273        return $this->realHash;
274    }
279    protected function postUpdateMeta(): void
280    {
281        $sql = sprintf(
282            "UPDATE %sfaqattachment SET virtual_hash = '%s', mime_type = '%s' WHERE id = %d",
283            Database::getTablePrefix(),
284            $this->databaseDriver->escape($this->virtualHash),
285            $this->readMimeType(),
286            $this->id,
287        );
288
289        $this->databaseDriver->query($sql);
290    }
296    protected function readMimeType(): string
297    {
298        $ext = pathinfo($this->filename, PATHINFO_EXTENSION);
299        $this->mimeType = AbstractMimeType::guessByExt($ext);
300
301        return $this->mimeType;
302    }
314    protected function mkVirtualHash(): ?string
315    {
316        if (!$this->encrypted) {
317            if ($this->realHash !== '') {
318                $this->virtualHash = $this->realHash;
319            }
320
321            return $this->virtualHash;
322        }
323
324        if ($this->recordId === null || $this->realHash === '' || $this->filename === '' || $this->key === null) {
325            throw new AttachmentException(
326                'All of id, recordId, hash, filename, key is needed to generate fs hash for encrypted files',
327            );
328        }
329
330        $src = $this->id . $this->recordId . $this->realHash . $this->filename . $this->key;
331        $this->virtualHash = md5($src);
332
333        return $this->virtualHash;
334    }
341    protected function linkedRecords(): bool
342    {
343        $assoc = [];
344
345        $sql = sprintf(
346            "SELECT COUNT(1) AS count FROM %sfaqattachment WHERE virtual_hash = '%s'",
347            Database::getTablePrefix(),
348            $this->databaseDriver->escape($this->virtualHash),
349        );
350
351        $result = $this->databaseDriver->query($sql);
352
353        if ($result) {
354            $row = $this->databaseDriver->fetchArray($result);
355            if (is_array($row)) {
356                $assoc = $row;
357            }
358        }
359
360        return array_key_exists('count', $assoc) && (int) $assoc['count'] > 1;
361    }
366    public function deleteMeta(): void
367    {
368        $sql = sprintf('DELETE FROM %sfaqattachment WHERE id = %d', Database::getTablePrefix(), $this->id);
369
370        $this->databaseDriver->query($sql);
371    }