* @license MIT */ namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions; use Illuminate\Support\Facades\DB; use Pterodactyl\Addons\AdvancedAdmin\Models\FileRevision; use Pterodactyl\Addons\AdvancedAdmin\Models\RoleAuditLog; use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\RevisionStorageService; use Pterodactyl\Addons\AdvancedAdmin\Services\Revisions\ExclusionMatcher; class RevisionService { public function __construct( private readonly RevisionStorageService $storage, private readonly ExclusionMatcher $exclusions, ) {} /** * Record a new revision for a file. * Returns null if the file should be skipped (excluded, too large, binary unchanged, duplicate). */ public function record( int $serverId, string $filePath, string $content, ?int $authorId = null, string $summary = '' ): ?FileRevision { // Security: sanitize path $filePath = $this->storage->guardFilePath($filePath); // Skip excluded files if ($this->exclusions->isExcluded($filePath)) { return null; } // Skip files above size limit $maxSize = config('advanced-admin.revisions.max_file_size', 10 * 1024 * 1024); if (strlen($content) > $maxSize) { return null; } $pathHash = $this->storage->pathHash($serverId, $filePath); $contentHash = $this->storage->contentHash($content); // Deduplicate: if latest revision has same content, skip $latest = FileRevision::query() ->where('server_id', $serverId) ->where('path_hash', $pathHash) ->orderByDesc('revision_number') ->first(); if ($latest && $latest->content_hash === $contentHash) { return null; // No change } $revisionNumber = ($latest?->revision_number ?? 0) + 1; return DB::transaction(function () use ( $serverId, $filePath, $pathHash, $contentHash, $revisionNumber, $content, $authorId, $summary ) { $stored = $this->storage->store($serverId, $pathHash, $revisionNumber, $content); $revision = FileRevision::create([ 'server_id' => $serverId, 'file_path' => $filePath, 'path_hash' => $pathHash, 'content_hash' => $contentHash, 'revision_number' => $revisionNumber, 'storage_key' => $stored['storage_key'], 'storage_type' => 'full', 'size_bytes' => $stored['size_bytes'], 'compressed_size' => $stored['compressed_size'], 'author_id' => $authorId, 'change_summary' => $summary ?: 'File edited via panel', ]); $this->updateStorageUsage($serverId, $stored['compressed_size']); $this->enforceRetentionLimits($serverId, $pathHash); RoleAuditLog::record($authorId, 'revision.created', 'file_revision', $revision->id, [ 'server_id' => $serverId, 'file_path' => $filePath, 'revision' => $revisionNumber, ]); return $revision; }); } /** * Restore a revision — returns the stored content. */ public function restore(FileRevision $revision, ?int $actorId = null): string { $content = $this->storage->retrieve($revision->storage_key); RoleAuditLog::record($actorId, 'revision.restored', 'file_revision', $revision->id, [ 'server_id' => $revision->server_id, 'file_path' => $revision->file_path, 'revision' => $revision->revision_number, ]); return $content; } /** * Delete a specific revision from storage and DB. */ public function delete(FileRevision $revision): void { DB::transaction(function () use ($revision) { $this->storage->delete($revision->storage_key); $this->updateStorageUsage($revision->server_id, -$revision->compressed_size); $revision->delete(); }); } /** * Enforce per-file and per-server retention limits. * Deletes oldest revisions beyond the cap. */ public function enforceRetentionLimits(int $serverId, string $pathHash): void { $maxPerFile = config('advanced-admin.revisions.max_revisions_per_file', 50); $revisions = FileRevision::query() ->where('server_id', $serverId) ->where('path_hash', $pathHash) ->orderByDesc('revision_number') ->get(); $toDelete = $revisions->slice($maxPerFile); foreach ($toDelete as $old) { $this->delete($old); } // Per-server storage cap $maxStorage = config('advanced-admin.revisions.max_storage_per_server', 500 * 1024 * 1024); $usage = \Pterodactyl\Addons\AdvancedAdmin\Models\FileRevisionStorageUsage::find($serverId); if ($usage && $usage->total_bytes > $maxStorage) { // Delete oldest revisions across all files until under cap $oldest = FileRevision::query() ->where('server_id', $serverId) ->orderBy('created_at') ->first(); if ($oldest) $this->delete($oldest); } } private function updateStorageUsage(int $serverId, int $delta): void { \Pterodactyl\Addons\AdvancedAdmin\Models\FileRevisionStorageUsage::updateOrCreate( ['server_id' => $serverId], ['total_bytes' => DB::raw("total_bytes + {$delta}")] ); } }