Lines
83.87%
156 / 186
Methods
70.96%
22 / 31
Classes
0.00%
0 / 1
| 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 | ||
| 36 | class 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 | } |