* @license MIT */ namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions; use Illuminate\Support\Facades\Storage; use RuntimeException; class RevisionStorageService { private string $disk = 'local'; /** * Store file content, returning the storage key. * Content is gzip-compressed before storage. */ public function store(int $serverId, string $pathHash, int $revisionNumber, string $content): array { $key = $this->buildKey($serverId, $pathHash, $revisionNumber); $compressed = gzencode($content, 6); if ($compressed === false) { throw new RuntimeException('Failed to compress revision content.'); } Storage::disk($this->disk)->put($key, $compressed); return [ 'storage_key' => $key, 'size_bytes' => strlen($content), 'compressed_size' => strlen($compressed), ]; } /** * Retrieve and decompress stored content. */ public function retrieve(string $storageKey): string { $this->guardKey($storageKey); $compressed = Storage::disk($this->disk)->get($storageKey); if ($compressed === null) { throw new RuntimeException("Revision file not found: {$storageKey}"); } $content = gzdecode($compressed); if ($content === false) { throw new RuntimeException("Failed to decompress revision: {$storageKey}"); } return $content; } /** * Delete a stored revision file. */ public function delete(string $storageKey): void { $this->guardKey($storageKey); Storage::disk($this->disk)->delete($storageKey); } /** * Get the raw compressed file for streaming download. */ public function readStream(string $storageKey) { $this->guardKey($storageKey); return Storage::disk($this->disk)->readStream($storageKey); } /** * Detect if content is binary (non-diffable). */ public function isBinary(string $content): bool { // Check for null bytes — reliable binary indicator return str_contains($content, "\0"); } /** * Compute SHA-256 of content. */ public function contentHash(string $content): string { return hash('sha256', $content); } /** * Compute path hash (server-scoped, prevents cross-server lookup). */ public function pathHash(int $serverId, string $filePath): string { return hash('sha256', $serverId . ':' . $filePath); } /** * Validate a file path is within the server's allowed root. * Prevents path traversal attacks. * * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ public function guardFilePath(string $filePath): string { // Normalize — reject absolute paths or traversal sequences $normalized = str_replace('\\', '/', $filePath); if (str_starts_with($normalized, '/') || str_contains($normalized, '../') || str_contains($normalized, './')) { abort(422, 'Invalid file path.'); } return $normalized; } // ─── Internal ───────────────────────────────────────────────────────────── private function buildKey(int $serverId, string $pathHash, int $revisionNumber): string { $base = config('advanced-admin.revisions.storage_path', 'addons/revisions'); return "{$base}/{$serverId}/{$pathHash}/{$revisionNumber}.gz"; } /** * Prevent storage key manipulation — only allow keys within our storage prefix. */ private function guardKey(string $storageKey): void { $base = config('advanced-admin.revisions.storage_path', 'addons/revisions'); if (!str_starts_with($storageKey, $base . '/')) { abort(403, 'Invalid storage key.'); } } }