Laravel прегледи на врски: Безбедно снимајте и прикажувајте сликички од веб-страници во вашите обележувачи
Списокот со обележувачи станува многу покорисен кога секоја зачувана URL-адреса има препознатлив визуелен преглед. Незгодниот дел е сигурното создавање на тие прегледи: извршувањето на Chromium троши меморија, бара одржување на прелистувачот и го претвора обичното прикажување страници во инфраструктурна одговорност.
Овој туторијал создава Laravel апликација за обележувачи што снима PNG прегледи преку Screenshot API, ги обработува во редицата, ја валидира вратената слика и ја зачувува на јавниот диск на Laravel. Резултатот е одзивна апликација што никогаш не го изложува својот сервисен токен ниту ги тера посетителите да чекаат снимање од прелистувач.
Добијте пристап и копирајте го сервисниот токен
Завршете го воведувањето во услугата пред да напишете интеграциски код:
- Регистрирајте се на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате сметка.
- Отворете ја страницата на услугата Screenshot API.
- Изберете го достапниот Free, Plus или Pro план и завршете ја неговата активација.
- Отворете ја официјалната документација за услугата.
- Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
Повторното генерирање на овој токен го поништува претходно активниот токен, па ротацијата на токени мора да вклучува ажурирање на околината на апликацијата и рестартирање на долготрајните работници. Оваа услуга бара автентикација; не постои неавтентициран режим. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе ја користиме Bearer формата бидејќи ги задржува ингеренциите надвор од URL-адресите и дневниците за пристап.
Потврдете ја точната крајна точка
Операцијата за снимање е HTTP GET барање до https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Нејзиниот задолжителен параметар за барање url ја одредува страницата за снимање, а успешниот одговор содржи тело image/png заедно со заглавија за кеш и квота на одговорот.
Направете едно минимално барање од доверлив терминал. Излезот е бинарен, затоа запишете го во датотека, а задржете ги заглавијата за проверка:
curl --fail-with-body \
--get 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture' \
--header 'Authorization: Bearer YOUR_SERVICE_TOKEN' \
--data-urlencode 'url=https://example.com/' \
--dump-header /tmp/screenshot-headers.txt \
--output /tmp/example-preview.png
Не го внесувајте токенот во контрола на изворниот код или во историјата на школката на заедничка машина. Наместо тоа, ставете го во локалната конфигурација на околината на Laravel:
# .env
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_CONNECT_TIMEOUT=3
SCREENSHOT_RESPONSE_TIMEOUT=20
# Local development only; use a managed queue in production.
QUEUE_CONNECTION=database
Архитектура и компромиси
Веб-барањето треба брзо да валидира и зачува обележувач. Потоа задача во редицата ја повикува услугата за слики од екранот, ја проверува границата на одговорот и запишува локално контролирана PNG-слика. Страницата со обележувачи ја прикажува таа зачувана датотека наместо да вметнува произволна оддалечена слика.
Ова раздвојување ѝ дава на апликацијата ограничена HTTP-латентност, повторни обиди на ниво на редица и стабилен преглед дури и ако одредиштето подоцна се промени. Исто така троши простор за складирање и значи дека прегледите се евентуално конзистентни: нов обележувач накратко прикажува состојба на чекање.
Релевантната структура на проектот е намерно мала:
app/Http/Controllers/BookmarkController.phpвалидира и создава обележувачи.app/Services/ScreenshotClient.phpго поседува надворешниот HTTP-договор.app/Services/ScreenshotResult.phpги пресликува транспортните одговори во исходи од доменот.app/Jobs/CaptureBookmarkPreview.phpго контролира однесувањето за зачувување и повторни обиди.resources/views/bookmarks/index.blade.phpбезбедно ги прикажува прегледите.
Создајте го складиштето за обележувачи
Оваа имплементација претпоставува PHP 8.3 или понов, поддржана Laravel апликација, конфигурирана база на податоци и функционален бекенд за редицата. Генерирајте ги деловите на апликацијата и табелата за редицата во базата:
php artisan make:model Bookmark -m
php artisan make:controller BookmarkController
php artisan make:job CaptureBookmarkPreview
php artisan make:queue-table
php artisan migrate
php artisan storage:link
Дефинирајте ги полињата за обележувачот во генерираната миграција:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('bookmarks', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->text('url');
$table->string('preview_status')->default('pending');
$table->string('preview_path')->nullable();
$table->string('preview_error')->nullable();
$table->json('preview_meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('bookmarks');
}
};
Направете ги овие атрибути доделиви и претворете ги оперативните метаподатоци:
<?php
// app/Models/Bookmark.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class Bookmark extends Model
{
protected $fillable = [
'title', 'url', 'preview_status', 'preview_path',
'preview_error', 'preview_meta',
];
protected function casts(): array
{
return ['preview_meta' => 'array'];
}
}
Конфигурирајте ја API-границата
Додајте посебен запис во config/services.php. Читањето на env() само од конфигурациски датотеки ја одржува апликацијата компатибилна со кешот за конфигурација на Laravel:
'screenshot' => [
'endpoint' => 'https://ai.mihajlo.mk/api/screenshot-api/v1/capture',
'token' => env('SCREENSHOT_API_TOKEN'),
'connect_timeout' => (int) env('SCREENSHOT_CONNECT_TIMEOUT', 3),
'timeout' => (int) env('SCREENSHOT_RESPONSE_TIMEOUT', 20),
],
Пресликајте го секој оддалечен одговор во мал доменски објект. Не треба да се претпоставуваат имиња на заглавија надвор од документираните категории за кеш и квота, па пресликувачот ги задржува заглавијата чии имиња означуваат информации за кеш, квота или ограничување на стапката. Исто така го бележи стандардното заглавие Retry-After кога е присутно.
<?php
// app/Services/ScreenshotResult.php
namespace App\Services;
final readonly class ScreenshotResult
{
public function __construct(
public bool $successful,
public ?string $png,
public string $failure,
public bool $retryable,
public ?int $retryAfter,
public array $operationalHeaders,
) {}
}
Клиентот наметнува временски ограничувања, ги проверува статусот и типот на содржина, ја ограничува прифатената големина на телото и го потврдува PNG-потписот. Неуспесите при поврзување и грешките на серверот добиваат два ограничени, кратки повторни обиди. Одговорите за автентикација, валидација и квота не се повторуваат слепо.
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
final class ScreenshotClient
{
private const MAX_BYTES = 5_242_880;
public function capture(string $url): ScreenshotResult
{
$token = config('services.screenshot.token');
if (! is_string($token) || $token === '') {
throw new RuntimeException('Screenshot API token is not configured.');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(config('services.screenshot.connect_timeout'))
->timeout(config('services.screenshot.timeout'))
->get(config('services.screenshot.endpoint'), ['url' => $url]);
} catch (ConnectionException $exception) {
Log::warning('Screenshot transport failure', [
'attempt' => $attempt,
'exception' => $exception::class,
]);
if ($attempt < 3) {
usleep($attempt === 1 ? 250_000 : 750_000);
continue;
}
return new ScreenshotResult(
false, null, 'transport_failure', true, null, []
);
}
if ($response->serverError() && $attempt < 3) {
Log::warning('Screenshot upstream server error', [
'attempt' => $attempt,
'status' => $response->status(),
]);
usleep($attempt === 1 ? 250_000 : 750_000);
continue;
}
return $this->map($response);
}
throw new RuntimeException('Unreachable screenshot retry state.');
}
private function map(Response $response): ScreenshotResult
{
$headers = [];
foreach ($response->headers() as $name => $values) {
if (preg_match('/cache|quota|rate.?limit|retry-after/i', $name)) {
$headers[$name] = array_values((array) $values);
}
}
$retryAfter = filter_var(
$response->header('Retry-After'),
FILTER_VALIDATE_INT,
['options' => ['min_range' => 0]]
);
if ($response->status() === 429) {
return new ScreenshotResult(
false, null, 'quota_limited', true,
$retryAfter === false ? null : $retryAfter,
$headers
);
}
if (in_array($response->status(), [401, 403], true)) {
return new ScreenshotResult(
false, null, 'authentication_failed', false, null, $headers
);
}
if ($response->serverError()) {
return new ScreenshotResult(
false, null, 'upstream_failure', true, null, $headers
);
}
if (! $response->successful()) {
return new ScreenshotResult(
false, null, 'request_rejected', false, null, $headers
);
}
$body = $response->body();
$isPng = str_starts_with(
strtolower((string) $response->header('Content-Type')),
'image/png'
) && str_starts_with($body, "\x89PNG\r\n\x1a\n");
if (! $isPng || strlen($body) > self::MAX_BYTES) {
return new ScreenshotResult(
false, null, 'invalid_image_response', false, null, $headers
);
}
return new ScreenshotResult(true, $body, '', false, null, $headers);
}
}
Валидирајте URL-адреси и испратете го снимањето
Валидацијата на URL-адресите е и безбедносна граница и контрола на квотата. Прифатете само HTTP и HTTPS, одбијте ингеренции вградени во URL-адресата и одбијте очигледни локални цели. Контролите на одредиштето од страна на давателот остануваат суштински бидејќи валидацијата на апликацијата не може да го елиминира DNS rebinding на границата на оддалеченото преземање.
<?php
// app/Http/Controllers/BookmarkController.php
namespace App\Http\Controllers;
use App\Jobs\CaptureBookmarkPreview;
use App\Models\Bookmark;
use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
final class BookmarkController
{
public function index(): View
{
return view('bookmarks.index', [
'bookmarks' => Bookmark::latest()->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'title' => ['required', 'string', 'max:120'],
'url' => [
'required',
'url:http,https',
'max:2048',
function (string $attribute, mixed $value, Closure $fail): void {
$parts = parse_url((string) $value);
$host = strtolower((string) ($parts['host'] ?? ''));
if (isset($parts['user']) || isset($parts['pass'])) {
$fail('URLs containing credentials are not allowed.');
return;
}
if ($host === 'localhost' || str_ends_with($host, '.local')) {
$fail('Local destinations are not allowed.');
return;
}
if (filter_var($host, FILTER_VALIDATE_IP)
&& ! filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
)) {
$fail('Private or reserved IP addresses are not allowed.');
}
},
],
]);
$bookmark = Bookmark::create($data + ['preview_status' => 'pending']);
CaptureBookmarkPreview::dispatch($bookmark)->afterCommit();
return redirect()->route('bookmarks.index');
}
}
Задачата зачувува само проверени PNG-бајти. Одговорите за квота користат ограничено одложување врз основа на Retry-After кога е достапно; транспортните и серверските неуспеси го користат подолгиот распоред за повлекување на задачата. Трајните неуспеси се бележат без фрлање исклучок, со што се спречуваат бесцелни обиди во редицата.
<?php
// app/Jobs/CaptureBookmarkPreview.php
namespace App\Jobs;
use App\Models\Bookmark;
use App\Services\ScreenshotClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
final class CaptureBookmarkPreview implements ShouldQueue
{
use Queueable;
public int $tries = 4;
public function __construct(public Bookmark $bookmark) {}
public function backoff(): array
{
return [30, 120, 300];
}
public function handle(ScreenshotClient $client): void
{
$result = $client->capture($this->bookmark->url);
if ($result->successful) {
$path = "bookmark-previews/{$this->bookmark->id}.png";
if (! Storage::disk('public')->put($path, $result->png)) {
throw new RuntimeException('Preview storage failed.');
}
$this->bookmark->update([
'preview_status' => 'ready',
'preview_path' => $path,
'preview_error' => null,
'preview_meta' => $result->operationalHeaders,
]);
return;
}
Log::warning('Bookmark preview capture failed', [
'bookmark_id' => $this->bookmark->id,
'host' => parse_url($this->bookmark->url, PHP_URL_HOST),
'failure' => $result->failure,
]);
if ($result->failure === 'quota_limited'
&& $this->attempts() < $this->tries) {
$this->release(max(30, min(900, $result->retryAfter ?? 60)));
return;
}
if ($result->retryable) {
throw new RuntimeException($result->failure);
}
$this->bookmark->update([
'preview_status' => 'failed',
'preview_error' => $result->failure,
'preview_meta' => $result->operationalHeaders,
]);
}
public function failed(?Throwable $exception): void
{
$this->bookmark->update([
'preview_status' => 'failed',
'preview_error' => 'retries_exhausted',
]);
}
}
Прикажувајте само контролирани прегледи
Регистрирајте ги рутите и прикажете ги избегнатите податоци за обележувачите. Треба да се додаде авторизација ако обележувачите им припаѓаат на поединечни корисници.
// routes/web.php
use App\Http\Controllers\BookmarkController;
use Illuminate\Support\Facades\Route;
Route::get('/bookmarks', [BookmarkController::class, 'index'])
->name('bookmarks.index');
Route::post('/bookmarks', [BookmarkController::class, 'store'])
->middleware('throttle:20,1')
->name('bookmarks.store');
<!-- resources/views/bookmarks/index.blade.php -->
<form method="post" action="{{ route('bookmarks.store') }}">
@csrf
<input name="title" required maxlength="120">
<input name="url" type="url" required maxlength="2048">
<button type="submit">Save bookmark</button>
</form>
@foreach ($bookmarks as $bookmark)
<article>
@if ($bookmark->preview_status === 'ready')
<img
src="{{ Storage::disk('public')->url($bookmark->preview_path) }}"
alt=""
loading="lazy"
width="640"
height="400"
>
@else
<p>Preview: {{ $bookmark->preview_status }}</p>
@endif
<a href="{{ $bookmark->url }}"
rel="noopener noreferrer"
target="_blank">{{ $bookmark->title }}</a>
</article>
@endforeach
Тестирајте без повикување на активната услуга
HTTP-лажното претставување на Laravel ја прави API-границата детерминистичка. Овие тестови ги проверуваат автентикацијата, кодирањето на параметрите за барање, PNG-валидацијата, зачувувањето на заглавијата и складирањето:
<?php
// tests/Feature/ScreenshotIntegrationTest.php
namespace Tests\Feature;
use App\Jobs\CaptureBookmarkPreview;
use App\Models\Bookmark;
use App\Services\ScreenshotClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ScreenshotIntegrationTest extends TestCase
{
use RefreshDatabase;
public function test_client_maps_a_png_response(): void
{
config(['services.screenshot.token' => 'test-token']);
Http::fake([
'ai.mihajlo.mk/*' => Http::response(
"\x89PNG\r\n\x1a\nfake",
200,
['Content-Type' => 'image/png', 'X-Cache' => 'HIT']
),
]);
$result = app(ScreenshotClient::class)
->capture('https://example.com/path?a=1');
$this->assertTrue($result->successful);
$this->assertArrayHasKey('X-Cache', $result->operationalHeaders);
Http::assertSent(function (Request $request): bool {
parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);
return $request->method() === 'GET'
&& $request->hasHeader(
'Authorization',
'Bearer test-token'
)
&& ($query['url'] ?? null)
=== 'https://example.com/path?a=1';
});
}
public function test_job_stores_a_verified_preview(): void
{
Storage::fake('public');
config(['services.screenshot.token' => 'test-token']);
Http::fake([
'*' => Http::response(
"\x89PNG\r\n\x1a\nfake",
200,
['Content-Type' => 'image/png']
),
]);
$bookmark = Bookmark::create([
'title' => 'Example',
'url' => 'https://example.com/',
'preview_status' => 'pending',
]);
app(CaptureBookmarkPreview::class, ['bookmark' => $bookmark])
->handle(app(ScreenshotClient::class));
Storage::disk('public')
->assertExists("bookmark-previews/{$bookmark->id}.png");
$this->assertSame('ready', $bookmark->fresh()->preview_status);
}
}
Распоредување, набљудливост и вообичаени неуспеси
Во продукција, внесете го токенот преку управувачот со тајни на платформата, извршете php artisan config:cache, применете ги миграциите, осигурете се дека јавниот диск е траен и одржувајте работник за редицата под процесен супервизор. По ротација на сервисниот токен или распоредување код, рестартирајте ги работниците со php artisan queue:restart.
Следете го бројот на обележувачи ready, failed и долготрајно pending. Дневниците намерно ги содржат ID на обележувачот, домаќинот, статусот и категоријата на неуспех, но не и токенот, целосната низа на параметри на URL-адресата или телото на одговорот.
- Неуспеси при автентикација: потврдете ги активацијата и токенот ограничен на услугата, потоа повторно изградете го кешот за конфигурација и рестартирајте ги работниците.
- Снимања ограничени со квота: прегледајте ги зачуваните метаподатоци за квотата, намалете ја зачестеноста на повторното снимање и потврдете дека избраниот план одговара на побарувачката.
- Неважечки одговори со слика: задржете го одбивањето. Никогаш не зачувувајте неочекуван тип на содржина како датотека што може да ја прикаже прелистувач.
- Постојани редови на чекање: проверете дали работник троши од конфигурираната редица и дали се следат неуспешните задачи.
- Недостасуваат слики: потврдете ги
storage:link, дозволите за дискот и трајното споделено складирање кога се користат повеќе инстанци на апликацијата.
Конечна контролна листа за проверка
- Зачувајте јавна HTTPS URL-адреса и потврдете дека веб-барањето се враќа веднаш.
- Извршете
php artisan queue:work --tries=4и набљудувајте како обележувачот стануваready. - Потврдете дека зачуваната датотека започнува со PNG-потписот и се сервира од јавниот диск.
- Извршете
php artisan testбез активен API-сообраќај. - Тестирајте неважечка локална URL-адреса, неважечки токен, симулиран 429 и симулиран серверски неуспех.
- Потврдете дека дневниците на апликацијата и зачуваните метаподатоци не содржат ингеренции или целосна чувствителна URL-адреса.
Самата слика од екранот е само видливиот резултат. Продукциската функционалност е границата околу неа: контролирани влезови, тајна задржана надвор од URL-адресите, детерминистичко пресликување на одговорите, ограничени повторни обиди, безбедно складирање и доволно оперативен контекст за дијагностицирање неуспеси. Штом овие делови се поставени, прегледите на врски престануваат да бидат инфраструктура на прелистувач и стануваат обичен, сигурен дел од доменот на обележувачите.