Files
pterodactyl_addon/app/Addons/AdvancedAdmin/Services/Revisions/RevisionStorageService.php
2026-06-11 20:33:44 -05:00

135 lines
4.1 KiB
PHP

<?php
/**
* File Revision Storage Service
* Handles compressed storage, path traversal prevention, and binary detection.
*
* @author Ball Studios <https://git.balls.studio>
* @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.');
}
}
}