Lines 100.00% 90 / 90
Methods 100.00% 20 / 20
Classes 100.00% 1 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 4 / 4 100.00% 1 / 1 2
 getMeta 100.00% 17 / 17 100.00% 1 / 1 4
 buildUrl 100.00% 1 / 1 100.00% 1 / 1 1
 setKey 100.00% 2 / 2 100.00% 1 / 1 1
 setRecordLang 100.00% 1 / 1 100.00% 1 / 1 1
 getId 100.00% 1 / 1 100.00% 1 / 1 1
 setId 100.00% 1 / 1 100.00% 1 / 1 1
 getRecordId 100.00% 1 / 1 100.00% 1 / 1 1
 setRecordId 100.00% 1 / 1 100.00% 1 / 1 1
 saveMeta 100.00% 20 / 20 100.00% 1 / 1 3
 getTenantQuotaEnforcer 100.00% 1 / 1 100.00% 1 / 1 1
 getFilename 100.00% 1 / 1 100.00% 1 / 1 1
 getMimeType 100.00% 1 / 1 100.00% 1 / 1 1
 getFilesize 100.00% 1 / 1 100.00% 1 / 1 1
 getRealHash 100.00% 1 / 1 100.00% 1 / 1 1
 postUpdateMeta 100.00% 8 / 8 100.00% 1 / 1 1
 readMimeType 100.00% 3 / 3 100.00% 1 / 1 1
 mkVirtualHash 100.00% 11 / 11 100.00% 1 / 1 7
 linkedRecords 100.00% 12 / 12 100.00% 1 / 1 4
 deleteMeta 100.00% 2 / 2 100.00% 1 / 1 1
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}