Compare commits

..

No commits in common. "6d02415b7d46cb5d4fee953e7f036f48f86c47dd" and "809965e81c74624c5cb200be8bef46a424bab91f" have entirely different histories.

12 changed files with 7 additions and 819 deletions

View file

@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters;
use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag;
use App\Services\ImageGenerationService;
use App\Services\Plugin\Parsers\ResponseParserRegistry;
use App\Services\PluginImportService;
use Carbon\Carbon;
@ -45,7 +44,6 @@ class Plugin extends Model
'no_bleed' => 'boolean',
'dark_mode' => 'boolean',
'preferred_renderer' => 'string',
'plugin_type' => 'string',
];
protected static function boot()
@ -135,11 +133,6 @@ class Plugin extends Model
public function isDataStale(): bool
{
// Image webhook plugins don't use data staleness - images are pushed directly
if ($this->plugin_type === 'image_webhook') {
return false;
}
if ($this->data_strategy === 'webhook') {
// Treat as stale if any webhook event has occurred in the past hour
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
@ -454,10 +447,6 @@ class Plugin extends Model
*/
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
{
if ($this->plugin_type !== 'recipe') {
throw new \InvalidArgumentException('Render method is only applicable for recipe plugins.');
}
if ($this->render_markup) {
$renderedContent = '';

View file

@ -280,10 +280,6 @@ class ImageGenerationService
public static function resetIfNotCacheable(?Plugin $plugin): void
{
if ($plugin?->id) {
// Image webhook plugins have finalized images that shouldn't be reset
if ($plugin->plugin_type === 'image_webhook') {
return;
}
// Check if any devices have custom dimensions or use non-standard DeviceModels
$hasCustomDimensions = Device::query()
->where(function ($query): void {

View file

@ -29,24 +29,8 @@ class PluginFactory extends Factory
'icon_url' => null,
'flux_icon_name' => null,
'author_name' => $this->faker->name(),
'plugin_type' => 'recipe',
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
/**
* Indicate that the plugin is an image webhook plugin.
*/
public function imageWebhook(): static
{
return $this->state(fn (array $attributes): array => [
'plugin_type' => 'image_webhook',
'data_strategy' => 'static',
'data_stale_minutes' => 60,
'polling_url' => null,
'polling_verb' => 'get',
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
]);
}
}

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plugins', function (Blueprint $table): void {
$table->string('plugin_type')->default('recipe')->after('uuid');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table): void {
$table->dropColumn('plugin_type');
});
}
};

View file

@ -1,298 +0,0 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
new class extends Component {
public Plugin $plugin;
public string $name;
public array $checked_devices = [];
public array $device_playlists = [];
public array $device_playlist_names = [];
public array $device_weekdays = [];
public array $device_active_from = [];
public array $device_active_until = [];
public function mount(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
abort_unless($this->plugin->plugin_type === 'image_webhook', 404);
$this->name = $this->plugin->name;
}
protected array $rules = [
'name' => 'required|string|max:255',
'checked_devices' => 'array',
'device_playlist_names' => 'array',
'device_playlists' => 'array',
'device_weekdays' => 'array',
'device_active_from' => 'array',
'device_active_until' => 'array',
];
public function updateName(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->validate(['name' => 'required|string|max:255']);
$this->plugin->update(['name' => $this->name]);
}
public function addToPlaylist()
{
$this->validate([
'checked_devices' => 'required|array|min:1',
]);
foreach ($this->checked_devices as $deviceId) {
if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
$this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
return;
}
if ($this->device_playlists[$deviceId] === 'new') {
if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
$this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
return;
}
}
}
foreach ($this->checked_devices as $deviceId) {
$playlist = null;
if ($this->device_playlists[$deviceId] === 'new') {
$playlist = \App\Models\Playlist::create([
'device_id' => $deviceId,
'name' => $this->device_playlist_names[$deviceId],
'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
'active_from' => $this->device_active_from[$deviceId] ?? null,
'active_until' => $this->device_active_until[$deviceId] ?? null,
]);
} else {
$playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
}
$maxOrder = $playlist->items()->max('order') ?? 0;
// Image webhook plugins only support full layout
$playlist->items()->create([
'plugin_id' => $this->plugin->id,
'order' => $maxOrder + 1,
]);
}
$this->reset([
'checked_devices',
'device_playlists',
'device_playlist_names',
'device_weekdays',
'device_active_from',
'device_active_until',
]);
Flux::modal('add-to-playlist')->close();
}
public function getDevicePlaylists($deviceId)
{
return \App\Models\Playlist::where('device_id', $deviceId)->get();
}
public function hasAnyPlaylistSelected(): bool
{
foreach ($this->checked_devices as $deviceId) {
if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
return true;
}
}
return false;
}
public function deletePlugin(): void
{
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
$this->plugin->delete();
$this->redirect(route('plugins.image-webhook'));
}
public function getImagePath(): ?string
{
if (!$this->plugin->current_image) {
return null;
}
$extensions = ['png', 'bmp'];
foreach ($extensions as $ext) {
$path = 'images/generated/'.$this->plugin->current_image.'.'.$ext;
if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) {
return $path;
}
}
return null;
}
};
?>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Image Webhook {{$plugin->name}}</h2>
<flux:button.group>
<flux:modal.trigger name="add-to-playlist">
<flux:button icon="play" variant="primary">Add to Playlist</flux:button>
</flux:modal.trigger>
<flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu>
<flux:modal.trigger name="delete-plugin">
<flux:menu.item icon="trash" variant="danger">Delete Instance</flux:menu.item>
</flux:modal.trigger>
</flux:menu>
</flux:dropdown>
</flux:button.group>
</div>
<flux:modal name="add-to-playlist" class="min-w-2xl">
<div class="space-y-6">
<div>
<flux:heading size="lg">Add to Playlist</flux:heading>
</div>
<form wire:submit="addToPlaylist">
<flux:separator text="Device(s)" />
<div class="mt-4 mb-4">
<flux:checkbox.group wire:model.live="checked_devices">
@foreach(auth()->user()->devices as $device)
<flux:checkbox label="{{ $device->name }}" value="{{ $device->id }}"/>
@endforeach
</flux:checkbox.group>
</div>
@if(count($checked_devices) > 0)
<flux:separator text="Playlist Selection" />
<div class="mt-4 mb-4 space-y-6">
@foreach($checked_devices as $deviceId)
@php
$device = auth()->user()->devices->find($deviceId);
@endphp
<div class="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4">
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
{{ $device->name }}
</div>
<div class="mb-4">
<flux:select wire:model.live.debounce="device_playlists.{{ $deviceId }}">
<option value="">Select Playlist or Create New</option>
@foreach($this->getDevicePlaylists($deviceId) as $playlist)
<option value="{{ $playlist->id }}">{{ $playlist->name }}</option>
@endforeach
<option value="new">Create New Playlist</option>
</flux:select>
</div>
@if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new')
<div class="space-y-4">
<div>
<flux:input label="Playlist Name" wire:model="device_playlist_names.{{ $deviceId }}"/>
</div>
<div>
<flux:checkbox.group wire:model="device_weekdays.{{ $deviceId }}" label="Active Days (optional)">
<flux:checkbox label="Monday" value="1"/>
<flux:checkbox label="Tuesday" value="2"/>
<flux:checkbox label="Wednesday" value="3"/>
<flux:checkbox label="Thursday" value="4"/>
<flux:checkbox label="Friday" value="5"/>
<flux:checkbox label="Saturday" value="6"/>
<flux:checkbox label="Sunday" value="0"/>
</flux:checkbox.group>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<flux:input type="time" label="Active From (optional)" wire:model="device_active_from.{{ $deviceId }}"/>
</div>
<div>
<flux:input type="time" label="Active Until (optional)" wire:model="device_active_until.{{ $deviceId }}"/>
</div>
</div>
</div>
@endif
</div>
@endforeach
</div>
@endif
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Add to Playlist</flux:button>
</div>
</form>
</div>
</flux:modal>
<flux:modal name="delete-plugin" class="min-w-[22rem] space-y-6">
<div>
<flux:heading size="lg">Delete {{ $plugin->name }}?</flux:heading>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
</div>
<div class="flex gap-2">
<flux:spacer/>
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button wire:click="deletePlugin" variant="danger">Delete instance</flux:button>
</div>
</flux:modal>
<div class="grid lg:grid-cols-2 lg:gap-8">
<div>
<form wire:submit="updateName" class="mb-6">
<div class="mb-4">
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus/>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
</div>
</form>
<div class="mb-6">
<flux:label>Webhook URL</flux:label>
<flux:input
:value="route('api.plugin_settings.image', ['uuid' => $plugin->uuid])"
class="font-mono text-sm"
readonly
copyable
/>
<flux:description class="mt-2">POST an image (PNG or BMP) to this URL to update the displayed image.</flux:description>
<flux:callout variant="warning" icon="exclamation-circle" class="mt-4">
<flux:callout.text>Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown.</flux:callout.text>
</flux:callout>
</div>
</div>
<div>
<div class="mb-4">
<flux:label>Current Image</flux:label>
@if($this->getImagePath())
<img src="{{ url('storage/'.$this->getImagePath()) }}" alt="{{ $plugin->name }}" class="w-full h-auto rounded-lg border border-zinc-200 dark:border-zinc-700 mt-2" />
@else
<flux:callout variant="warning" class="mt-2">
<flux:text>No image uploaded yet. POST an image to the webhook URL to get started.</flux:text>
</flux:callout>
@endif
</div>
</div>
</div>
</div>
</div>

View file

@ -1,163 +0,0 @@
<?php
use App\Models\Plugin;
use Livewire\Volt\Component;
use Illuminate\Support\Str;
new class extends Component {
public string $name = '';
public array $instances = [];
protected $rules = [
'name' => 'required|string|max:255',
];
public function mount(): void
{
$this->refreshInstances();
}
public function refreshInstances(): void
{
$this->instances = auth()->user()
->plugins()
->where('plugin_type', 'image_webhook')
->orderBy('created_at', 'desc')
->get()
->toArray();
}
public function createInstance(): void
{
abort_unless(auth()->user() !== null, 403);
$this->validate();
Plugin::create([
'uuid' => Str::uuid(),
'user_id' => auth()->id(),
'name' => $this->name,
'plugin_type' => 'image_webhook',
'data_strategy' => 'static', // Not used for image_webhook, but required
'data_stale_minutes' => 60, // Not used for image_webhook, but required
]);
$this->reset(['name']);
$this->refreshInstances();
Flux::modal('create-instance')->close();
}
public function deleteInstance(int $pluginId): void
{
abort_unless(auth()->user() !== null, 403);
$plugin = Plugin::where('id', $pluginId)
->where('user_id', auth()->id())
->where('plugin_type', 'image_webhook')
->firstOrFail();
$plugin->delete();
$this->refreshInstances();
}
};
?>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Image Webhook
<flux:badge size="sm" class="ml-2">Plugin</flux:badge>
</h2>
<flux:modal.trigger name="create-instance">
<flux:button icon="plus" variant="primary">Create Instance</flux:button>
</flux:modal.trigger>
</div>
<flux:modal name="create-instance" class="md:w-96">
<div class="space-y-6">
<div>
<flux:heading size="lg">Create Image Webhook Instance</flux:heading>
<flux:subheading>Create a new instance that accepts images via webhook</flux:subheading>
</div>
<form wire:submit="createInstance">
<div class="mb-4">
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
name="name" autofocus/>
</div>
<div class="flex">
<flux:spacer/>
<flux:button type="submit" variant="primary">Create Instance</flux:button>
</div>
</form>
</div>
</flux:modal>
@if(empty($instances))
<div class="text-center py-12">
<flux:callout>
<flux:heading size="sm">No instances yet</flux:heading>
<flux:text>Create your first Image Webhook instance to get started.</flux:text>
</flux:callout>
</div>
@else
<table
class="min-w-full table-auto text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20"
data-flux-table="">
<thead data-flux-columns="">
<tr>
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
data-flux-column="">
<div class="whitespace-nowrap flex">Name</div>
</th>
<th class="py-3 px-3 first:pl-0 last:pr-0 text-right text-sm font-medium text-zinc-800 dark:text-white"
data-flux-column="">
<div class="whitespace-nowrap flex justify-end">Actions</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows="">
@foreach($instances as $instance)
<tr data-flux-row="">
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300">
{{ $instance['name'] }}
</td>
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white text-right">
<div class="flex items-center justify-end">
<flux:button.group>
<flux:button href="{{ route('plugins.image-webhook-instance', ['plugin' => $instance['id']]) }}" wire:navigate icon="pencil" iconVariant="outline">
</flux:button>
<flux:modal.trigger name="delete-instance-{{ $instance['id'] }}">
<flux:button icon="trash" iconVariant="outline">
</flux:button>
</flux:modal.trigger>
</flux:button.group>
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
@foreach($instances as $instance)
<flux:modal name="delete-instance-{{ $instance['id'] }}" class="min-w-88 space-y-6">
<div>
<flux:heading size="lg">Delete {{ $instance['name'] }}?</flux:heading>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
</div>
<div class="flex gap-2">
<flux:spacer/>
<flux:modal.close>
<flux:button variant="ghost">Cancel</flux:button>
</flux:modal.close>
<flux:button wire:click="deleteInstance({{ $instance['id'] }})" variant="danger">Delete instance</flux:button>
</div>
</flux:modal>
@endforeach
</div>
</div>

View file

@ -26,8 +26,6 @@ new class extends Component {
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
'api' =>
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
'image-webhook' =>
['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
];
protected $rules = [
@ -42,12 +40,7 @@ new class extends Component {
public function refreshPlugins(): void
{
// Only show recipe plugins in the main list (image_webhook has its own management page)
$userPlugins = auth()->user()?->plugins()
->where('plugin_type', 'recipe')
->get()
->makeHidden(['render_markup', 'data_payload'])
->toArray();
$userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray();
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
$allPlugins = array_values($allPlugins);
$allPlugins = $this->sortPlugins($allPlugins);

View file

@ -976,8 +976,6 @@ HTML;
wire:model.defer="multiValues.{{ $fieldKey }}.{{ $index }}"
:placeholder="$field['placeholder'] ?? 'Value...'"
class="flex-1"
pattern="[^,]*"
title="Commas are not allowed in this field"
/>
@if(count($multiValues[$fieldKey]) > 1)

View file

@ -549,91 +549,6 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
return response()->json(['message' => 'Data updated successfully']);
})->name('api.custom_plugins.webhook');
Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) {
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
// Check if plugin is image_webhook type
if ($plugin->plugin_type !== 'image_webhook') {
return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400);
}
// Accept image from either multipart form or raw binary
$image = null;
$extension = null;
if ($request->hasFile('image')) {
$file = $request->file('image');
$extension = mb_strtolower($file->getClientOriginalExtension());
$image = $file->get();
} elseif ($request->has('image')) {
// Base64 encoded image
$imageData = $request->input('image');
if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) {
$extension = mb_strtolower($matches[1]);
$image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1));
} else {
return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400);
}
} else {
// Try raw binary
$image = $request->getContent();
$contentType = $request->header('Content-Type', '');
$trimmedContent = mb_trim($image);
// Check if content is empty or just empty JSON
if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') {
return response()->json(['error' => 'No image data provided'], 400);
}
// If it's a JSON request without image field, return error
if (str_contains($contentType, 'application/json')) {
return response()->json(['error' => 'No image data provided'], 400);
}
// Detect image type from content
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_buffer($finfo, $image);
finfo_close($finfo);
$extension = match ($mimeType) {
'image/png' => 'png',
'image/bmp' => 'bmp',
default => null,
};
if (! $extension) {
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
}
}
// Validate extension
$allowedExtensions = ['png', 'bmp'];
if (! in_array($extension, $allowedExtensions)) {
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
}
// Generate a new UUID for each image upload to prevent device caching
$imageUuid = \Illuminate\Support\Str::uuid()->toString();
$filename = $imageUuid.'.'.$extension;
$path = 'images/generated/'.$filename;
// Save image to storage
Storage::disk('public')->put($path, $image);
// Update plugin's current_image field with the new UUID
$plugin->update([
'current_image' => $imageUuid,
]);
// Clean up old images
ImageGenerationService::cleanupFolder();
return response()->json([
'message' => 'Image uploaded successfully',
'image_url' => url('storage/'.$path),
]);
})->name('api.plugin_settings.image');
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
return response()->json([

View file

@ -31,8 +31,6 @@ Route::middleware(['auth'])->group(function () {
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook');
Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance');
Volt::route('playlists', 'playlists.index')->name('playlists.index');
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {

View file

@ -1,196 +0,0 @@
<?php
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('public');
Storage::disk('public')->makeDirectory('/images/generated');
});
test('can upload image to image webhook plugin via multipart form', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
$image = UploadedFile::fake()->image('test.png', 800, 480);
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
'image' => $image,
]);
$response->assertOk()
->assertJsonStructure([
'message',
'image_url',
]);
$plugin->refresh();
expect($plugin->current_image)
->not->toBeNull()
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
// File should exist with the new UUID
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
// Image URL should contain the new UUID
expect($response->json('image_url'))
->toContain($plugin->current_image);
});
test('can upload image to image webhook plugin via raw binary', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a simple PNG image binary
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
'CONTENT_TYPE' => 'image/png',
], $pngData);
$response->assertOk()
->assertJsonStructure([
'message',
'image_url',
]);
$plugin->refresh();
expect($plugin->current_image)
->not->toBeNull()
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
// File should exist with the new UUID
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
// Image URL should contain the new UUID
expect($response->json('image_url'))
->toContain($plugin->current_image);
});
test('can upload image to image webhook plugin via base64 data URI', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a simple PNG image as base64 data URI
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [
'image' => $base64Image,
]);
$response->assertOk()
->assertJsonStructure([
'message',
'image_url',
]);
$plugin->refresh();
expect($plugin->current_image)
->not->toBeNull()
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
// File should exist with the new UUID
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
// Image URL should contain the new UUID
expect($response->json('image_url'))
->toContain($plugin->current_image);
});
test('returns 400 for non-image-webhook plugin', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->create([
'user_id' => $user->id,
'plugin_type' => 'recipe',
]);
$image = UploadedFile::fake()->image('test.png', 800, 480);
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
'image' => $image,
]);
$response->assertStatus(400)
->assertJson(['error' => 'Plugin is not an image webhook plugin']);
});
test('returns 404 for non-existent plugin', function (): void {
$image = UploadedFile::fake()->image('test.png', 800, 480);
$response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [
'image' => $image,
]);
$response->assertNotFound();
});
test('returns 400 for unsupported image format', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a fake GIF file (not supported)
$gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
'CONTENT_TYPE' => 'image/gif',
], $gifData);
$response->assertStatus(400)
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
});
test('returns 400 for JPG image format', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
// Create a fake JPG file (not supported)
$jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A');
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
'CONTENT_TYPE' => 'image/jpeg',
], $jpgData);
$response->assertStatus(400)
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
});
test('returns 400 when no image data provided', function (): void {
$user = User::factory()->create();
$plugin = Plugin::factory()->imageWebhook()->create([
'user_id' => $user->id,
]);
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []);
$response->assertStatus(400)
->assertJson(['error' => 'No image data provided']);
});
test('image webhook plugin isDataStale returns false', function (): void {
$plugin = Plugin::factory()->imageWebhook()->create();
expect($plugin->isDataStale())->toBeFalse();
});
test('image webhook plugin factory creates correct plugin type', function (): void {
$plugin = Plugin::factory()->imageWebhook()->create();
expect($plugin)
->plugin_type->toBe('image_webhook')
->data_strategy->toBe('static');
});

View file

@ -685,11 +685,11 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context
* [Input, Expected Result, Forbidden String]
*/
dataset('xss_vectors', [
'standard_script' => ['Safe <script>alert(1)</script>', 'Safe ', '<script>'],
'standard_script' => ['Safe <script>alert(1)</script>', 'Safe ', '<script>'],
'attribute_event_handlers' => ['<a onmouseover="alert(1)">Link</a>', '<a>Link</a>', 'onmouseover'],
'javascript_protocol' => ['<a href="javascript:alert(1)">Click</a>', '<a>Click</a>', 'javascript:'],
'iframe_injection' => ['Watch <iframe src="https://x.com"></iframe>', 'Watch ', '<iframe>'],
'img_onerror_fallback' => ['Photo <img src=x onerror=alert(1)>', 'Photo <img src="x" alt="x">', 'onerror'],
'javascript_protocol' => ['<a href="javascript:alert(1)">Click</a>', '<a>Click</a>', 'javascript:'],
'iframe_injection' => ['Watch <iframe src="https://x.com"></iframe>', 'Watch ', '<iframe>'],
'img_onerror_fallback' => ['Photo <img src=x onerror=alert(1)>', 'Photo <img src="x" alt="x">', 'onerror'],
]);
test('plugin model sanitizes template fields on save', function (string $input, string $expected, string $forbidden): void {
@ -731,8 +731,8 @@ test('plugin model preserves multi_string csv format', function (): void {
'data_strategy' => 'static',
'polling_verb' => 'get',
'configuration' => [
'tags' => 'laravel,pest,security',
],
'tags' => 'laravel,pest,security'
]
]);
expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security');