File transfer

This commit is contained in:
2026-06-11 20:33:44 -05:00
parent 2cb1d58264
commit b362357bcb
58 changed files with 5096 additions and 4 deletions

View File

@@ -0,0 +1,116 @@
<?php
/**
* Diff Service — generates unified diffs between two text contents.
*
* Uses SebastianBergmann\Diff if available, falls back to simple line diff.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions;
class DiffService
{
/**
* Generate a unified diff between old and new content.
* Returns array of lines with type: 'context' | 'added' | 'removed' | 'header'
*/
public function diff(string $oldContent, string $newContent, string $fileName = 'file'): array
{
if (class_exists(\SebastianBergmann\Diff\Differ::class)) {
return $this->diffViaSebastian($oldContent, $newContent, $fileName);
}
return $this->diffNative($oldContent, $newContent);
}
/**
* Compute a structured diff using SebastianBergmann\Diff.
*/
private function diffViaSebastian(string $old, string $new, string $fileName): array
{
$differ = new \SebastianBergmann\Diff\Differ(
new \SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder("--- a/{$fileName}\n+++ b/{$fileName}\n")
);
$rawDiff = $differ->diff($old, $new);
return $this->parseUnifiedDiff($rawDiff);
}
/**
* Simple pure-PHP line-level diff (fallback).
*/
private function diffNative(string $old, string $new): array
{
$oldLines = explode("\n", $old);
$newLines = explode("\n", $new);
$result = [];
// LCS-based diff
$lcs = $this->lcs($oldLines, $newLines);
$i = 0; $j = 0; $k = 0;
while ($i < count($oldLines) || $j < count($newLines)) {
if ($k < count($lcs) && $i < count($oldLines) && $oldLines[$i] === $lcs[$k] && $j < count($newLines) && $newLines[$j] === $lcs[$k]) {
$result[] = ['type' => 'context', 'content' => $oldLines[$i], 'line_old' => $i + 1, 'line_new' => $j + 1];
$i++; $j++; $k++;
} elseif ($j < count($newLines) && ($k >= count($lcs) || $newLines[$j] !== $lcs[$k])) {
$result[] = ['type' => 'added', 'content' => $newLines[$j], 'line_new' => $j + 1];
$j++;
} else {
$result[] = ['type' => 'removed', 'content' => $oldLines[$i], 'line_old' => $i + 1];
$i++;
}
}
return $result;
}
private function parseUnifiedDiff(string $diff): array
{
$lines = explode("\n", $diff);
$result = [];
$oldLine = 0; $newLine = 0;
foreach ($lines as $line) {
if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
$result[] = ['type' => 'header', 'content' => $line];
} elseif (str_starts_with($line, '@@')) {
// Parse @@ -old +new @@
preg_match('/@@ -(\d+).*\+(\d+)/', $line, $m);
$oldLine = (int) ($m[1] ?? 0);
$newLine = (int) ($m[2] ?? 0);
$result[] = ['type' => 'header', 'content' => $line];
} elseif (str_starts_with($line, '-')) {
$result[] = ['type' => 'removed', 'content' => substr($line, 1), 'line_old' => $oldLine++];
} elseif (str_starts_with($line, '+')) {
$result[] = ['type' => 'added', 'content' => substr($line, 1), 'line_new' => $newLine++];
} elseif (str_starts_with($line, ' ')) {
$result[] = ['type' => 'context', 'content' => substr($line, 1), 'line_old' => $oldLine++, 'line_new' => $newLine++];
}
}
return $result;
}
/**
* Longest Common Subsequence (for native diff).
*/
private function lcs(array $a, array $b): array
{
$m = count($a); $n = count($b);
$dp = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
for ($i = 1; $i <= $m; $i++) {
for ($j = 1; $j <= $n; $j++) {
$dp[$i][$j] = $a[$i-1] === $b[$j-1]
? $dp[$i-1][$j-1] + 1
: max($dp[$i-1][$j], $dp[$i][$j-1]);
}
}
$result = []; $i = $m; $j = $n;
while ($i > 0 && $j > 0) {
if ($a[$i-1] === $b[$j-1]) { array_unshift($result, $a[$i-1]); $i--; $j--; }
elseif ($dp[$i-1][$j] > $dp[$i][$j-1]) { $i--; }
else { $j--; }
}
return $result;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/**
* Exclusion Matcher — determines if a file path matches any exclusion glob pattern.
*
* @author Ball Studios <https://git.balls.studio>
* @license MIT
*/
namespace Pterodactyl\Addons\AdvancedAdmin\Services\Revisions;
class ExclusionMatcher
{
/** @var string[] */
private array $patterns;
public function __construct()
{
$this->patterns = config('advanced-admin.revisions.excluded_patterns', []);
}
/**
* Returns true if the file should be excluded from tracking.
*/
public function isExcluded(string $filePath): bool
{
$basename = basename($filePath);
foreach ($this->patterns as $pattern) {
// Match against both full path and basename
if (fnmatch($pattern, $basename, FNM_CASEFOLD)) {
return true;
}
if (fnmatch($pattern, $filePath, FNM_CASEFOLD | FNM_PATHNAME)) {
return true;
}
// Also check for case-insensitive substring matches on sensitive keywords
if (str_contains(strtolower($basename), 'secret') ||
str_contains(strtolower($basename), 'password') ||
str_contains(strtolower($basename), 'token') && str_ends_with($basename, '.json')
) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,166 @@
<?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}")]
);
}
}

View File

@@ -0,0 +1,134 @@
<?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.');
}
}
}