167 lines
5.8 KiB
PHP
167 lines
5.8 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Revision Service — orchestrates creating, restoring, and pruning revisions.
|
|
*
|
|
* @author Ball Studios <https://git.balls.studio>
|
|
* @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}")]
|
|
);
|
|
}
|
|
}
|