Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.65% covered (success)
93.65%
59 / 63
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
SafeRemoteMediaDownloader
93.65% covered (success)
93.65%
59 / 63
33.33% covered (danger)
33.33%
1 / 3
25.16
0.00% covered (danger)
0.00%
0 / 1
 download
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
12
 isSafeUrl
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
11.31
 isPrivateIp
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
1<?php
2
3declare(strict_types=1);
4
5namespace ImovelZapAi\Importing\Application\Services;
6
7use Illuminate\Support\Facades\Http;
8use Illuminate\Support\Facades\Storage;
9
10final class SafeRemoteMediaDownloader
11{
12    /**
13     * @return array{path: string, checksum: string, mime: string}|null
14     */
15    public function download(string $url, string $propertyId, int $order): ?array
16    {
17        if (! $this->isSafeUrl($url)) {
18            return null;
19        }
20
21        $timeout = (int) config('importing.media.timeout_seconds', 15);
22        $maxBytes = (int) config('importing.media.max_bytes', 8 * 1024 * 1024);
23        $allowed = config('importing.media.allowed_mimes', ['image/jpeg', 'image/png', 'image/webp']);
24
25        $response = Http::timeout($timeout)
26            ->withOptions(['allow_redirects' => ['max' => 3]])
27            ->withHeaders(['User-Agent' => 'ImovelZapAi-Importing/1.0'])
28            ->get($url);
29
30        if (! $response->successful()) {
31            return null;
32        }
33
34        $body = $response->body();
35        if (strlen($body) === 0 || strlen($body) > $maxBytes) {
36            return null;
37        }
38
39        $mime = (string) ($response->header('Content-Type') ?: '');
40        $mime = strtolower(trim(explode(';', $mime)[0]));
41
42        if ($mime === '' || ! in_array($mime, $allowed, true)) {
43            $finfo = new \finfo(FILEINFO_MIME_TYPE);
44            $mime = (string) $finfo->buffer($body);
45            if (! in_array($mime, $allowed, true)) {
46                return null;
47            }
48        }
49
50        $checksum = hash('sha256', $body);
51        $extension = match ($mime) {
52            'image/png' => 'png',
53            'image/webp' => 'webp',
54            default => 'jpg',
55        };
56
57        $relative = "properties/{$propertyId}/import-{$order}-{$checksum}.{$extension}";
58        Storage::disk('public')->put($relative, $body);
59
60        return [
61            'path' => $relative,
62            'checksum' => $checksum,
63            'mime' => $mime,
64        ];
65    }
66
67    public function isSafeUrl(string $url): bool
68    {
69        if (! filter_var($url, FILTER_VALIDATE_URL)) {
70            return false;
71        }
72
73        $parts = parse_url($url);
74        if ($parts === false) {
75            return false;
76        }
77
78        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
79        if (! in_array($scheme, ['http', 'https'], true)) {
80            return false;
81        }
82
83        $host = (string) ($parts['host'] ?? '');
84        if ($host === '' || strcasecmp($host, 'localhost') === 0) {
85            return false;
86        }
87
88        $ips = [];
89        if (filter_var($host, FILTER_VALIDATE_IP)) {
90            $ips[] = $host;
91        } else {
92            $resolved = @gethostbynamel($host) ?: [];
93            $ips = $resolved;
94        }
95
96        if ($ips === []) {
97            return false;
98        }
99
100        foreach ($ips as $ip) {
101            if ($this->isPrivateIp($ip)) {
102                return false;
103            }
104        }
105
106        return true;
107    }
108
109    private function isPrivateIp(string $ip): bool
110    {
111        if (! filter_var($ip, FILTER_VALIDATE_IP)) {
112            return true;
113        }
114
115        return filter_var(
116            $ip,
117            FILTER_VALIDATE_IP,
118            FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
119        ) === false;
120    }
121}