Skip to content

Commit 89d154b

Browse files
simonhampclaude
andcommitted
Add plugin detail page with GitHub webhook sync
- Add plugin detail page at /plugins/{plugin} - Plugin cards now link to detail page instead of external URLs - Add iOS/Android version fields to plugins table - Add repository_url, webhook_secret, readme_html fields for sync - Create PluginSyncService to fetch README, composer.json, nativephp.json from GitHub - Add webhook endpoint for GitHub to trigger syncs on push - Update plugin submission form to require GitHub repository URL - Show webhook setup instructions to plugin authors - Render README content on plugin detail page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cd29826 commit 89d154b

14 files changed

+595
-50
lines changed

app/Http/Controllers/CustomerPluginController.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,17 @@ public function store(SubmitPluginRequest $request): RedirectResponse
3434
{
3535
$user = Auth::user();
3636

37-
$user->plugins()->create([
37+
$plugin = $user->plugins()->create([
3838
'name' => $request->name,
39+
'repository_url' => $request->repository_url,
3940
'type' => $request->type,
4041
'anystack_id' => $request->anystack_id,
4142
'status' => PluginStatus::Pending,
4243
]);
4344

44-
return redirect()->route('customer.plugins.index')
45+
$plugin->generateWebhookSecret();
46+
47+
return redirect()->route('customer.plugins.show', $plugin)
4548
->with('success', 'Your plugin has been submitted for review!');
4649
}
4750

app/Http/Controllers/PluginDirectoryController.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,13 @@ public function index(): View
2828
'latestPlugins' => $latestPlugins,
2929
]);
3030
}
31+
32+
public function show(Plugin $plugin): View
33+
{
34+
abort_unless($plugin->isApproved(), 404);
35+
36+
return view('plugin-show', [
37+
'plugin' => $plugin,
38+
]);
39+
}
3140
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Plugin;
6+
use App\Services\PluginSyncService;
7+
use Illuminate\Http\JsonResponse;
8+
use Illuminate\Http\Request;
9+
10+
class PluginWebhookController extends Controller
11+
{
12+
public function __invoke(Request $request, string $secret, PluginSyncService $syncService): JsonResponse
13+
{
14+
$plugin = Plugin::where('webhook_secret', $secret)->first();
15+
16+
if (! $plugin) {
17+
return response()->json(['error' => 'Invalid webhook secret'], 404);
18+
}
19+
20+
if (! $plugin->isApproved()) {
21+
return response()->json(['error' => 'Plugin is not approved'], 403);
22+
}
23+
24+
$synced = $syncService->sync($plugin);
25+
26+
if (! $synced) {
27+
return response()->json(['error' => 'Failed to sync plugin'], 500);
28+
}
29+
30+
return response()->json([
31+
'success' => true,
32+
'synced_at' => $plugin->fresh()->last_synced_at->toIso8601String(),
33+
]);
34+
}
35+
}

app/Http/Middleware/VerifyCsrfToken.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
1414
protected $except = [
1515
'stripe/webhook',
1616
'opencollective/contribution',
17+
'webhooks/plugins/*',
1718
];
1819
}

app/Http/Requests/SubmitPluginRequest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ public function rules(): array
2323
'regex:/^[a-z0-9]([_.-]?[a-z0-9]+)*\/[a-z0-9]([_.-]?[a-z0-9]+)*$/i',
2424
'unique:plugins,name',
2525
],
26+
'repository_url' => [
27+
'required',
28+
'url',
29+
'max:255',
30+
'regex:/^https:\/\/github\.com\/[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/',
31+
],
2632
'type' => ['required', 'string', Rule::enum(PluginType::class)],
2733
'anystack_id' => [
2834
'nullable',
@@ -39,6 +45,9 @@ public function messages(): array
3945
'name.required' => 'Please enter your plugin\'s Composer package name.',
4046
'name.regex' => 'Please enter a valid Composer package name (e.g., vendor/package-name).',
4147
'name.unique' => 'This plugin has already been submitted.',
48+
'repository_url.required' => 'Please enter your plugin\'s GitHub repository URL.',
49+
'repository_url.url' => 'Please enter a valid URL.',
50+
'repository_url.regex' => 'Please enter a valid GitHub repository URL (e.g., https://github.com/vendor/repo).',
4251
'type.required' => 'Please select whether your plugin is free or paid.',
4352
'type.enum' => 'Please select a valid plugin type.',
4453
'anystack_id.required_if' => 'Please enter your Anystack Product ID for paid plugins.',

app/Models/Plugin.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class Plugin extends Model
2424
'type' => PluginType::class,
2525
'approved_at' => 'datetime',
2626
'featured' => 'boolean',
27+
'composer_data' => 'array',
28+
'nativephp_data' => 'array',
29+
'last_synced_at' => 'datetime',
2730
];
2831

2932
protected static function booted(): void
@@ -130,6 +133,43 @@ public function getAnystackUrl(): ?string
130133
return "https://anystack.sh/products/{$this->anystack_id}";
131134
}
132135

136+
public function getWebhookUrl(): ?string
137+
{
138+
if (! $this->webhook_secret) {
139+
return null;
140+
}
141+
142+
return route('webhooks.plugins', $this->webhook_secret);
143+
}
144+
145+
public function generateWebhookSecret(): string
146+
{
147+
$secret = bin2hex(random_bytes(32));
148+
149+
$this->update(['webhook_secret' => $secret]);
150+
151+
return $secret;
152+
}
153+
154+
public function getRepositoryOwnerAndName(): ?array
155+
{
156+
if (! $this->repository_url) {
157+
return null;
158+
}
159+
160+
$path = parse_url($this->repository_url, PHP_URL_PATH);
161+
$parts = array_values(array_filter(explode('/', trim($path, '/'))));
162+
163+
if (count($parts) < 2) {
164+
return null;
165+
}
166+
167+
return [
168+
'owner' => $parts[0],
169+
'repo' => str_replace('.git', '', $parts[1]),
170+
];
171+
}
172+
133173
public function approve(int $approvedById): void
134174
{
135175
$previousStatus = $this->status;

app/Services/PluginSyncService.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
namespace App\Services;
4+
5+
use App\Models\Plugin;
6+
use App\Support\CommonMark\CommonMark;
7+
use Illuminate\Support\Facades\Http;
8+
use Illuminate\Support\Facades\Log;
9+
10+
class PluginSyncService
11+
{
12+
public function sync(Plugin $plugin): bool
13+
{
14+
$repo = $plugin->getRepositoryOwnerAndName();
15+
16+
if (! $repo) {
17+
Log::warning("Plugin {$plugin->id} has no valid repository URL");
18+
19+
return false;
20+
}
21+
22+
$baseUrl = "https://raw.githubusercontent.com/{$repo['owner']}/{$repo['repo']}/main";
23+
24+
$readme = $this->fetchFile("{$baseUrl}/README.md");
25+
$composerJson = $this->fetchFile("{$baseUrl}/composer.json");
26+
$nativephpJson = $this->fetchFile("{$baseUrl}/nativephp.json");
27+
28+
if (! $composerJson) {
29+
Log::warning("Plugin {$plugin->id}: Could not fetch composer.json");
30+
31+
return false;
32+
}
33+
34+
$composerData = json_decode($composerJson, true);
35+
$nativephpData = $nativephpJson ? json_decode($nativephpJson, true) : null;
36+
37+
$updateData = [
38+
'composer_data' => $composerData,
39+
'nativephp_data' => $nativephpData,
40+
'last_synced_at' => now(),
41+
];
42+
43+
if ($composerData) {
44+
if (isset($composerData['description'])) {
45+
$updateData['description'] = $composerData['description'];
46+
}
47+
}
48+
49+
if ($nativephpData) {
50+
$updateData['ios_version'] = $this->extractIosVersion($nativephpData);
51+
$updateData['android_version'] = $this->extractAndroidVersion($nativephpData);
52+
}
53+
54+
if ($readme) {
55+
$updateData['readme_html'] = CommonMark::convertToHtml($readme);
56+
}
57+
58+
$plugin->update($updateData);
59+
60+
Log::info("Plugin {$plugin->id} synced successfully");
61+
62+
return true;
63+
}
64+
65+
protected function fetchFile(string $url): ?string
66+
{
67+
try {
68+
$response = Http::timeout(10)->get($url);
69+
70+
if ($response->successful()) {
71+
return $response->body();
72+
}
73+
} catch (\Exception $e) {
74+
Log::warning("Failed to fetch {$url}: {$e->getMessage()}");
75+
}
76+
77+
return null;
78+
}
79+
80+
protected function extractIosVersion(array $nativephpData): ?string
81+
{
82+
return $nativephpData['ios']['min_version'] ?? null;
83+
}
84+
85+
protected function extractAndroidVersion(array $nativephpData): ?string
86+
{
87+
return $nativephpData['android']['min_version'] ?? null;
88+
}
89+
}

database/factories/PluginFactory.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,11 @@ public function definition(): array
104104
return [
105105
'user_id' => User::factory(),
106106
'name' => fake()->unique()->numerify("{$vendor}/{$package}-###"),
107+
'repository_url' => "https://github.com/{$vendor}/{$package}",
108+
'webhook_secret' => bin2hex(random_bytes(32)),
107109
'description' => fake()->randomElement($this->descriptions),
110+
'ios_version' => fake()->randomElement(['15.0+', '16.0+', '14.0+', '17.0+', null]),
111+
'android_version' => fake()->randomElement(['12+', '13+', '11+', '14+', null]),
108112
'type' => PluginType::Free,
109113
'status' => PluginStatus::Pending,
110114
'featured' => false,
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('plugins', function (Blueprint $table) {
15+
$table->string('ios_version')->nullable()->after('description');
16+
$table->string('android_version')->nullable()->after('ios_version');
17+
});
18+
}
19+
20+
/**
21+
* Reverse the migrations.
22+
*/
23+
public function down(): void
24+
{
25+
Schema::table('plugins', function (Blueprint $table) {
26+
$table->dropColumn(['ios_version', 'android_version']);
27+
});
28+
}
29+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('plugins', function (Blueprint $table) {
15+
$table->string('repository_url')->nullable()->after('name');
16+
$table->string('webhook_secret', 64)->nullable()->unique()->after('repository_url');
17+
$table->longText('readme_html')->nullable()->after('android_version');
18+
$table->json('composer_data')->nullable()->after('readme_html');
19+
$table->json('nativephp_data')->nullable()->after('composer_data');
20+
$table->timestamp('last_synced_at')->nullable()->after('nativephp_data');
21+
});
22+
}
23+
24+
/**
25+
* Reverse the migrations.
26+
*/
27+
public function down(): void
28+
{
29+
Schema::table('plugins', function (Blueprint $table) {
30+
$table->dropColumn([
31+
'repository_url',
32+
'webhook_secret',
33+
'readme_html',
34+
'composer_data',
35+
'nativephp_data',
36+
'last_synced_at',
37+
]);
38+
});
39+
}
40+
};

0 commit comments

Comments
 (0)