Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
20 / 20
CRAP
100.00% covered (success)
100.00%
1 / 1
AbstractAttachment
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
20 / 20
35
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getMeta
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 buildUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setRecordLang
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRecordId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRecordId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 saveMeta
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
3
 getTenantQuotaEnforcer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilename
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMimeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFilesize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRealHash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 postUpdateMeta
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 readMimeType
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 mkVirtualHash
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 linkedRecords
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 deleteMeta
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Abstract attachment class.
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\Database;
23use phpMyFAQ\Database\DatabaseDriver;
24use phpMyFAQ\Tenant\TenantQuotaEnforcer;
25
26/**
27 * Class AttachmentAbstract
28 *
29 * @package phpMyFAQ\Attachment
30 */
31abstract class AbstractAttachment implements AttachmentInterface
32{
33    /**
34     * Attachment id.
35     */
36    protected int $id = 0;
37
38    /**
39     * The key to encrypt with.
40     */
41    protected ?string $key = null;
42
43    /**
44     * Errors.
45     *
46     * @var string[]
47     */
48    protected array $error = [];
49
50    /**
51     * Database instance.
52     */
53    protected DatabaseDriver $databaseDriver;
54
55    /**
56     * Record ID.
57     */
58    protected ?int $recordId = null;
59
60    /**
61     * Record language.
62     */
63    protected string $recordLang = '';
64
65    /**
66     * Real file md5 hash.
67     */
68    protected string $realHash = '';
69
70    /**
71     * Virtual unique md5 hash used for encrypted files.
72     * Must equal real hash for unencrypted files.
73     */
74    protected string $virtualHash = '';
75
76    /**
77     * Filesize in bytes.
78     */
79    protected int $filesize = 0;
80
81    /**
82     * Filename.
83     */
84    protected string $filename = '';
85
86    /**
87     * Encrypted.
88     */
89    protected ?bool $encrypted = null;
90
91    /**
92     * Attachment file mime type.
93     */
94    protected string $mimeType = '';
95
96    private ?TenantQuotaEnforcer $tenantQuotaEnforcer = null;
97
98    /**
99     * Constructor.
100     *
101     * @param int|null $attachmentId attachment id
102     */
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    }
112
113    /**
114     * Get meta data.
115     */
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    }
149
150    public function buildUrl(): string
151    {
152        return sprintf('attachment/%d', $this->id);
153    }
154
155    /**
156     * Set the encryption key.
157     *
158     * @param string|null $key Encryption key
159     */
160    public function setKey(?string $key): void
161    {
162        $this->key = $key;
163        $this->encrypted = null !== $key;
164    }
165
166    /**
167     * Set record language.
168     *
169     * @param string $lang record language
170     */
171    public function setRecordLang(string $lang): void
172    {
173        $this->recordLang = $lang;
174    }
175
176    /**
177     * Get attachment id.
178     */
179    public function getId(): int
180    {
181        return $this->id;
182    }
183
184    /**
185     * Sets attachment id.
186     */
187    public function setId(int $attachmentId): void
188    {
189        $this->id = $attachmentId;
190    }
191
192    /**
193     * Get attachment record id.
194     */
195    public function getRecordId(): int
196    {
197        return $this->recordId ?? 0;
198    }
199
200    /**
201     * Set record id.
202     *
203     * @param int $recordId record id
204     */
205    public function setRecordId(int $recordId): void
206    {
207        $this->recordId = $recordId;
208    }
209
210    /**
211     * Save attachment meta data.
212     *
213     * @return int saved attachment id
214     *
215     * @todo implement update case
216     */
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    }
250
251    protected function getTenantQuotaEnforcer(): TenantQuotaEnforcer
252    {
253        return $this->tenantQuotaEnforcer ??= TenantQuotaEnforcer::createFromDatabaseDriver($this->databaseDriver);
254    }
255
256    public function getFilename(): string
257    {
258        return $this->filename;
259    }
260
261    public function getMimeType(): string
262    {
263        return $this->mimeType;
264    }
265
266    public function getFilesize(): int
267    {
268        return $this->filesize;
269    }
270
271    public function getRealHash(): string
272    {
273        return $this->realHash;
274    }
275
276    /**
277     * Update several meta-things after it was saved.
278     */
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    }
291
292    /**
293     * The function is supposed to detect the file mime
294     * type.
295     */
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    }
303
304    /**
305     * Generate hash based on current conditions.
306     *
307     * @return string|null NOTE The way a file is saved in the filesystem
308     * NOTE The way a file is saved in the filesystem
309     * is based on md5 hash. If the file is unencrypted,
310     *  it md5 hash is used directly, otherwise a
311     * hash based on several tokens gets generated.
312     * @throws AttachmentException
313     */
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    }
335
336    /**
337     * Check if the same virtual hash exists more than once
338     * in the database. If so, this means the file
339     * is already uploaded as unencrypted.
340     */
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    }
362
363    /**
364     * Remove metadata from the database.
365     */
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    }
372}