Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
388 changes: 23 additions & 365 deletions phpstan-baseline.neon

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ parameters:
tmpDir: build/phpstan
checkOctaneCompatibility: true
checkModelProperties: true
ignoreErrors:
-
message: '#^Parameter \#1 \$view of function view expects view\-string\|null, string given\.$#'
reportUnmatched: false

89 changes: 2 additions & 87 deletions src/Http/Controllers/QueueMonitorController.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Storvia\Vantage\Models\VantageJob;
use Storvia\Vantage\Support\JobRestorer;
use Storvia\Vantage\Support\QueueDepthChecker;
use Storvia\Vantage\Support\TagAggregator;
use Storvia\Vantage\Support\VantageLogger;
Expand Down Expand Up @@ -424,7 +425,7 @@ public function retry($id)
}

// Safely restore job from payload with security checks
$job = $this->restoreJobFromPayload($run, $jobClass);
$job = app(JobRestorer::class)->restore($run, $jobClass);

if (! $job) {
return back()->with('error', 'Unable to restore job. Payload might be missing or corrupted.');
Expand All @@ -450,92 +451,6 @@ public function retry($id)
}
}

/**
* Safely restore job from payload with security checks.
*
* @param VantageJob $run The job run record
* @param string $expectedJobClass The expected job class name for validation
* @return object|null The restored job object or null on failure
*/
protected function restoreJobFromPayload(VantageJob $run, string $expectedJobClass): ?object
{
if (! $run->payload) {
return null;
}

// Validate expected class exists and is a valid job class
if (! class_exists($expectedJobClass)) {
VantageLogger::warning('Vantage: Expected job class does not exist', [
'run_id' => $run->id,
'expected_class' => $expectedJobClass,
]);

return null;
}

try {
$payload = is_array($run->payload) ? $run->payload : json_decode($run->payload, true);

if (! is_array($payload)) {
VantageLogger::warning('Vantage: Invalid payload format', ['run_id' => $run->id]);

return null;
}

// Get the serialized command from Laravel's raw payload
$serialized = $payload['raw_payload']['data']['command'] ?? null;

// Fallback to old format if new format not available
if (! $serialized) {
$serialized = $payload['data']['command'] ?? null;
}

if (! $serialized || ! is_string($serialized)) {
VantageLogger::warning('Vantage: No serialized command in payload', ['run_id' => $run->id]);

return null;
}

// Unserialize with security: only allow the expected job class
$job = @unserialize($serialized, ['allowed_classes' => [$expectedJobClass]]);

if (! is_object($job)) {
VantageLogger::warning('Vantage: Unserialize did not return object', [
'run_id' => $run->id,
'result_type' => gettype($job),
]);

return null;
}

// Double-check the class matches the expected class (security validation)
if (! $job instanceof $expectedJobClass) {
VantageLogger::warning('Vantage: Unserialized job class does not match expected class', [
'run_id' => $run->id,
'expected_class' => $expectedJobClass,
'actual_class' => get_class($job),
]);

return null;
}

VantageLogger::info('Vantage: Successfully restored job', [
'run_id' => $run->id,
'job_class' => get_class($job),
]);

return $job;

} catch (\Throwable $e) {
VantageLogger::error('Vantage: Exception while restoring job from payload', [
'run_id' => $run->id,
'error' => $e->getMessage(),
]);

return null;
}
}

/**
* Get retry chain
*/
Expand Down
2 changes: 1 addition & 1 deletion src/Http/Middleware/AuthorizeVantage.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class AuthorizeVantage
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
Expand Down
1 change: 1 addition & 0 deletions src/Listeners/RecordJobSuccess.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Storvia\Vantage\Listeners;

use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Support\Str;
use Storvia\Vantage\Models\VantageJob;
use Storvia\Vantage\Support\JobPerformanceContext;
use Storvia\Vantage\Support\PayloadExtractor;
Expand Down
28 changes: 28 additions & 0 deletions src/Models/VantageJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,36 @@

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Storvia\Vantage\Database\Factories\VantageJobFactory;

/**
* @property int $id
* @property string $status
* @property string $job_uuid
* @property string $job_class
* @property string $queue
* @property string|null $connection
* @property int $attempt
* @property int|null $retried_from_id
* @property array|null $payload
* @property array|null $job_tags
* @property string|null $exception_class
* @property string|null $exception_message
* @property string|null $stack
* @property Carbon|null $started_at
* @property Carbon|null $finished_at
* @property int|null $duration_ms
* @property int|null $memory_start_bytes
* @property int|null $memory_end_bytes
* @property int|null $memory_peak_start_bytes
* @property int|null $memory_peak_end_bytes
* @property int|null $memory_peak_delta_bytes
* @property int|null $cpu_user_ms
* @property int|null $cpu_sys_ms
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class VantageJob extends Model
{
use HasFactory;
Expand Down
91 changes: 91 additions & 0 deletions src/Support/JobRestorer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Storvia\Vantage\Support;

use Storvia\Vantage\Models\VantageJob;

class JobRestorer
{
/**
* Safely restore a job instance from its stored payload.
*
* Performs the following security checks before returning an object:
* - The expected class must exist in the application.
* - The stored payload must be a valid JSON array.
* - The serialized command must be present (new or legacy format).
* - Unserialize is restricted to the expected class only.
* - The resulting object must be an instance of the expected class.
*/
public function restore(VantageJob $job, string $expectedJobClass): ?object
{
if (! $job->payload) {
return null;
}

if (! class_exists($expectedJobClass)) {
VantageLogger::warning('Vantage: Expected job class does not exist', [
'job_id' => $job->id,
'expected_class' => $expectedJobClass,
]);

return null;
}

try {
$payload = is_array($job->payload) ? $job->payload : json_decode($job->payload, true);

if (! is_array($payload)) {
VantageLogger::warning('Vantage: Invalid payload format', ['job_id' => $job->id]);

return null;
}

// Support both the new format (from PayloadExtractor) and the legacy format.
$serialized = $payload['raw_payload']['data']['command']
?? $payload['data']['command']
?? null;

if (! $serialized || ! is_string($serialized)) {
VantageLogger::warning('Vantage: No serialized command in payload', ['job_id' => $job->id]);

return null;
}

$command = @unserialize($serialized, ['allowed_classes' => [$expectedJobClass]]);

if (! is_object($command)) {
VantageLogger::warning('Vantage: Unserialize did not return an object', [
'job_id' => $job->id,
'result_type' => gettype($command),
]);

return null;
}

if (! $command instanceof $expectedJobClass) {
VantageLogger::warning('Vantage: Unserialized job class does not match expected class', [
'job_id' => $job->id,
'expected_class' => $expectedJobClass,
'actual_class' => get_class($command),
]);

return null;
}

VantageLogger::info('Vantage: Successfully restored job from payload', [
'job_id' => $job->id,
'job_class' => get_class($command),
]);

return $command;

} catch (\Throwable $e) {
VantageLogger::error('Vantage: Exception while restoring job from payload', [
'job_id' => $job->id,
'error' => $e->getMessage(),
]);

return null;
}
}
}
3 changes: 2 additions & 1 deletion src/Support/QueueDepthChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Storvia\Vantage\Models\VantageJob;

class QueueDepthChecker
{
Expand Down Expand Up @@ -153,7 +154,7 @@ protected static function getFallbackQueueDepth(?string $queueName, string $driv
// For unsupported drivers, we can still show what we know from job_runs
// Count jobs that are processing or recently started (might be queued)
try {
$query = \Storvia\Vantage\Models\VantageJob::where('status', 'processing');
$query = VantageJob::where('status', 'processing');

if ($queueName) {
$query->where('queue', $queueName);
Expand Down
12 changes: 7 additions & 5 deletions src/Support/TagAggregator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Storvia\Vantage\Support;

use Carbon\Carbon;
use Illuminate\Database\Connection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
Expand All @@ -28,7 +30,7 @@ class TagAggregator

protected string $connectionName;

protected \Illuminate\Database\Connection $connection;
protected Connection $connection;

protected string $jobsTable;

Expand All @@ -47,7 +49,7 @@ public function __construct()
/**
* Get top tags with statistics, using the most efficient method available.
*
* @param \Carbon\Carbon $since Filter jobs created after this date
* @param Carbon $since Filter jobs created after this date
* @param int $limit Maximum number of tags to return
* @return Collection Collection of tag statistics
*/
Expand All @@ -65,7 +67,7 @@ public function getTopTags($since, int $limit = 10): Collection
/**
* Get detailed tag statistics including duration averages.
*
* @param \Carbon\Carbon $since Filter jobs created after this date
* @param Carbon $since Filter jobs created after this date
* @return array Associative array of tag => statistics
*/
public function getTagStats($since): array
Expand Down Expand Up @@ -602,7 +604,7 @@ protected function formatTagStatsResult($rows): array
*
* @param int $jobId The job ID
* @param array $tags Array of tag strings
* @param \Carbon\Carbon|null $createdAt The job's created_at timestamp
* @param Carbon|null $createdAt The job's created_at timestamp
*/
public function insertJobTags(int $jobId, array $tags, $createdAt = null): void
{
Expand Down Expand Up @@ -665,7 +667,7 @@ public function deleteJobTags(int $jobId): void
* Delete old tags based on job created_at timestamp.
* Used by the prune command.
*
* @param \Carbon\Carbon $before Delete tags for jobs created before this date
* @param Carbon $before Delete tags for jobs created before this date
* @return int Number of tags deleted
*/
public function pruneOldTags($before): int
Expand Down
Loading
Loading