435 lines
14 KiB
PHP
435 lines
14 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Core\Uptelligence\Services;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
use Core\Uptelligence\Models\UptelligenceWebhook;
|
|
use Core\Uptelligence\Models\UptelligenceWebhookDelivery;
|
|
use Core\Uptelligence\Models\Vendor;
|
|
use Core\Uptelligence\Models\VersionRelease;
|
|
|
|
/**
|
|
* WebhookReceiverService - processes incoming vendor release webhooks.
|
|
*
|
|
* Handles webhook verification, payload parsing, and release record creation
|
|
* for GitHub releases, GitLab releases, npm publish, and Packagist webhooks.
|
|
*/
|
|
class WebhookReceiverService
|
|
{
|
|
// -------------------------------------------------------------------------
|
|
// Signature Verification
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Verify webhook signature.
|
|
*
|
|
* Returns signature status for logging.
|
|
*/
|
|
public function verifySignature(UptelligenceWebhook $webhook, string $payload, ?string $signature): string
|
|
{
|
|
if (empty($webhook->secret)) {
|
|
return UptelligenceWebhookDelivery::SIGNATURE_MISSING;
|
|
}
|
|
|
|
if (empty($signature)) {
|
|
return UptelligenceWebhookDelivery::SIGNATURE_MISSING;
|
|
}
|
|
|
|
$isValid = $webhook->verifySignature($payload, $signature);
|
|
|
|
return $isValid
|
|
? UptelligenceWebhookDelivery::SIGNATURE_VALID
|
|
: UptelligenceWebhookDelivery::SIGNATURE_INVALID;
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Payload Parsing
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse payload based on provider.
|
|
*
|
|
* Returns normalised release data or null if not a release event.
|
|
*
|
|
* @return array{
|
|
* event_type: string,
|
|
* version: string|null,
|
|
* tag_name: string|null,
|
|
* release_name: string|null,
|
|
* body: string|null,
|
|
* url: string|null,
|
|
* prerelease: bool,
|
|
* draft: bool,
|
|
* published_at: string|null,
|
|
* author: string|null,
|
|
* raw: array
|
|
* }|null
|
|
*/
|
|
public function parsePayload(string $provider, array $payload): ?array
|
|
{
|
|
return match ($provider) {
|
|
UptelligenceWebhook::PROVIDER_GITHUB => $this->parseGitHubPayload($payload),
|
|
UptelligenceWebhook::PROVIDER_GITLAB => $this->parseGitLabPayload($payload),
|
|
UptelligenceWebhook::PROVIDER_NPM => $this->parseNpmPayload($payload),
|
|
UptelligenceWebhook::PROVIDER_PACKAGIST => $this->parsePackagistPayload($payload),
|
|
default => $this->parseCustomPayload($payload),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse GitHub release webhook payload.
|
|
*
|
|
* GitHub sends:
|
|
* - action: published, created, edited, deleted, prereleased, released
|
|
* - release: { tag_name, name, body, draft, prerelease, created_at, published_at, author }
|
|
*/
|
|
protected function parseGitHubPayload(array $payload): ?array
|
|
{
|
|
// Only process release events
|
|
$action = $payload['action'] ?? null;
|
|
if (! in_array($action, ['published', 'released', 'created'])) {
|
|
return null;
|
|
}
|
|
|
|
$release = $payload['release'] ?? [];
|
|
if (empty($release)) {
|
|
return null;
|
|
}
|
|
|
|
$tagName = $release['tag_name'] ?? null;
|
|
$version = $this->normaliseVersion($tagName);
|
|
|
|
return [
|
|
'event_type' => "github.release.{$action}",
|
|
'version' => $version,
|
|
'tag_name' => $tagName,
|
|
'release_name' => $release['name'] ?? $tagName,
|
|
'body' => $release['body'] ?? null,
|
|
'url' => $release['html_url'] ?? null,
|
|
'prerelease' => (bool) ($release['prerelease'] ?? false),
|
|
'draft' => (bool) ($release['draft'] ?? false),
|
|
'published_at' => $release['published_at'] ?? $release['created_at'] ?? null,
|
|
'author' => $release['author']['login'] ?? null,
|
|
'raw' => $release,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse GitLab release webhook payload.
|
|
*
|
|
* GitLab sends:
|
|
* - object_kind: release
|
|
* - action: create, update, delete
|
|
* - tag: tag name
|
|
* - name, description, released_at
|
|
*/
|
|
protected function parseGitLabPayload(array $payload): ?array
|
|
{
|
|
$objectKind = $payload['object_kind'] ?? null;
|
|
$action = $payload['action'] ?? null;
|
|
|
|
// Handle release events
|
|
if ($objectKind === 'release' && in_array($action, ['create', 'update'])) {
|
|
$tagName = $payload['tag'] ?? null;
|
|
$version = $this->normaliseVersion($tagName);
|
|
|
|
return [
|
|
'event_type' => "gitlab.release.{$action}",
|
|
'version' => $version,
|
|
'tag_name' => $tagName,
|
|
'release_name' => $payload['name'] ?? $tagName,
|
|
'body' => $payload['description'] ?? null,
|
|
'url' => $payload['url'] ?? null,
|
|
'prerelease' => false,
|
|
'draft' => false,
|
|
'published_at' => $payload['released_at'] ?? $payload['created_at'] ?? null,
|
|
'author' => null,
|
|
'raw' => $payload,
|
|
];
|
|
}
|
|
|
|
// Handle tag push events (may indicate release)
|
|
if ($objectKind === 'tag_push') {
|
|
$ref = $payload['ref'] ?? '';
|
|
$tagName = str_replace('refs/tags/', '', $ref);
|
|
$version = $this->normaliseVersion($tagName);
|
|
|
|
// Only process if it looks like a version tag
|
|
if ($version && $this->isVersionTag($tagName)) {
|
|
return [
|
|
'event_type' => 'gitlab.tag.push',
|
|
'version' => $version,
|
|
'tag_name' => $tagName,
|
|
'release_name' => $tagName,
|
|
'body' => null,
|
|
'url' => null,
|
|
'prerelease' => false,
|
|
'draft' => false,
|
|
'published_at' => null,
|
|
'author' => $payload['user_name'] ?? null,
|
|
'raw' => $payload,
|
|
];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse npm publish webhook payload.
|
|
*
|
|
* npm sends:
|
|
* - event: package:publish
|
|
* - name: package name
|
|
* - version: version number
|
|
* - dist-tags: { latest, next, etc. }
|
|
*/
|
|
protected function parseNpmPayload(array $payload): ?array
|
|
{
|
|
$event = $payload['event'] ?? null;
|
|
|
|
// Handle package publish events
|
|
if ($event !== 'package:publish') {
|
|
return null;
|
|
}
|
|
|
|
$version = $payload['version'] ?? null;
|
|
if (empty($version)) {
|
|
return null;
|
|
}
|
|
|
|
$distTags = $payload['dist-tags'] ?? [];
|
|
$isLatest = ($distTags['latest'] ?? null) === $version;
|
|
|
|
return [
|
|
'event_type' => 'npm.package.publish',
|
|
'version' => $version,
|
|
'tag_name' => $version,
|
|
'release_name' => ($payload['name'] ?? 'Package')." v{$version}",
|
|
'body' => null,
|
|
'url' => isset($payload['name']) ? "https://www.npmjs.com/package/{$payload['name']}/v/{$version}" : null,
|
|
'prerelease' => ! $isLatest,
|
|
'draft' => false,
|
|
'published_at' => $payload['time'] ?? null,
|
|
'author' => $payload['maintainers'][0]['name'] ?? null,
|
|
'raw' => $payload,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse Packagist webhook payload.
|
|
*
|
|
* Packagist sends:
|
|
* - package: { name, url }
|
|
* - versions: array of version objects
|
|
*/
|
|
protected function parsePackagistPayload(array $payload): ?array
|
|
{
|
|
$package = $payload['package'] ?? $payload['repository'] ?? [];
|
|
$versions = $payload['versions'] ?? [];
|
|
|
|
// Find the latest version
|
|
if (empty($versions)) {
|
|
return null;
|
|
}
|
|
|
|
// Get the most recent version (first in array or highest semver)
|
|
$latestVersion = null;
|
|
$latestVersionData = null;
|
|
|
|
foreach ($versions as $versionKey => $versionData) {
|
|
// Skip dev versions
|
|
if (str_contains($versionKey, 'dev-')) {
|
|
continue;
|
|
}
|
|
|
|
$normalised = $this->normaliseVersion($versionKey);
|
|
if ($normalised && (! $latestVersion || version_compare($normalised, $latestVersion, '>'))) {
|
|
$latestVersion = $normalised;
|
|
$latestVersionData = $versionData;
|
|
}
|
|
}
|
|
|
|
if (! $latestVersion) {
|
|
return null;
|
|
}
|
|
|
|
return [
|
|
'event_type' => 'packagist.package.update',
|
|
'version' => $latestVersion,
|
|
'tag_name' => $latestVersionData['version'] ?? $latestVersion,
|
|
'release_name' => ($package['name'] ?? 'Package')." {$latestVersion}",
|
|
'body' => $latestVersionData['description'] ?? null,
|
|
'url' => $package['url'] ?? null,
|
|
'prerelease' => false,
|
|
'draft' => false,
|
|
'published_at' => $latestVersionData['time'] ?? null,
|
|
'author' => $latestVersionData['authors'][0]['name'] ?? null,
|
|
'raw' => $payload,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse custom webhook payload.
|
|
*
|
|
* Accepts a flexible format for custom integrations.
|
|
*/
|
|
protected function parseCustomPayload(array $payload): ?array
|
|
{
|
|
// Try common field names for version
|
|
$version = $payload['version']
|
|
?? $payload['tag']
|
|
?? $payload['tag_name']
|
|
?? $payload['release']['version']
|
|
?? $payload['release']['tag_name']
|
|
?? null;
|
|
|
|
if (empty($version)) {
|
|
return null;
|
|
}
|
|
|
|
$normalised = $this->normaliseVersion($version);
|
|
|
|
return [
|
|
'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'custom.release',
|
|
'version' => $normalised ?? $version,
|
|
'tag_name' => $version,
|
|
'release_name' => $payload['name'] ?? $payload['release_name'] ?? $version,
|
|
'body' => $payload['body'] ?? $payload['description'] ?? $payload['changelog'] ?? null,
|
|
'url' => $payload['url'] ?? $payload['release_url'] ?? null,
|
|
'prerelease' => (bool) ($payload['prerelease'] ?? false),
|
|
'draft' => (bool) ($payload['draft'] ?? false),
|
|
'published_at' => $payload['published_at'] ?? $payload['released_at'] ?? $payload['timestamp'] ?? null,
|
|
'author' => $payload['author'] ?? null,
|
|
'raw' => $payload,
|
|
];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Release Processing
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Process a parsed release and create/update vendor version record.
|
|
*
|
|
* @return array{action: string, release_id: int|null, version: string|null}
|
|
*/
|
|
public function processRelease(
|
|
UptelligenceWebhookDelivery $delivery,
|
|
Vendor $vendor,
|
|
array $parsedData
|
|
): array {
|
|
$version = $parsedData['version'] ?? null;
|
|
|
|
if (empty($version)) {
|
|
return [
|
|
'action' => 'skipped',
|
|
'release_id' => null,
|
|
'version' => null,
|
|
'reason' => 'No version found in payload',
|
|
];
|
|
}
|
|
|
|
// Skip draft releases
|
|
if ($parsedData['draft'] ?? false) {
|
|
return [
|
|
'action' => 'skipped',
|
|
'release_id' => null,
|
|
'version' => $version,
|
|
'reason' => 'Draft release',
|
|
];
|
|
}
|
|
|
|
// Check if this version already exists
|
|
$existingRelease = VersionRelease::where('vendor_id', $vendor->id)
|
|
->where('version', $version)
|
|
->first();
|
|
|
|
if ($existingRelease) {
|
|
Log::info('Uptelligence webhook: Version already exists', [
|
|
'vendor_id' => $vendor->id,
|
|
'version' => $version,
|
|
'release_id' => $existingRelease->id,
|
|
]);
|
|
|
|
return [
|
|
'action' => 'exists',
|
|
'release_id' => $existingRelease->id,
|
|
'version' => $version,
|
|
];
|
|
}
|
|
|
|
// Create new version release record
|
|
$release = VersionRelease::create([
|
|
'vendor_id' => $vendor->id,
|
|
'version' => $version,
|
|
'previous_version' => $vendor->current_version,
|
|
'metadata_json' => [
|
|
'release_name' => $parsedData['release_name'] ?? null,
|
|
'body' => $parsedData['body'] ?? null,
|
|
'url' => $parsedData['url'] ?? null,
|
|
'prerelease' => $parsedData['prerelease'] ?? false,
|
|
'published_at' => $parsedData['published_at'] ?? null,
|
|
'author' => $parsedData['author'] ?? null,
|
|
'webhook_delivery_id' => $delivery->id,
|
|
'event_type' => $parsedData['event_type'] ?? null,
|
|
],
|
|
]);
|
|
|
|
// Update vendor's current version
|
|
$vendor->update([
|
|
'previous_version' => $vendor->current_version,
|
|
'current_version' => $version,
|
|
'last_checked_at' => now(),
|
|
]);
|
|
|
|
Log::info('Uptelligence webhook: New release recorded', [
|
|
'vendor_id' => $vendor->id,
|
|
'vendor_name' => $vendor->name,
|
|
'version' => $version,
|
|
'release_id' => $release->id,
|
|
]);
|
|
|
|
return [
|
|
'action' => 'created',
|
|
'release_id' => $release->id,
|
|
'version' => $version,
|
|
];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Normalise a version string by removing common prefixes.
|
|
*/
|
|
protected function normaliseVersion(?string $version): ?string
|
|
{
|
|
if (empty($version)) {
|
|
return null;
|
|
}
|
|
|
|
// Remove common prefixes
|
|
$normalised = preg_replace('/^v(?:ersion)?[.\-]?/i', '', $version);
|
|
|
|
// Validate it looks like a version number
|
|
if (preg_match('/^\d+\.\d+/', $normalised)) {
|
|
return $normalised;
|
|
}
|
|
|
|
// If it doesn't look like a version, return as-is
|
|
return $version;
|
|
}
|
|
|
|
/**
|
|
* Check if a tag name looks like a version tag.
|
|
*/
|
|
protected function isVersionTag(string $tagName): bool
|
|
{
|
|
// Common version patterns
|
|
return (bool) preg_match('/^v?\d+\.\d+(\.\d+)?/', $tagName);
|
|
}
|
|
}
|