Laravel pretpregledi poveznica: sigurno snimajte i prikazujte minijature web-stranica u svojim oznakama
Popis oznaka postaje mnogo korisniji kada svaki spremljeni URL ima prepoznatljiv vizualni pregled. Nezgodan dio je pouzdana izrada tih pregleda: pokretanje Chromiuma troši memoriju, zahtijeva zakrpavanje preglednika i pretvara uobičajeno renderiranje stranica u infrastrukturnu odgovornost.
Ovaj vodič izrađuje Laravel aplikaciju za oznake koja snima PNG preglede putem Screenshot API-ja, obrađuje ih u redu čekanja, provjerava vraćenu sliku i pohranjuje je na Laravelov javni disk. Rezultat je responzivna aplikacija koja nikada ne izlaže svoj servisni token niti posjetitelje tjera da čekaju snimanje preglednika.
Dobijte pristup i kopirajte servisni token
Dovršite uvođenje u servis prije pisanja integracijskog koda:
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- Otvorite stranicu usluge Screenshot API.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na servis.
Ponovno generiranje ovog tokena opoziva prethodno aktivni token, stoga rotacija tokena mora uključivati ažuriranje aplikacijskog okruženja i ponovno pokretanje dugotrajnih radnika. Ova usluga zahtijeva autentikaciju; ne postoji neautentificirani način rada. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo oblik Bearer jer zadržava vjerodajnicu izvan URL-ova i zapisnika pristupa.
Potvrdite točnu krajnju točku
Operacija snimanja je HTTP GET zahtjev prema https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita url određuje stranicu za snimanje, a uspješan odgovor sadrži tijelo image/png te zaglavlja odgovora za predmemoriju i kvotu.
Pošaljite jedan minimalan zahtjev s pouzdanog terminala. Izlaz je binaran, zato ga zapišite u datoteku uz zadržavanje zaglavlja za pregled:
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
Ne lijepite token u kontrolu izvornog koda ni u povijest ljuske na zajedničkom računalu. Umjesto toga, stavite ga u Laravelovu lokalnu konfiguraciju okruženja:
# .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
Arhitektura i kompromisi
Web zahtjev treba brzo provjeriti i spremiti oznaku. Posao u redu čekanja zatim poziva servis za snimke zaslona, provjerava granicu odgovora i zapisuje lokalno kontrolirani PNG. Stranica s oznakama prikazuje tu pohranjenu datoteku umjesto ugrađivanja proizvoljne udaljene slike.
Ovo razdvajanje aplikaciji daje ograničenu HTTP latenciju, ponovne pokušaje na razini reda čekanja i stabilan pregled čak i ako se odredište kasnije promijeni. Također troši prostor za pohranu i znači da su pregledi eventualno konzistentni: nova oznaka nakratko prikazuje stanje na čekanju.
Relevantna struktura projekta namjerno je mala:
app/Http/Controllers/BookmarkController.phpprovjerava i stvara oznake.app/Services/ScreenshotClient.phpupravlja vanjskim HTTP ugovorom.app/Services/ScreenshotResult.phpmapira transportne odgovore u ishode domene.app/Jobs/CaptureBookmarkPreview.phpupravlja postojanošću i ponašanjem ponovnih pokušaja.resources/views/bookmarks/index.blade.phpsigurno prikazuje preglede.
Izradite pohranu oznaka
Ova implementacija pretpostavlja PHP 8.3 ili noviji, podržanu Laravel aplikaciju, konfiguriranu bazu podataka i funkcionalan pozadinski sustav reda čekanja. Generirajte dijelove aplikacije i tablicu reda čekanja u bazi podataka:
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
Definirajte polja oznake u generiranoj migraciji:
<?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');
}
};
Učinite te atribute dodjeljivima i pretvorite operativne metapodatke:
<?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'];
}
}
Konfigurirajte API granicu
Dodajte namjenski unos u config/services.php. Čitanje env() samo iz konfiguracijskih datoteka održava aplikaciju kompatibilnom s Laravelovom predmemorijom konfiguracije:
'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),
],
Mapirajte svaki udaljeni odgovor u mali objekt domene. Nazive zaglavlja izvan dokumentiranih kategorija predmemorije i kvote ne treba nagađati, stoga mapper zadržava zaglavlja čiji nazivi označavaju podatke o predmemoriji, kvoti ili ograničenju brzine. Također bilježi standardno zaglavlje Retry-After kada je prisutno.
<?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,
) {}
}
Klijent provodi vremenska ograničenja, provjerava status i vrstu sadržaja, ograničava veličinu prihvaćenog tijela i potvrđuje PNG potpis. Neuspjesi povezivanja i pogreške poslužitelja dobivaju dva ograničena, kratka ponovna pokušaja. Odgovori autentikacije, provjere valjanosti i kvote ne pokušavaju se naslijepo ponovno.
<?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);
}
}
Provjerite URL-ove i pokrenite snimanje
Provjera URL-a istovremeno je sigurnosna granica i kontrola kvote. Prihvatite samo HTTP i HTTPS, odbijte vjerodajnice ugrađene u URL i odbijte očita lokalna odredišta. Kontrole odredišta na strani pružatelja ostaju ključne jer provjera aplikacije ne može ukloniti DNS ponovno vezanje na granici udaljenog dohvaćanja.
<?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');
}
}
Posao pohranjuje samo provjerene PNG bajtove. Odgovori kvote koriste ograničeno odgađanje na temelju Retry-After kada je dostupno; neuspjesi prijenosa i poslužitelja koriste dulji raspored odgode posla. Trajni neuspjesi bilježe se bez izbacivanja iznimke, čime se sprječavaju besmisleni pokušaji u redu čekanja.
<?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',
]);
}
}
Prikazujte samo kontrolirane preglede
Registrirajte rute i prikažite izbjegnute podatke oznaka. Autorizaciju treba dodati ako oznake pripadaju pojedinačnim korisnicima.
// 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
Testirajte bez pozivanja aktivne usluge
Laravelov HTTP lažnjak čini API granicu determinističkom. Ovi testovi provjeravaju autentikaciju, kodiranje upita, provjeru PNG-a, očuvanje zaglavlja i pohranu:
<?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);
}
}
Implementacija, nadzornosti i uobičajeni neuspjesi
U produkciji ubrizgajte token putem upravitelja tajnama platforme, pokrenite php artisan config:cache, primijenite migracije, osigurajte da je javni disk trajan i držite radnika reda čekanja pod nadzorom upravitelja procesa. Nakon rotacije servisnog tokena ili implementacije koda ponovno pokrenite radnike pomoću php artisan queue:restart.
Pratite broj oznaka ready, failed i dugotrajno pending. Zapisnici namjerno sadrže ID oznake, host, status i kategoriju neuspjeha, ali ne token, cijeli niz upita URL-a ni tijelo odgovora.
- Neuspjesi autentikacije: potvrdite aktivaciju i token ograničen na servis, zatim ponovno izgradite predmemoriju konfiguracije i ponovno pokrenite radnike.
- Snimanja ograničena kvotom: pregledajte očuvane metapodatke kvote, smanjite učestalost ponovnog snimanja i provjerite odgovara li odabrani plan potražnji.
- Nevažeći odgovori slika: zadržite odbijanje. Nikada ne spremajte neočekivanu vrstu sadržaja kao datoteku koju preglednik može prikazati.
- Trajno redovi na čekanju: provjerite obrađuje li radnik konfigurirani red čekanja te nadziru li se neuspjeli poslovi.
- Nedostajuće slike: potvrdite
storage:link, dozvole diska i trajnu dijeljenu pohranu kada se koristi više instanci aplikacije.
Završni kontrolni popis provjere
- Spremite javni HTTPS URL i potvrdite da se web zahtjev odmah vraća.
- Pokrenite
php artisan queue:work --tries=4i promatrajte kako oznaka postajeready. - Provjerite da pohranjena datoteka počinje PNG potpisom i poslužuje se s javnog diska.
- Pokrenite
php artisan testbez aktivnog API prometa. - Testirajte nevažeći lokalni URL, nevažeći token, simulirani 429 i simulirani neuspjeh poslužitelja.
- Potvrdite da zapisnici aplikacije i pohranjeni metapodaci ne sadrže vjerodajnicu ni potpuni osjetljivi URL.
Sama snimka zaslona samo je vidljivi rezultat. Produkcijska značajka je granica oko nje: kontrolirani unosi, tajna zadržana izvan URL-ova, determinističko mapiranje odgovora, ograničeni ponovni pokušaji, sigurna pohrana i dovoljno operativnog konteksta za dijagnosticiranje neuspjeha. Kada su ti dijelovi postavljeni, pregledi poveznica prestaju biti infrastruktura preglednika i postaju običan, pouzdan dio domene oznaka.