Туториали

Native PHP 8.3: Automate Website Stack Audits for Redesign Quotes

Нативен PHP 8.3: Автоматизирајте ревизии на веб-стекот за понуди за редизајн

Понудата за редизајн може да се распадне кога „едноставната брошурна веб-страница“ ќе се покаже дека содржи застарен CMS, неколку производи за аналитика, CDN, вграден систем за е-трговија и JavaScript зависности што никој не ги споменал. Проверувањето на технолошкиот стек пред проценувањето на работата ги заменува претпоставките со докази.

Овој туторијал изработува продукциски ориентирана Native PHP 8.3 апликација од командна линија што ја испраќа јавната URL-адреса на клиентот до API-то Website Technology Detector, ги мапира резултатите оценети според доверба во доменски објекти и создава повторно употреблив JSON извештај за понуда за редизајн. Дизајнот ги држи HTTP-грижите изолирани, намерно се справува со привремени неуспеси и останува едноставен за тестирање и распоредување.

Добијте пристап и копирајте го токенот за услугата

Најпрво, создајте сметка на https://ai.mihajlo.mk/register, или користете https://ai.mihajlo.mk/login ако веќе имате.

  1. Отворете ја страницата на услугата Website Technology Detector.
  2. Изберете достапен Free, Plus или Pro план и завршете ја неговата активација.
  3. Отворете ја официјалната документација за услугата.
  4. Најдете го панелот Service token и копирајте го токенот ограничен на услугата.
  5. Зачувајте го во конфигурацијата на околината на проектот, никогаш во PHP изворен код.

Оваа услуга бара автентикација; нема режим без токен. Прифаќа Bearer токен, заглавие X-API-Token или параметар за барање token. Ќе ја користиме Bearer-формата бидејќи ја задржува автентикацијата надвор од URL-адресите и дневниците за пристап.

Повторното генерирање на токенот за услугата го отповикува претходно активниот токен. Сметајте ја ротацијата за операција на распоредување: ажурирајте ја тајната насекаде каде што се извршува командата за ревизија, потврдете го новиот токен и дури потоа отстранете ја секоја застарена конфигурација.

Потврдете го HTTP договорот

Точниот повик е POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Неговото JSON тело содржи url.

Пред да напишете код за апликацијата, направете едно минимално барање. Заменете го локално местодржачот, но не ја зачувувајте добиената команда во датотеки со историја на школката или во документација на проектот со вистински токен.

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'

Одговорот содржи детекции на технологии со доверба, докази, информации за верзијата и информации за пренасочување. Оддалечениот JSON и понатаму мора да се третира како недоверлив влез: полињата може да недостигаат, да бидат празни или неправилно форматирани за време на инцидент во надворешната услуга или идна транзиција на договорот.

Обликувајте го Native PHP проектот

Апликацијата намерно е CLI команда. Развивач што подготвува понуда може да ја изврши по потреба, да го зачува JSON-от покрај другите белешки од истражувањето и да избегне изложување јавна крајна точка што секој би можел да ја користи за трошење на квотата на сметката.

Ви треба PHP 8.3 или понов, екстензиите cURL и JSON, Composer и PHPUnit 11 за тестови. Границата на API-то има четири слоја: cURL транспорт, услуга за детектор со политика за повторни обиди, доменски објекти за одговор и мал адаптер за команда.

quote-stack-audit/
├── bin/audit-site
├── src/
│   ├── AuditReport.php
│   ├── DetectionResult.php
│   ├── HttpResponse.php
│   ├── Technology.php
│   ├── Transport.php
│   ├── TransportException.php
│   ├── CurlTransport.php
│   └── WebsiteDetector.php
├── tests/WebsiteDetectorTest.php
├── .env.example
├── .gitignore
├── composer.json
└── phpunit.xml

Најпрво создајте ги датотеките за зависности и околина:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "QuoteAudit\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "QuoteAudit\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit"
  }
}
# .env.example
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN

# .gitignore
.env.local
vendor/
build/

cp .env.example .env.local
composer install

Ставете го вистинскиот токен само во .env.local. Верзијата што се зачувува во репозиториумот содржи местодржач. Во продукција треба да се вметне истата променлива преку менаџерот за тајни на хостинг-платформата, наместо да се распоредува датотека со тајна.

Изградете ограничен cURL транспорт

Транспортот се поврзува само со фиксната HTTPS API крајна точка. Користи временско ограничување од три секунди за поврзување и вкупно временско ограничување од петнаесет секунди, ги зачувува заглавијата на одговорот за обработка на ограничувањата на стапката и ја остава проверката на сертификатите овозможена.

<?php
// src/Transport.php
namespace QuoteAudit;

interface Transport
{
    public function send(string $json, string $token): HttpResponse;
}

// src/HttpResponse.php
namespace QuoteAudit;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// src/TransportException.php
namespace QuoteAudit;

final class TransportException extends \RuntimeException {}

// src/CurlTransport.php
namespace QuoteAudit;

final class CurlTransport implements Transport
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    public function send(string $json, string $token): HttpResponse
    {
        $handle = curl_init(self::ENDPOINT);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        $raw = curl_exec($handle);

        if ($raw === false) {
            throw new TransportException(curl_error($handle));
        }

        $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        $headerSize = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
        $headerText = substr($raw, 0, $headerSize);
        $body = substr($raw, $headerSize);
        $headers = [];

        foreach (preg_split('/\r\n|\r|\n/', $headerText) as $line) {
            if (!str_contains($line, ':')) {
                continue;
            }

            [$name, $value] = explode(':', $line, 2);
            $headers[strtolower(trim($name))] = trim($value);
        }

        return new HttpResponse($status, $headers, $body);
    }
}

Мапирајте го оддалечениот JSON во доменски резултати

Понудата не треба директно да зависи од произволна низа на одговор. Доменскиот модел ги задржува корисните концепти, додека им овозможува на доказите, верзиите и информациите за пренасочување да задржат структурирани вредности наместо да ги претвора во погрешни низи.

<?php
// src/Technology.php
namespace QuoteAudit;

final readonly class Technology implements \JsonSerializable
{
    public function __construct(
        public string $name,
        public ?float $confidence,
        public array $evidence,
        public array $versions,
    ) {}

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

// src/AuditReport.php
namespace QuoteAudit;

final readonly class AuditReport implements \JsonSerializable
{
    public function __construct(
        public string $requestedUrl,
        public array $technologies,
        public array $redirectInformation,
    ) {}

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

// src/DetectionResult.php
namespace QuoteAudit;

final readonly class DetectionResult implements \JsonSerializable
{
    private function __construct(
        public bool $ok,
        public ?AuditReport $report,
        public ?string $error,
        public ?int $httpStatus,
    ) {}

    public static function success(AuditReport $report): self
    {
        return new self(true, $report, null, null);
    }

    public static function failure(string $error, ?int $status = null): self
    {
        return new self(false, null, $error, $status);
    }

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

Услугата повторува само при транспортни грешки, HTTP 408, HTTP 429 и одговори 5xx од серверската страна. Неуспесите на автентикацијата и валидацијата не се повторуваат. Одложувањето е ограничено, ја почитува целобројната вредност Retry-After кога е присутна и во спротивно додава мала случајна варијација.

<?php
// src/WebsiteDetector.php
namespace QuoteAudit;

final class WebsiteDetector
{
    private readonly \Closure $sleep;
    private readonly \Closure $log;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        ?\Closure $sleep = null,
        ?\Closure $log = null,
    ) {
        $this->sleep = $sleep ?? static fn(int $ms) => usleep($ms * 1000);
        $this->log = $log ?? static function (array $context): void {};
    }

    public function detect(string $url): DetectionResult
    {
        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));

        if (
            filter_var($url, FILTER_VALIDATE_URL) === false ||
            !in_array($scheme, ['http', 'https'], true) ||
            isset($parts['user']) ||
            isset($parts['pass'])
        ) {
            return DetectionResult::failure('invalid_url');
        }

        $body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->send($body, $this->token);
            } catch (TransportException $exception) {
                ($this->log)([
                    'event' => 'detector_transport_error',
                    'attempt' => $attempt,
                    'target_host' => $parts['host'] ?? null,
                ]);

                if ($attempt === 3) {
                    return DetectionResult::failure('transport_error');
                }

                ($this->sleep)($this->backoff($attempt, null));
                continue;
            }

            ($this->log)([
                'event' => 'detector_response',
                'attempt' => $attempt,
                'status' => $response->status,
                'target_host' => $parts['host'] ?? null,
            ]);

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($payload)) {
                        throw new \UnexpectedValueException();
                    }

                    return DetectionResult::success($this->map($url, $payload));
                } catch (\JsonException|\UnexpectedValueException) {
                    return DetectionResult::failure(
                        'malformed_response',
                        $response->status
                    );
                }
            }

            $retryable = $response->status === 408 ||
                $response->status === 429 ||
                $response->status >= 500;

            if ($retryable && $attempt < 3) {
                ($this->sleep)($this->backoff(
                    $attempt,
                    $response->headers['retry-after'] ?? null
                ));
                continue;
            }

            if (in_array($response->status, [401, 403], true)) {
                return DetectionResult::failure(
                    'authentication_failed',
                    $response->status
                );
            }

            if ($response->status === 429) {
                return DetectionResult::failure(
                    'rate_limited',
                    $response->status
                );
            }

            return DetectionResult::failure(
                $response->status >= 500
                    ? 'upstream_unavailable'
                    : 'request_rejected',
                $response->status
            );
        }

        return DetectionResult::failure('unexpected_failure');
    }

    private function map(string $url, array $payload): AuditReport
    {
        $rows = $this->findCollection($payload);

        if ($rows === null) {
            throw new \UnexpectedValueException();
        }

        $technologies = [];

        foreach ($rows as $row) {
            if (!is_array($row) || !is_string($row['name'] ?? null)) {
                continue;
            }

            $confidence = $row['confidence'] ?? null;

            $technologies[] = new Technology(
                trim($row['name']),
                is_numeric($confidence) ? (float) $confidence : null,
                $this->asList($row['evidence'] ?? null),
                $this->asList($row['versions'] ?? null),
            );
        }

        if ($rows !== [] && $technologies === []) {
            throw new \UnexpectedValueException();
        }

        return new AuditReport(
            $url,
            $technologies,
            $this->collectRedirectInformation($payload)
        );
    }

    private function findCollection(array $node): ?array
    {
        foreach ($node as $key => $value) {
            if (
                is_string($key) &&
                in_array(strtolower($key), ['technologies', 'detections'], true) &&
                is_array($value)
            ) {
                return $value;
            }

            if (is_array($value)) {
                $found = $this->findCollection($value);

                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    private function collectRedirectInformation(
        array $node,
        string $path = ''
    ): array {
        $result = [];

        foreach ($node as $key => $value) {
            $current = $path === '' ? (string) $key : $path . '.' . $key;

            if (is_string($key) && str_contains(strtolower($key), 'redirect')) {
                $result[$current] = $value;
            } elseif (is_array($value)) {
                $result += $this->collectRedirectInformation($value, $current);
            }
        }

        return $result;
    }

    private function asList(mixed $value): array
    {
        if ($value === null) {
            return [];
        }

        return is_array($value) ? array_values($value) : [$value];
    }

    private function backoff(int $attempt, ?string $retryAfter): int
    {
        if ($retryAfter !== null && ctype_digit(trim($retryAfter))) {
            return min(2000, (int) $retryAfter * 1000);
        }

        return min(2000, 250 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }
}

Додајте ја командата за ревизија

Влезната точка вчитува едноставна локална датотека за околина, запишува структурирани оперативни настани во стандардниот излез за грешки и го резервира стандардниот излез за конечниот JSON-артефакт. Никогаш не го запишува токенот, телото на одговорот или целосната URL-адреса на клиентот во дневник.

<?php
// bin/audit-site
declare(strict_types=1);

use QuoteAudit\CurlTransport;
use QuoteAudit\WebsiteDetector;

require dirname(__DIR__) . '/vendor/autoload.php';

function loadEnvironment(string $file): void
{
    if (!is_file($file)) {
        return;
    }

    foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
        $line = trim($line);

        if ($line === '' || str_starts_with($line, '#')) {
            continue;
        }

        [$key, $value] = array_pad(explode('=', $line, 2), 2, '');

        if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $key)) {
            continue;
        }

        $value = trim($value);
        if (
            strlen($value) >= 2 &&
            (($value[0] === '"' && str_ends_with($value, '"')) ||
             ($value[0] === "'" && str_ends_with($value, "'")))
        ) {
            $value = substr($value, 1, -1);
        }

        if (getenv($key) === false) {
            putenv($key . '=' . $value);
        }
    }
}

loadEnvironment(dirname(__DIR__) . '/.env.local');

$url = $argv[1] ?? '';
$token = getenv('WEBSITE_DETECTOR_TOKEN');

if (!is_string($token) || $token === '' || $token === 'YOUR_SERVICE_TOKEN') {
    fwrite(STDERR, "WEBSITE_DETECTOR_TOKEN is not configured.\n");
    exit(78);
}

$logger = static function (array $context): void {
    fwrite(STDERR, json_encode($context, JSON_UNESCAPED_SLASHES) . PHP_EOL);
};

$result = (new WebsiteDetector(
    new CurlTransport(),
    $token,
    log: $logger
))->detect($url);

echo json_encode(
    $result,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . PHP_EOL;

exit($result->ok ? 0 : 1);

Извршете ја со јавна URL-адреса на клиент и зачувајте само успешен излез како артефакт за понуда:

chmod +x bin/audit-site
php bin/audit-site https://example.com > build/example-com-stack.json

Тестирајте повторни обиди без повикување на услугата

Детерминистички лажен транспорт ги прави патеките на неуспех брзи и без трошење квота. Најважните тестови докажуваат дека ограничувањата на стапката се повторуваат, неуспесите на автентикацијата не се повторуваат и дека неправилно форматираните успешни тела стануваат структурирани неуспеси.

<?php
// tests/WebsiteDetectorTest.php
namespace QuoteAudit\Tests;

use PHPUnit\Framework\TestCase;
use QuoteAudit\HttpResponse;
use QuoteAudit\Transport;
use QuoteAudit\WebsiteDetector;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function send(string $json, string $token): HttpResponse
    {
        return $this->responses[$this->calls++];
    }
}

final class WebsiteDetectorTest extends TestCase
{
    public function testRetriesRateLimitThenMapsDetection(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '0'], '{}'),
            new HttpResponse(200, [], json_encode([
                'technologies' => [[
                    'name' => 'PHP',
                    'confidence' => 95,
                    'evidence' => ['response evidence'],
                    'versions' => ['8.3'],
                ]],
                'redirects' => [],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $result = (new WebsiteDetector(
            $fake,
            'test-token',
            static function (int $milliseconds): void {}
        ))->detect('https://example.com');

        self::assertTrue($result->ok);
        self::assertSame(2, $fake->calls);
        self::assertSame('PHP', $result->report->technologies[0]->name);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(401, [], '{}'),
        ]);

        $result = (new WebsiteDetector(
            $fake,
            'invalid-token',
            static function (int $milliseconds): void {}
        ))->detect('https://example.com');

        self::assertFalse($result->ok);
        self::assertSame('authentication_failed', $result->error);
        self::assertSame(1, $fake->calls);
    }

    public function testRejectsMalformedSuccessBody(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], '{"unexpected":true}'),
        ]);

        $result = (new WebsiteDetector(
            $fake,
            'test-token'
        ))->detect('https://example.com');

        self::assertSame('malformed_response', $result->error);
    }
}
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="quote-audit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
composer test

Безбедност, набљудливост и распоредување

Поднесената URL-адреса се открива на надворешната услуга, затоа избегнувајте URL-адреси што содржат приватни параметри за барање, токени за преглед, идентификатори на клиенти или ингеренции. Овој проект прифаќа само HTTP и HTTPS URL-адреси без вградени кориснички имиња или лозинки. Ако командата стане веб-функција, додајте авторизација, квоти, политика за одобрени хостови и извршување во заднина пред да ја изложите.

Одржувајте ги дневниците оперативни, наместо форензички. Запишувајте го името на хостот на целта, бројот на обидот, HTTP статусот, времетраењето и категоријата на конечен неуспех. Никогаш не запишувајте заглавија за авторизација или целосни тела на одговори. Следете ги повторените исходи authentication_failed, rate_limited и upstream_unavailable одделно, бидејќи бараат различни одговори.

Распоредете со овозможени PHP екстензии cURL и JSON, извршете composer install --no-dev --classmap-authoritative и внесете WEBSITE_DETECTOR_TOKEN од складиште за тајни. Планирајте ја ротацијата на токените намерно бидејќи повторното генерирање веднаш го поништува претходниот активен токен.

Вообичаени неуспеси

  • 401 или 403: проверете ги токенот ограничен на услугата, активацијата на планот и тајната за распоредување. Не повторувајте автоматски.
  • 429: почитувајте ја ограничената политика за повторни обиди, потоа одложете ја ревизијата или разгледајте го капацитетот на планот.
  • Неправилно форматиран одговор: зачувајте ја категоријата на неуспех и истражете без тивко да го третирате како празна листа на технологии.
  • Истекување на време или 5xx: дозволете ограничените повторни обиди да завршат, а потоа извршете повторно подоцна наместо бесконечно да ја блокирате подготовката на понудата.
  • Докази со ниска доверба: вклучете ги во белешките од истражувањето, но потврдете ги рачно пред да ги претворите во опсег или цени.

Конечна контролна листа за верификација

  • Вистинскиот токен постои само во конфигурација поддржана од околината.
  • Минималното API барање успева со точната POST крајна точка.
  • composer test поминува без мрежен пристап.
  • Успешната ревизија содржи мапирани технологии, доверба, докази, верзии и достапни информации за пренасочување.
  • Неуспесите на автентикацијата прават едно барање; привремените неуспеси прават не повеќе од три.
  • Дневниците не содржат токен, тело на одговор или чувствителни URL-податоци.
  • Генерираниот JSON се прегледува заедно со веб-страницата, наместо да се третира како непогрешлив опсег.

Детекторот на стек не ја пишува понудата за редизајн наместо вас. Тој прави нешто повредно: го претвора првиот технички разговор од шпекулација во преглед поткрепен со докази. Кога ревизијата е ограничена, тестирана и експлицитна за неизвесноста, мала PHP команда станува сигурен дел од професионалното истражување, наместо уште една кревка скрипта.

Портрет на автор на блогот

Mihajlo

Јас сум Михајло - развивач поттикнат од љубопитност, дисциплина и постојаната желба да создадам нешто значајно. Споделувам увиди, упатства и бесплатни услуги за да им помогнам на другите да ја поедностават својата работа и да растат во постојано развивачкиот свет на софтверот и вештачката интелигенција.