Backup #133

ID133
Dateipfad/var/www/dev.campus.systemische-tools.de/src/Infrastructure/Persistence/FileBackupRepository.php
Version4
Typ modified
Größe8.6 KB
Hash0efe3f08a6534d3ed3e6c4f75187d95cdf8a19262883dbaf82eca55cc9224566
Datum2025-12-20 19:53:28
Geändert vonclaude-code-hook
GrundClaude Code Pre-Hook Backup vor Edit-Operation
Datei existiert Ja

Dateiinhalt

<?php

declare(strict_types=1);

namespace Infrastructure\Persistence;

use Infrastructure\Config\DatabaseFactory;
use PDO;

/**
 * Repository for file_backup_history table.
 */
class FileBackupRepository
{
    private PDO $db;

    /**
     * Allowlist of base paths where file restoration is permitted.
     * Only files within these directories can be restored.
     */
    private const ALLOWED_RESTORE_PATHS = [
        '/var/www/dev.campus.systemische-tools.de/',
        '/var/www/prod.campus.systemische-tools.de/',
    ];

    public function __construct()
    {
        $this->db = DatabaseFactory::dev();
    }

    /**
     * Find all backups with optional filters.
     *
     * @param array<string, mixed> $filters
     */
    public function findAll(array $filters = [], int $limit = 50, int $offset = 0): array
    {
        $sql = 'SELECT id, file_path, content_hash, file_size, version, change_type,
                       changed_at, changed_by, reason
                FROM file_backup_history WHERE 1=1';
        $params = [];

        if (!empty($filters['search'])) {
            $sql .= ' AND file_path LIKE :search';
            $params['search'] = '%' . $filters['search'] . '%';
        }

        if (!empty($filters['change_type'])) {
            $sql .= ' AND change_type = :change_type';
            $params['change_type'] = $filters['change_type'];
        }

        $sql .= ' ORDER BY changed_at DESC LIMIT :limit OFFSET :offset';

        $stmt = $this->db->prepare($sql);
        foreach ($params as $key => $value) {
            $stmt->bindValue(':' . $key, $value);
        }
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Count total backups with filters.
     *
     * @param array<string, mixed> $filters
     */
    public function count(array $filters = []): int
    {
        $sql = 'SELECT COUNT(*) FROM file_backup_history WHERE 1=1';
        $params = [];

        if (!empty($filters['search'])) {
            $sql .= ' AND file_path LIKE :search';
            $params['search'] = '%' . $filters['search'] . '%';
        }

        if (!empty($filters['change_type'])) {
            $sql .= ' AND change_type = :change_type';
            $params['change_type'] = $filters['change_type'];
        }

        $stmt = $this->db->prepare($sql);
        $stmt->execute($params);

        return (int) $stmt->fetchColumn();
    }

    /**
     * Find backup by ID.
     */
    public function findById(int $id): ?array
    {
        $stmt = $this->db->prepare(
            'SELECT * FROM file_backup_history WHERE id = :id'
        );
        $stmt->execute(['id' => $id]);
        $result = $stmt->fetch(PDO::FETCH_ASSOC);

        return $result !== false ? $result : null;
    }

    /**
     * Find all backups for a specific file path.
     */
    public function findByFilePath(string $path): array
    {
        $stmt = $this->db->prepare(
            'SELECT id, file_path, content_hash, file_size, version, change_type,
                    changed_at, changed_by, reason
             FROM file_backup_history
             WHERE file_path = :path
             ORDER BY version DESC'
        );
        $stmt->execute(['path' => $path]);

        return $stmt->fetchAll(PDO::FETCH_ASSOC);
    }

    /**
     * Get statistics about backups.
     *
     * @return array<string, int>
     */
    public function getStatistics(): array
    {
        $stats = [];

        // Total backups
        $stats['total'] = (int) $this->db->query(
            'SELECT COUNT(*) FROM file_backup_history'
        )->fetchColumn();

        // Unique files
        $stats['files'] = (int) $this->db->query(
            'SELECT COUNT(DISTINCT file_path) FROM file_backup_history'
        )->fetchColumn();

        // Total versions (max version per file summed)
        $stats['versions'] = (int) $this->db->query(
            'SELECT COALESCE(SUM(max_version), 0) FROM (
                SELECT MAX(version) as max_version FROM file_backup_history GROUP BY file_path
            ) as v'
        )->fetchColumn();

        // Last 24 hours
        $stats['recent'] = (int) $this->db->query(
            'SELECT COUNT(*) FROM file_backup_history WHERE changed_at >= NOW() - INTERVAL 24 HOUR'
        )->fetchColumn();

        // By change type
        $stats['created'] = (int) $this->db->query(
            "SELECT COUNT(*) FROM file_backup_history WHERE change_type = 'created'"
        )->fetchColumn();

        $stats['modified'] = (int) $this->db->query(
            "SELECT COUNT(*) FROM file_backup_history WHERE change_type = 'modified'"
        )->fetchColumn();

        return $stats;
    }

    /**
     * Restore a file from backup.
     *
     * @throws \RuntimeException If restore fails
     * @throws \SecurityException If path validation fails
     */
    public function restore(int $id): bool
    {
        $backup = $this->findById($id);

        if ($backup === null) {
            throw new \RuntimeException('Backup not found');
        }

        $filePath = $backup['file_path'];
        $content = $backup['file_content'];

        // SECURITY: Validate the restore path before writing
        $this->validateRestorePath($filePath);

        // Check if directory exists
        $dir = dirname($filePath);
        if (!is_dir($dir)) {
            throw new \RuntimeException("Directory does not exist: {$dir}");
        }

        // Write content back to file
        $result = file_put_contents($filePath, $content);

        if ($result === false) {
            throw new \RuntimeException("Failed to write to file: {$filePath}");
        }

        // Log the restore action
        $this->logRestore($id, $filePath);

        return true;
    }

    /**
     * Validate that a file path is safe for restoration.
     *
     * @throws \RuntimeException If path is not allowed
     */
    private function validateRestorePath(string $filePath): void
    {
        // Check for path traversal attempts
        if (str_contains($filePath, '..')) {
            $this->logSecurityViolation('path_traversal', $filePath);
            throw new \RuntimeException('Invalid path: path traversal detected');
        }

        // Normalize the path
        $dir = dirname($filePath);
        $realDir = realpath($dir);

        if ($realDir === false) {
            throw new \RuntimeException('Invalid path: directory does not exist');
        }

        // Reconstruct full path with real directory
        $normalizedPath = $realDir . '/' . basename($filePath);

        // Check against allowlist
        $isAllowed = false;
        foreach (self::ALLOWED_RESTORE_PATHS as $allowedPath) {
            if (str_starts_with($normalizedPath, $allowedPath)) {
                $isAllowed = true;
                break;
            }
        }

        if (!$isAllowed) {
            $this->logSecurityViolation('path_not_allowed', $filePath);
            throw new \RuntimeException('Invalid path: restore location not permitted');
        }
    }

    /**
     * Log security violation attempts.
     */
    private function logSecurityViolation(string $type, string $filePath): void
    {
        $stmt = $this->db->prepare(
            "INSERT INTO mcp_log (tool, operation, parameters, result, logged_at)
             VALUES ('backup_restore', 'security_violation', :params, 'blocked', NOW())"
        );
        $stmt->execute([
            'params' => json_encode([
                'violation_type' => $type,
                'attempted_path' => $filePath,
                'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
            ]),
        ]);
    }

    /**
     * Log restore action to mcp_log.
     */
    private function logRestore(int $backupId, string $filePath): void
    {
        $stmt = $this->db->prepare(
            "INSERT INTO mcp_log (tool, operation, parameters, result, logged_at)
             VALUES ('backup_restore', 'restore', :params, 'success', NOW())"
        );
        $stmt->execute([
            'params' => json_encode(['backup_id' => $backupId, 'file_path' => $filePath]),
        ]);
    }

    /**
     * Get content preview (first N lines).
     */
    public function getContentPreview(int $id, int $maxLines = 500): ?string
    {
        $backup = $this->findById($id);

        if ($backup === null || empty($backup['file_content'])) {
            return null;
        }

        $lines = explode("\n", $backup['file_content']);

        if (count($lines) <= $maxLines) {
            return $backup['file_content'];
        }

        return implode("\n", array_slice($lines, 0, $maxLines)) . "\n\n... (" . (count($lines) - $maxLines) . ' weitere Zeilen)';
    }
}

Vollständig herunterladen

Aktionen

Herunterladen

Andere Versionen dieser Datei

ID Version Typ Größe Datum
133 4 modified 8.6 KB 2025-12-20 19:53
129 3 modified 8.6 KB 2025-12-20 19:40
117 2 modified 6.6 KB 2025-12-20 19:24
116 1 modified 6.3 KB 2025-12-20 19:24

← Zurück zur Übersicht