Laravel oznake: sigurni vizualni pregledi poveznica s API integracijom snimki zaslona
Oznaka je korisnija kada je možete prepoznati na prvi pogled. Naslov i URL prisiljavaju vas da pamtite što se krije iza poveznice; snimka zaslona pretvara isti popis u vizualni radni prostor.
Težak dio nije prikazivanje slike. Teško je sigurno snimati proizvoljne web-stranice, bez pokretanja preglednika u spremnicima vaše aplikacije, blokiranja zahtjeva dok se Chromium pokreće ili pretvaranja vašeg poslužitelja u neograničeni proxy za dohvaćanje URL-ova. Ova Laravel implementacija delegira snimanje namjenskom Screenshot API-ju, asinkrono obrađuje PNG, provjerava odgovor na granici aplikacije te čuva predmemorijske i kvotne metapodatke za operacije.
Dobijte pristup Screenshot API-ju
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 aktivaciju.
- Otvorite službenu dokumentaciju Screenshot API-ja.
- Pronađite ploču Service token i kopirajte njezin token ograničen na uslugu.
Ponovno generiranje servisnog tokena opoziva prethodno aktivni token, stoga rotaciju tretirajte kao promjenu pri implementaciji: ažurirajte svako okruženje koje ga koristi, ponovno pokrenite radnike, a zatim namjerno opozovite ili ponovno generirajte token.
Ovo nije usluga bez tokena. Svaki zahtjev za snimanje mora se autentificirati Bearer tokenom, zaglavljem X-API-Token ili parametrom upita token. Upotrijebit ćemo zaglavlje Bearer jer se vjerodajnice u upitu mogu pojaviti u zapisnicima pristupa, povijesti preglednika i URL-ovima za nadzor.
Potvrdite točnu krajnju točku
API poziv je GET https://ai.mihajlo.mk/api/screenshot-api/v1/capture. Njegov obavezni parametar upita je url, a uspješan odgovor sadrži tijelo image/png te zaglavlja predmemorije i kvote.
Pokrenite jedan minimalni test prije pisanja integracijskog koda:
SCREENSHOT_TOKEN=YOUR_SERVICE_TOKEN
curl --fail-with-body \
--header "Authorization: Bearer ${SCREENSHOT_TOKEN}" \
--header "Accept: image/png" \
--get \
--data-urlencode "url=https://example.com" \
--output example.png \
https://ai.mihajlo.mk/api/screenshot-api/v1/capture
Sada pohranite vjerodajnicu u Laravelovu konfiguraciju okruženja. Nikada ne predajte popunjenu datoteku .env u repozitorij.
SCREENSHOT_API_TOKEN=YOUR_SERVICE_TOKEN
SCREENSHOT_API_ENDPOINT=https://ai.mihajlo.mk/api/screenshot-api/v1/capture
SCREENSHOT_MAX_BYTES=10485760
QUEUE_CONNECTION=database
Dodajte granicu usluge u config/services.php:
'screenshot' => [
'token' => env('SCREENSHOT_API_TOKEN'),
'endpoint' => env(
'SCREENSHOT_API_ENDPOINT',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
),
'max_bytes' => (int) env('SCREENSHOT_MAX_BYTES', 10 * 1024 * 1024),
],
Arhitektura: snimanje držite izvan putanje zahtjeva
Aplikacija prihvaća oznaku, odmah je sprema i šalje zadatak u red. Zadatak poziva API, provjerava PNG, pohranjuje ga na Laravelov privatni lokalni disk i ažurira oznaku. Zaštićeni kontroler kasnije poslužuje datoteku.
Ova mala asinkrona granica je važna. Generiranje snimke zaslona uključuje drugu mrežu i udaljeni renderer; čekanje korisnika povezalo bi latenciju obrasca s oboje. Red također daje prolaznim neuspjesima kontrolirano mjesto za ponovni pokušaj.
Relevantna struktura projekta je:
app/
Data/ScreenshotResult.php
Exceptions/ScreenshotFailure.php
Http/Controllers/BookmarkController.php
Http/Requests/StoreBookmarkRequest.php
Jobs/GenerateBookmarkPreview.php
Models/Bookmark.php
Services/ScreenshotClient.php
database/migrations/..._create_bookmarks_table.php
tests/Unit/ScreenshotClientTest.php
Ovaj vodič pretpostavlja Laravel aplikaciju koja koristi PHP 8.3 ili noviji, s već omogućenim autentificiranjem. Za red temeljen na bazi podataka izradite tablicu reda ako je projekt već nema, a zatim pokrenite migracije:
php artisan make:model Bookmark -m
php artisan make:request StoreBookmarkRequest
php artisan make:controller BookmarkController
php artisan make:job GenerateBookmarkPreview
php artisan make:test ScreenshotClientTest --unit
php artisan make:queue-table
php artisan migrate
Modelirajte pregled kao stanje, a ne kao nullable sliku
Nedostajuća putanja može značiti da je stavka u redu, da nije uspjela ili da nikad nije zatražena. Učinite ta stanja eksplicitnima i zadržite operativne metapodatke odgovora bez pretvaranja da su nedokumentirani nazivi zaglavlja stabilna polja.
<?php
// database/migrations/..._create_bookmarks_table.php
Schema::create('bookmarks', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('url');
$table->string('preview_status')->default('queued');
$table->string('preview_path')->nullable();
$table->string('preview_error')->nullable();
$table->json('preview_meta')->nullable();
$table->timestamp('preview_generated_at')->nullable();
$table->timestamps();
});
// app/Models/Bookmark.php
final class Bookmark extends Model
{
protected $fillable = [
'title', 'url', 'preview_status', 'preview_path',
'preview_error', 'preview_meta', 'preview_generated_at',
];
protected function casts(): array
{
return [
'preview_meta' => 'array',
'preview_generated_at' => 'immutable_datetime',
];
}
}
Izgradite obrambenu API granicu
Klijent vraća domenski objekt umjesto da Laravelov HTTP odgovor propušta kroz cijelu aplikaciju. Koristi ograničena vremenska ograničenja, ponovno pokušava pri neuspjesima veze, HTTP 408 i pogreškama poslužitelja uz kratko odgađanje, ali ne pokušava naslijepo ponovno neispravne zahtjeve ili neuspjelu autentifikaciju.
<?php
// app/Data/ScreenshotResult.php
namespace App\Data;
final readonly class ScreenshotResult
{
public function __construct(
public string $png,
public array $operationalHeaders,
) {}
}
// app/Exceptions/ScreenshotFailure.php
namespace App\Exceptions;
use RuntimeException;
final class ScreenshotFailure extends RuntimeException
{
public function __construct(
public readonly string $kind,
public readonly bool $retryable,
public readonly ?int $retryAfter = null,
string $message = 'Screenshot capture failed',
) {
parent::__construct($message);
}
}
<?php
// app/Services/ScreenshotClient.php
namespace App\Services;
use App\Data\ScreenshotResult;
use App\Exceptions\ScreenshotFailure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
final class ScreenshotClient
{
public function capture(string $url): ScreenshotResult
{
$token = (string) config('services.screenshot.token');
if ($token === '') {
throw new ScreenshotFailure(
'configuration', false, null, 'Screenshot token is missing'
);
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = Http::withToken($token)
->accept('image/png')
->connectTimeout(3)
->timeout(20)
->get(config('services.screenshot.endpoint'), [
'url' => $url,
]);
} catch (ConnectionException $exception) {
if ($attempt === 3) {
throw new ScreenshotFailure(
'connection', true, null, 'Screenshot connection failed'
);
}
$this->backoff($attempt);
continue;
}
if ($response->successful()) {
return $this->mapPng($response);
}
if ($response->status() === 429) {
throw new ScreenshotFailure(
'quota', true, $this->retryAfter($response),
'Screenshot quota is temporarily unavailable'
);
}
if (in_array($response->status(), [401, 403], true)) {
throw new ScreenshotFailure(
'authentication', false, null,
'Screenshot credentials were rejected'
);
}
if (in_array($response->status(), [400, 422], true)) {
throw new ScreenshotFailure(
'request_rejected', false, null,
'Screenshot request was rejected'
);
}
if ($response->status() === 408 || $response->serverError()) {
if ($attempt < 3) {
$this->backoff($attempt);
continue;
}
throw new ScreenshotFailure(
'upstream_transient', true, null,
'Screenshot service remained unavailable'
);
}
throw new ScreenshotFailure(
'upstream_rejected', false, null,
'Screenshot service returned an unexpected status'
);
}
throw new ScreenshotFailure('unknown', true);
}
private function mapPng(Response $response): ScreenshotResult
{
$body = $response->body();
$contentType = strtolower((string) $response->header('Content-Type'));
$limit = (int) config('services.screenshot.max_bytes');
if (! str_starts_with($contentType, 'image/png')
|| ! str_starts_with($body, "\x89PNG\r\n\x1a\n")
|| strlen($body) > $limit) {
throw new ScreenshotFailure(
'invalid_image', true, null,
'Screenshot response was not an acceptable PNG'
);
}
return new ScreenshotResult($body, $this->operationalHeaders($response));
}
private function operationalHeaders(Response $response): array
{
$result = [];
foreach ($response->headers() as $name => $values) {
$normalized = strtolower($name);
if (in_array($normalized, [
'cache-control', 'age', 'etag', 'expires', 'retry-after',
], true) || str_contains($normalized, 'quota')
|| str_contains($normalized, 'ratelimit')) {
$result[$normalized] = implode(', ', $values);
}
}
return $result;
}
private function retryAfter(Response $response): int
{
$value = $response->header('Retry-After');
return is_numeric($value)
? min(max((int) $value, 60), 3600)
: 300;
}
private function backoff(int $attempt): void
{
usleep($attempt === 1 ? 250_000 : 750_000);
}
}
Mapiranje zaglavlja namjerno prepoznaje standardna zaglavlja predmemorije i zaglavlja izričito označena kao metapodaci kvote ili ograničenja stope. Time se obrađuju operativne informacije bez pohranjivanja svakog uzvodnog zaglavlja ili izmišljanja nedokumentiranih polja odgovora. Ako vaš sustav nadzora zahtijeva fiksne metrike, točne nazive preuzmite iz službene dokumentacije.
Provjerite URL-ove prije slanja u red
API će dohvatiti URL u ime aplikacije, stoga korisnici ne bi trebali moći slati vjerodajnice, lokalne nazive hostova ili doslovne IP adrese. Ograničavanje značajke na HTTPS javne nazive hostova također smanjuje slučajno otkrivanje osjetljivih internih URL-ova.
<?php
// app/Http/Requests/StoreBookmarkRequest.php
namespace App\Http\Requests;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
final class StoreBookmarkRequest extends FormRequest
{
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:200'],
'url' => [
'required', 'string', 'max:2048',
function (string $attribute, mixed $value, Closure $fail): void {
$parts = is_string($value) ? parse_url($value) : false;
$host = is_array($parts) ? ($parts['host'] ?? '') : '';
$invalid = $parts === false
|| strtolower($parts['scheme'] ?? '') !== 'https'
|| $host === ''
|| isset($parts['user'])
|| isset($parts['pass'])
|| filter_var($host, FILTER_VALIDATE_IP) !== false
|| $host === 'localhost'
|| str_ends_with(strtolower($host), '.local');
if ($invalid) {
$fail('Use an HTTPS URL with a public hostname.');
}
},
],
];
}
}
Ovo je sprječavanje zloupotrebe na strani aplikacije, a ne dokaz da se naziv hosta uvijek javno razrješava. Izbjegavajte sinkrone DNS provjere na putanji zahtjeva; DNS se može promijeniti između provjere i snimanja. Ako vaš proizvod široko prihvaća nepouzdane URL-ove, potvrdite trenutačne mjere zaštite mrežnog pristupa usluge u službenoj dokumentaciji i razmotrite popis dopuštenih domena.
Generirajte i poslužujte preglede
<?php
// app/Jobs/GenerateBookmarkPreview.php
namespace App\Jobs;
use App\Exceptions\ScreenshotFailure;
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 Throwable;
final class GenerateBookmarkPreview implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 75;
public function __construct(public readonly int $bookmarkId) {}
public function backoff(): array
{
return [30, 120, 300];
}
public function handle(ScreenshotClient $client): void
{
$bookmark = Bookmark::find($this->bookmarkId);
if ($bookmark === null) {
return;
}
try {
$result = $client->capture($bookmark->url);
} catch (ScreenshotFailure $failure) {
Log::log(
$failure->kind === 'authentication' ? 'critical' : 'warning',
'Bookmark preview capture failed',
['bookmark_id' => $this->bookmarkId, 'kind' => $failure->kind]
);
if ($failure->kind === 'quota') {
$bookmark->update(['preview_error' => 'quota_deferred']);
$this->release($failure->retryAfter ?? 300);
return;
}
if ($failure->retryable) {
throw $failure;
}
$bookmark->update([
'preview_status' => 'failed',
'preview_error' => $failure->kind,
]);
return;
}
$path = "bookmarks/previews/{$bookmark->id}.png";
Storage::disk('local')->put($path, $result->png);
$bookmark->update([
'preview_status' => 'ready',
'preview_path' => $path,
'preview_error' => null,
'preview_meta' => $result->operationalHeaders,
'preview_generated_at' => now(),
]);
}
public function failed(?Throwable $exception): void
{
Bookmark::whereKey($this->bookmarkId)->update([
'preview_status' => 'failed',
'preview_error' => 'retries_exhausted',
]);
}
}
Deterministički naziv datoteke čini duplicirane zadatke bezopasnima: kasnije uspješno snimanje zamjenjuje isti pregled. Zapisnici sadrže ID oznake i strukturiranu vrstu neuspjeha, nikada poslani URL, token, PNG ili tijelo uzvodnog odgovora.
<?php
// routes/web.php
Route::middleware('auth')->group(function () {
Route::post('/bookmarks', [BookmarkController::class, 'store']);
Route::get('/bookmarks/{bookmark}/preview', [
BookmarkController::class, 'preview',
])->name('bookmarks.preview');
});
// app/Http/Controllers/BookmarkController.php
final class BookmarkController extends Controller
{
public function store(StoreBookmarkRequest $request): RedirectResponse
{
$bookmark = Bookmark::create([
...$request->validated(),
'preview_status' => 'queued',
]);
GenerateBookmarkPreview::dispatch($bookmark->id);
return back()->with('status', 'Bookmark saved; preview queued.');
}
public function preview(Bookmark $bookmark): BinaryFileResponse
{
abort_unless(
$bookmark->preview_status === 'ready'
&& $bookmark->preview_path !== null
&& Storage::disk('local')->exists($bookmark->preview_path),
404
);
return response()->file(
Storage::disk('local')->path($bookmark->preview_path),
[
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
'X-Content-Type-Options' => 'nosniff',
]
);
}
}
Ove rute zadržite autentificiranima. U aplikaciji oznaka s više korisnika dodajte identifikator vlasnika i autorizirajte i oznaku i pregled putem Laravelove politike; sama autentifikacija nije izolacija zakupaca.
Testirajte vanjsku granicu bez mreže
Laravelov Http::fake() čini ponašanje uspjeha i kvote determinističkim.
<?php
namespace Tests\Unit;
use App\Exceptions\ScreenshotFailure;
use App\Services\ScreenshotClient;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
final class ScreenshotClientTest extends TestCase
{
public function test_it_maps_a_valid_png_and_cache_headers(): void
{
config()->set('services.screenshot.token', 'test-token');
config()->set('services.screenshot.max_bytes', 1024);
config()->set(
'services.screenshot.endpoint',
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture'
);
$png = "\x89PNG\r\n\x1a\nfake";
Http::fake([
'https://ai.mihajlo.mk/api/screenshot-api/v1/capture*' =>
Http::response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'public, max-age=60',
]),
]);
$result = app(ScreenshotClient::class)->capture(
'https://example.com'
);
$this->assertSame($png, $result->png);
$this->assertSame(
'public, max-age=60',
$result->operationalHeaders['cache-control']
);
Http::assertSent(fn ($request) =>
$request->hasHeader('Authorization', 'Bearer test-token')
&& $request['url'] === 'https://example.com'
);
}
public function test_it_defers_instead_of_retrying_a_quota_response(): void
{
config()->set('services.screenshot.token', 'test-token');
Http::fake([
'*' => Http::response('', 429, ['Retry-After' => '120']),
]);
try {
app(ScreenshotClient::class)->capture('https://example.com');
$this->fail('Expected ScreenshotFailure');
} catch (ScreenshotFailure $failure) {
$this->assertSame('quota', $failure->kind);
$this->assertSame(120, $failure->retryAfter);
}
Http::assertSentCount(1);
}
}
Implementacija, nadzor i česti neuspjesi
Pokrenite testove, predmemorirajte produkcijsku konfiguraciju i pokrenite radnika reda čije vremensko ograničenje procesa premašuje vremensko ograničenje zadatka:
php artisan test
php artisan config:cache
php artisan queue:work --tries=5 --timeout=90
Nakon promjene ili rotacije tokena ponovno izgradite predmemoriju konfiguracije i ponovno pokrenite dugotrajne radnike naredbom php artisan queue:restart. Osigurajte da je storage upisiv i postojan između implementacija ili zamijenite lokalni disk odgovarajuće konfiguriranim dijeljenim Laravelovim datotečnim diskom.
Nadzirite brojke prema strukturiranoj vrsti neuspjeha, starosti reda, iscrpljenim zadacima, latenciji snimanja i odgodama povezanima s kvotom. Upozorite na neuspjehe autentifikacije jer ponovni pokušaji ne mogu popraviti opozvani ili pogrešno implementirani token.
- 401 ili 403: provjerite token ograničen na uslugu, aktivaciju plana, unos okruženja i predmemoriju konfiguracije.
- 429 ili ponovljene odgode kvote: pregledajte vraćene metapodatke kvote, smanjite nepotrebna ponovna snimanja ili pregledajte aktivni plan.
- Neispravna slika: pregledajte status i vrstu sadržaja u kontroliranom okruženju, ali ne zapisujte binarno tijelo u zapisnike.
- Zadaci ostaju u redu: potvrdite da radnik koristi istu vezu reda kao web-proces.
- Pregled vraća 404: provjerite stanje oznake, postojanost pohrane, dozvole datoteke i zapise neuspjelih zadataka.
Završni kontrolni popis za provjeru
- Token postoji samo u konfiguraciji podržanoj okruženjem.
- Zahtjev koristi točnu GET krajnju točku za snimanje i obavezni parametar
url. - Samo prihvatljivi HTTPS javni nazivi hostova dolaze do reda.
- Klijent provodi vremenska ograničenja, ograničene ponovne pokušaje, provjeru PNG-a i lokalno ograničenje veličine.
- Neuspjesi autentifikacije i provjere ne pokušavaju se naslijepo ponovno.
- Odgovori kvote odgađaju rad umjesto stvaranja oluje ponovnih pokušaja.
- Metapodaci predmemorije i kvote prelaze API granicu bez izlaganja proizvoljnih zaglavlja.
- Datoteke pregleda ostaju privatne i poslužuju se putem autentificirane rute.
- Testovi koriste
Http::fake()i nikada ne troše stvarnu kvotu.
Završena značajka namjerno je skromna: spremite oznaku, dopustite radniku da izradi njezin vizualni pregled i sigurno poslužite rezultat. Njezina produkcijska kvaliteta proizlazi iz granica oko tog jednostavnog toka. Udaljeni renderer ostaje izvan vaše infrastrukture, vjerodajnice ostaju izvan koda, stanja neuspjeha ostaju vidljiva, a praktična minijatura nikada ne postaje izgovor za slabljenje aplikacije oko nje.