Lines
95.02%
191 / 201
Methods
69.23%
9 / 13
Classes
0.00%
0 / 1
| Name | Lines | Methods | CRAP | ||||
|---|---|---|---|---|---|---|---|
| __construct | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| getLastBackupInfo | 92.59% | 25 / 27 | 0.00% | 0 / 1 | 7.02 | ||
| createBackup | 100.00% | 7 / 7 | 100.00% | 1 / 1 | 2 | ||
| verifyBackup | 100.00% | 8 / 8 | 100.00% | 1 / 1 | 2 | ||
| generateBackupQueries | 100.00% | 9 / 9 | 100.00% | 1 / 1 | 3 | ||
| getBackupTableNames | 100.00% | 20 / 20 | 100.00% | 1 / 1 | 10 | ||
| getBackupHeader | 100.00% | 11 / 11 | 100.00% | 1 / 1 | 1 | ||
| createContentFolderBackup | 80.95% | 17 / 21 | 0.00% | 0 / 1 | 7.34 | ||
| export | 100.00% | 4 / 4 | 100.00% | 1 / 1 | 1 | ||
| parseBackupFile | 93.87% | 46 / 49 | 0.00% | 0 / 1 | 14.04 | ||
| executeBackupQueries | 100.00% | 24 / 24 | 100.00% | 1 / 1 | 3 | ||
| getRepository | 100.00% | 1 / 1 | 100.00% | 1 / 1 | 1 | ||
| isCompleteStatement | 94.73% | 18 / 19 | 0.00% | 0 / 1 | 9.01 | ||
| 43 | readonly class Backup | |
| 44 | { | |
| 45 | private BackupRepository $backupRepository; | |
| 46 | ||
| 47 | /** | |
| 48 | * Constructor. | |
| 49 | */ | |
| 50 | public function __construct( | |
| 51 | private Configuration $configuration, | |
| 52 | private DatabaseHelper $databaseHelper, | |
| 53 | ) { | |
| 54 | $this->backupRepository = new BackupRepository($this->configuration); | |
| 55 | } | |
| 56 | ||
| 57 | /** | |
| 58 | * Returns last backup date (formatted) and whether it's older than 30 days. | |
| 59 | * | |
| 60 | * @return array{lastBackupDate: ?string, isBackupOlderThan30Days: bool} | |
| 61 | */ | |
| 62 | public function getLastBackupInfo(): array | |
| 63 | { | |
| 64 | $lastBackupDateFormatted = null; | |
| 65 | try { | |
| 66 | $backups = $this->getRepository()->getAll(); | |
| 67 | $lastBackup = $backups[0] ?? null; | |
| 68 | ||
| 69 | if ($lastBackup === null || !property_exists($lastBackup, 'created') || $lastBackup->created === null) { | |
| 70 | $isBackupOlderThan30Days = true; | |
| 71 | return [ | |
| 72 | 'lastBackupDate' => $lastBackupDateFormatted, | |
| 73 | 'isBackupOlderThan30Days' => $isBackupOlderThan30Days, | |
| 74 | ]; | |
| 75 | } | |
| 76 | ||
| 77 | $createdRaw = (string) $lastBackup->created; | |
| 78 | $createdDate = DateTimeImmutable::createFromFormat(format: 'Y-m-d H:i:s', datetime: $createdRaw); | |
| 79 | $createdDate = $createdDate === false ? null : $createdDate; | |
| 80 | if ($createdDate === null) { | |
| 81 | $isBackupOlderThan30Days = true; | |
| 82 | return [ | |
| 83 | 'lastBackupDate' => $lastBackupDateFormatted, | |
| 84 | 'isBackupOlderThan30Days' => $isBackupOlderThan30Days, | |
| 85 | ]; | |
| 86 | } | |
| 87 | ||
| 88 | $lastBackupDateFormatted = $createdDate->format(format: 'Y-m-d H:i:s'); | |
| 89 | $threshold = new DateTimeImmutable(datetime: '-30 days'); | |
| 90 | $isBackupOlderThan30Days = $createdDate < $threshold; | |
| 91 | } catch (\Throwable) { | |
| 92 | $isBackupOlderThan30Days = true; | |
| 93 | } | |
| 94 | ||
| 95 | return [ | |
| 96 | 'lastBackupDate' => $lastBackupDateFormatted, | |
| 97 | 'isBackupOlderThan30Days' => $isBackupOlderThan30Days, | |
| 98 | ]; | |
| 99 | } | |
| 100 | ||
| 101 | /** | |
| 102 | * @throws SodiumException | |
| 103 | */ | |
| 104 | public function createBackup(string $backupType, string $backupFile): string | |
| 105 | { | |
| 106 | $backupDate = date(format: 'Y-m-d-H-i-s'); | |
| 107 | ||
| 108 | $fileNamePrefix = Database::getTablePrefix() !== '' ? Database::getTablePrefix() . '.phpmyfaq' : 'phpmyfaq'; | |
| 109 | $fileName = sprintf('%s-%s.%s.sql', $fileNamePrefix, $backupType, $backupDate); | |
| 110 | ||
| 111 | $authKey = sodium_crypto_auth_keygen(); | |
| 112 | $authCode = sodium_crypto_auth($backupFile, $authKey); | |
| 113 | ||
| 114 | // persist backup metadata via repository | |
| 115 | $this->getRepository()->add($fileName, sodium_bin2hex($authKey), sodium_bin2hex($authCode), $backupDate); | |
| 116 | ||
| 117 | return $fileName; | |
| 118 | } | |
| 119 | ||
| 120 | /** | |
| 121 | * @throws SodiumException | |
| 122 | */ | |
| 123 | public function verifyBackup(string $backup, string $backupFileName): bool | |
| 124 | { | |
| 125 | $row = $this->getRepository()->findByFilename($backupFileName); | |
| 126 | if ($row !== null) { | |
| 127 | return sodium_crypto_auth_verify( | |
| 128 | sodium_hex2bin((string) $row->authcode), | |
| 129 | $backup, | |
| 130 | sodium_hex2bin((string) $row->authkey), | |
| 131 | ); | |
| 132 | } | |
| 133 | ||
| 134 | return false; | |
| 135 | } | |
| 136 | ||
| 137 | public function generateBackupQueries(string $tableNames): string | |
| 138 | { | |
| 139 | $backup = implode(separator: "\r\n", array: $this->getBackupHeader($tableNames)); | |
| 140 | ||
| 141 | foreach (explode(separator: ' ', string: $tableNames) as $tableName) { | |
| 142 | if ('' === $tableName) { | |
| 143 | continue; | |
| 144 | } | |
| 145 | ||
| 146 | $backup .= implode(separator: "\r\n", array: $this->databaseHelper->buildInsertQueries( | |
| 147 | 'SELECT * FROM ' . $tableName, | |
| 148 | $tableName, | |
| 149 | )); | |
| 150 | } | |
| 151 | ||
| 152 | return $backup; | |
| 153 | } | |
| 154 | ||
| 155 | /** | |
| 156 | * @throws \Exception | |
| 157 | */ | |
| 158 | public function getBackupTableNames(BackupType $backupType): string | |
| 159 | { | |
| 160 | $tables = $this->configuration->getDb()->getTableNames(Database::getTablePrefix()); | |
| 161 | $tableNames = ''; | |
| 162 | ||
| 163 | switch ($backupType) { | |
| 164 | case BackupType::BACKUP_TYPE_DATA: | |
| 165 | foreach ($tables as $table) { | |
| 166 | if (Database::getTablePrefix() . 'faqadminlog' === trim((string) $table)) { | |
| 167 | continue; | |
| 168 | } | |
| 169 | ||
| 170 | if (Database::getTablePrefix() . 'faqsessions' === trim((string) $table)) { | |
| 171 | continue; | |
| 172 | } | |
| 173 | ||
| 174 | $tableNames .= $table . ' '; | |
| 175 | } | |
| 176 | ||
| 177 | break; | |
| 178 | case BackupType::BACKUP_TYPE_LOGS: | |
| 179 | foreach ($tables as $table) { | |
| 180 | if ( | |
| 181 | Database::getTablePrefix() . 'faqadminlog' !== trim((string) $table) | |
| 182 | && Database::getTablePrefix() . 'faqsessions' !== trim((string) $table) | |
| 183 | ) { | |
| 184 | continue; | |
| 185 | } | |
| 186 | ||
| 187 | $tableNames .= $table . ' '; | |
| 188 | } | |
| 189 | ||
| 190 | break; | |
| 191 | case BackupType::BACKUP_TYPE_CONTENT: | |
| 192 | throw new \Exception(message: 'To be implemented'); | |
| 193 | } | |
| 194 | ||
| 195 | return $tableNames; | |
| 196 | } | |
| 197 | ||
| 198 | /** | |
| 199 | * Returns the backup file header | |
| 200 | * @return string[] | |
| 201 | */ | |
| 202 | private function getBackupHeader(string $tableNames): array | |
| 203 | { | |
| 204 | return [ | |
| 205 | sprintf( | |
| 206 | '-- pmf%s: %s', | |
| 207 | substr(string: $this->configuration->getVersion(), offset: 0, length: 3), | |
| 208 | $tableNames, | |
| 209 | ), | |
| 210 | '-- DO NOT REMOVE THE FIRST LINE!', | |
| 211 | '-- pmftableprefix: ' . Database::getTablePrefix(), | |
| 212 | '-- DO NOT REMOVE THE LINES ABOVE!', | |
| 213 | '-- Otherwise this backup will be broken.', | |
| 214 | ]; | |
| 215 | } | |
| 216 | ||
| 217 | /** | |
| 218 | * Creates a ZipArchive of the content-folder in a temporary, non-web-accessible location. | |
| 219 | * The caller is responsible for removing the returned file. | |
| 220 | * | |
| 221 | * @throws \Exception | |
| 222 | */ | |
| 223 | public function createContentFolderBackup(): string | |
| 224 | { | |
| 225 | $zipFile = tempnam(directory: sys_get_temp_dir(), prefix: 'pmf_content_backup_'); | |
| 226 | if ($zipFile === false) { | |
| 227 | throw new Exception(message: 'Error while creating temporary file for ZipArchive'); | |
| 228 | } | |
| 229 | ||
| 230 | $zipArchive = new ZipArchive(); | |
| 231 | if (true !== $zipArchive->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE)) { | |
| 232 | unlink($zipFile); | |
| 233 | throw new Exception(message: 'Error while creating ZipArchive'); | |
| 234 | } | |
| 235 | ||
| 236 | $files = new RecursiveIteratorIterator( | |
| 237 | new RecursiveDirectoryIterator(PMF_CONTENT_DIR), | |
| 238 | RecursiveIteratorIterator::LEAVES_ONLY, | |
| 239 | ); | |
| 240 | ||
| 241 | foreach ($files as $file) { | |
| 242 | if (!$file instanceof \SplFileInfo || $file->isDir()) { | |
| 243 | continue; | |
| 244 | } | |
| 245 | ||
| 246 | $filePath = $file->getRealPath(); | |
| 247 | if ($filePath === false) { | |
| 248 | continue; | |
| 249 | } | |
| 250 | ||
| 251 | $relativePath = substr($filePath, strlen((string) PMF_CONTENT_DIR) + 1); | |
| 252 | $zipArchive->addFile($filePath, $relativePath); | |
| 253 | } | |
| 254 | ||
| 255 | $zipArchive->close(); | |
| 256 | ||
| 257 | return $zipFile; | |
| 258 | } | |
| 259 | ||
| 260 | /** | |
| 261 | * Creates a backup for the given type and returns filename + content. | |
| 262 | * | |
| 263 | * @throws SodiumException | |
| 264 | * @throws \Exception | |
| 265 | * | |
| 266 | */ | |
| 267 | public function export(BackupType $backupType): BackupExportResult | |
| 268 | { | |
| 269 | $tableNames = $this->getBackupTableNames($backupType); | |
| 270 | ||
| 271 | $backupContent = $this->generateBackupQueries($tableNames); | |
| 272 | ||
| 273 | $fileName = $this->createBackup($backupType->value, $backupContent); | |
| 274 | ||
| 275 | return new BackupExportResult($fileName, $backupContent); | |
| 276 | } | |
| 277 | ||
| 278 | /** | |
| 279 | * Parses a backup file, checks the version and creates SQL queries + table prefix. | |
| 280 | * | |
| 281 | * @throws Exception | |
| 282 | */ | |
| 283 | public function parseBackupFile(string $filePath, string $currentVersion): BackupParseResult | |
| 284 | { | |
| 285 | $handle = fopen($filePath, mode: 'r'); | |
| 286 | if (false === $handle) { | |
| 287 | throw new Exception(message: sprintf('Cannot open backup file "%s".', $filePath)); | |
| 288 | } | |
| 289 | ||
| 290 | $firstLine = fgets($handle, length: 65_536); | |
| 291 | if (false === $firstLine) { | |
| 292 | fclose($handle); | |
| 293 | throw new Exception(message: 'Empty backup file.'); | |
| 294 | } | |
| 295 | ||
| 296 | $versionFound = Strings::substr(string: $firstLine, start: 0, length: 9); | |
| 297 | ||
| 298 | $versionExpected = '-- pmf' . substr(string: $currentVersion, offset: 0, length: 3); | |
| 299 | ||
| 300 | // Tabellen aus der ersten Zeile extrahieren | |
| 301 | $tablesLine = trim(Strings::substr(string: $firstLine, start: 11)); | |
| 302 | $tables = explode(separator: ' ', string: $tablesLine); | |
| 303 | ||
| 304 | $queries = []; | |
| 305 | foreach ($tables as $table) { | |
| 306 | if ('' === $table) { | |
| 307 | continue; | |
| 308 | } | |
| 309 | ||
| 310 | $queries[] = sprintf('DELETE FROM %s', $table); | |
| 311 | } | |
| 312 | ||
| 313 | $tablePrefix = ''; | |
| 314 | $currentQuery = ''; | |
| 315 | ||
| 316 | while (true) { | |
| 317 | $line = fgets($handle, length: 65_536); | |
| 318 | if ($line === false) { | |
| 319 | break; | |
| 320 | } | |
| 321 | ||
| 322 | $trimmedLine = trim($line); | |
| 323 | $backupPrefixPattern = '-- pmftableprefix:'; | |
| 324 | $backupPrefixPatternLength = Strings::strlen($backupPrefixPattern); | |
| 325 | ||
| 326 | if ( | |
| 327 | Strings::substr(string: $trimmedLine, start: 0, length: $backupPrefixPatternLength) | |
| 328 | === $backupPrefixPattern | |
| 329 | ) { | |
| 330 | $tablePrefix = trim(Strings::substr($trimmedLine, $backupPrefixPatternLength)); | |
| 331 | ||
| 332 | continue; | |
| 333 | } | |
| 334 | ||
| 335 | // Skip comment lines (-- or # at start of line) | |
| 336 | if ( | |
| 337 | Strings::substr(string: $trimmedLine, start: 0, length: 2) === '--' | |
| 338 | || Strings::substr(string: $trimmedLine, start: 0, length: 1) === '#' | |
| 339 | ) { | |
| 340 | continue; | |
| 341 | } | |
| 342 | ||
| 343 | // Skip empty lines | |
| 344 | if ($trimmedLine === '') { | |
| 345 | continue; | |
| 346 | } | |
| 347 | ||
| 348 | // Accumulate lines to handle multi-line SQL statements | |
| 349 | $currentQuery .= $line; | |
| 350 | ||
| 351 | // Check if statement is complete (ends with ; and quotes are balanced) | |
| 352 | if ($this->isCompleteStatement($currentQuery)) { | |
| 353 | $queries[] = trim(rtrim(trim($currentQuery), characters: ';')); | |
| 354 | $currentQuery = ''; | |
| 355 | } | |
| 356 | } | |
| 357 | ||
| 358 | // Handle any remaining incomplete query | |
| 359 | $remainingQuery = trim($currentQuery); | |
| 360 | if ($remainingQuery !== '' && $remainingQuery !== ';') { | |
| 361 | $queries[] = rtrim($remainingQuery, characters: ';'); | |
| 362 | } | |
| 363 | ||
| 364 | fclose($handle); | |
| 365 | ||
| 366 | $versionMatches = $versionFound === $versionExpected; | |
| 367 | ||
| 368 | return new BackupParseResult( | |
| 369 | versionMatches: $versionMatches, | |
| 370 | versionFound: $versionFound, | |
| 371 | versionExpected: $versionExpected, | |
| 372 | queries: $queries, | |
| 373 | tablePrefix: $tablePrefix, | |
| 374 | ); | |
| 375 | } | |
| 376 | ||
| 377 | /** | |
| 378 | * Executes the given backup queries with the correct table prefix. | |
| 379 | */ | |
| 380 | public function executeBackupQueries(array $queries, string $tablePrefix): BackupExecuteResult | |
| 381 | { | |
| 382 | $databaseDriver = $this->configuration->getDb(); | |
| 383 | ||
| 384 | $ok = 0; | |
| 385 | $failed = 0; | |
| 386 | $lastErrorQuery = null; | |
| 387 | $lastErrorReason = null; | |
| 388 | ||
| 389 | foreach ($queries as $query) { | |
| 390 | $alignedQuery = $this->databaseHelper::alignTablePrefix( | |
| 391 | (string) $query, | |
| 392 | $tablePrefix, | |
| 393 | Database::getTablePrefix(), | |
| 394 | ); | |
| 395 | ||
| 396 | $result = $databaseDriver->query($alignedQuery); | |
| 397 | if (!$result) { | |
| 398 | ++$failed; | |
| 399 | $lastErrorQuery = $alignedQuery; | |
| 400 | $lastErrorReason = $databaseDriver->error(); | |
| 401 | ||
| 402 | continue; | |
| 403 | } | |
| 404 | ||
| 405 | ++$ok; | |
| 406 | } | |
| 407 | ||
| 408 | return new BackupExecuteResult( | |
| 409 | queriesOk: $ok, | |
| 410 | queriesFailed: $failed, | |
| 411 | lastErrorQuery: $lastErrorQuery, | |
| 412 | lastErrorReason: $lastErrorReason, | |
| 413 | ); | |
| 414 | } | |
| 415 | ||
| 416 | private function getRepository(): BackupRepository | |
| 417 | { | |
| 418 | return $this->backupRepository; | |
| 419 | } | |
| 420 | ||
| 421 | /** | |
| 422 | * Checks if a SQL statement is complete (ends with semicolon outside of string literals). | |
| 423 | */ | |
| 424 | private function isCompleteStatement(string $query): bool | |
| 425 | { | |
| 426 | $trimmed = rtrim($query); | |
| 427 | if (!str_ends_with($trimmed, ';')) { | |
| 428 | return false; | |
| 429 | } | |
| 430 | ||
| 431 | // Count unescaped single quotes to determine if we're inside a string literal | |
| 432 | // We need to account for escaped quotes: \' and '' (SQL escape) | |
| 433 | $inString = false; | |
| 434 | $length = strlen($trimmed); | |
| 435 | ||
| 436 | for ($i = 0; $i < $length; $i++) { | |
| 437 | $char = $trimmed[$i]; | |
| 438 | ||
| 439 | if ($char !== "'") { | |
| 440 | continue; | |
| 441 | } | |
| 442 | ||
| 443 | if (!$inString) { | |
| 444 | $inString = true; | |
| 445 | continue; | |
| 446 | } | |
| 447 | ||
| 448 | // Inside a string - check for escaped quotes | |
| 449 | // Double single quote escape ('') | |
| 450 | if (($i + 1) < $length && $trimmed[$i + 1] === "'") { | |
| 451 | $i++; | |
| 452 | continue; | |
| 453 | } | |
| 454 | ||
| 455 | // Backslash escape (\') | |
| 456 | if ($i > 0 && $trimmed[$i - 1] === '\\') { | |
| 457 | continue; | |
| 458 | } | |
| 459 | ||
| 460 | // End of string literal | |
| 461 | $inString = false; | |
| 462 | } | |
| 463 | ||
| 464 | // Statement is complete if we're not inside a string literal | |
| 465 | return !$inString; | |
| 466 | } | |
| 467 | } |