Files
BarangaySystem/app/Http/Controllers/Market/ProductPhotoSearchController.php
2026-06-06 18:43:00 +08:00

191 lines
6.8 KiB
PHP

<?php
namespace App\Http\Controllers\Market;
use App\Http\Controllers\Controller;
use App\Http\Controllers\FilesMainController;
use App\Http\Controllers\Helpers\Permissions\UserPermissions;
use App\Enums\UserActions;
use Hypervel\Http\Request;
use Hypervel\Support\Facades\Auth;
class ProductPhotoSearchController extends Controller
{
private const DDG_BROWSER_HEADERS = [
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'Accept-Language: en-US,en;q=0.9',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Referer: https://duckduckgo.com/',
];
// Step 1: fetch the DDG search page to extract the vqd session token.
// DDG requires this token to serve the image JSON endpoint.
private static function getDdgVqd(string $query): ?string
{
$url = 'https://duckduckgo.com/?q=' . urlencode($query) . '&iax=images&ia=images';
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => implode("\r\n", self::DDG_BROWSER_HEADERS),
'timeout' => 10,
]]);
$html = @file_get_contents($url, false, $ctx);
if (!$html) return null;
// The vqd token appears in the page JS in several formats depending on
// DDG's current build. Try the quoted forms first (vqd="4-xxx" /
// vqd='4-xxx' / vqd:"4-xxx"), then fall back to the bare form.
$patterns = [
'/vqd=["\']([0-9a-zA-Z._\-]+)["\']/', // vqd="4-123..." or vqd='4-123...'
'/vqd["\']?\s*[:=]\s*["\']([0-9a-zA-Z._\-]+)["\']/', // vqd:"4-123..."
'/vqd=([0-9a-zA-Z._\-]+)/', // bare vqd=4-123...
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $html, $m) && !empty($m[1])) {
return $m[1];
}
}
return null;
}
// GET /api/products/photo-search?q=...&page=1
public function search(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::SearchStockPhotos)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$query = trim($request->input('q', ''));
$page = max(1, (int) $request->input('page', 1));
if (!$query) {
return response()->json(['error' => 'Query required'], 422);
}
$vqd = self::getDdgVqd($query);
if (!$vqd) {
return response()->json(['error' => 'Could not reach image search service'], 502);
}
// s = offset; DDG returns ~15 results per call; page 1 = s=0, page 2 = s=15, etc.
$offset = ($page - 1) * 15;
$url = 'https://duckduckgo.com/i.js?' . http_build_query([
'q' => $query,
'vqd' => $vqd,
'o' => 'json',
'p' => '1',
'f' => ',,,',
'l' => 'us-en',
's' => $offset,
]);
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => implode("\r\n", self::DDG_BROWSER_HEADERS),
'timeout' => 10,
]]);
$raw = @file_get_contents($url, false, $ctx);
if ($raw === false) {
return response()->json(['error' => 'Failed to fetch image results'], 502);
}
$data = json_decode($raw, true);
$results = $data['results'] ?? [];
$photos = array_map(fn($r) => [
'id' => md5($r['image']), // stable ID from image URL
'thumb' => $r['thumbnail'], // DDG-proxied small thumb (safe to display)
'src' => $r['image'], // actual source image URL (used for download)
'title' => $r['title'] ?? '',
], array_slice($results, 0, 15));
return response()->json([
'success' => true,
'photos' => $photos,
'page' => $page,
'has_more' => count($results) >= 15,
]);
}
// POST /api/products/photo-download
// body: { src: "https://..." } — the actual source image URL from DDG results
public function download(Request $request)
{
if (!UserPermissions::isActionPermitted(Auth::user()->acct_type, UserActions::DownloadStockPhoto)) {
return response()->json(['error' => 'Unauthorized'], 403);
}
$src = $request->input('src', '');
// SSRF guard: must be http/https and must not target private/loopback IPs
$parsed = parse_url($src);
$scheme = $parsed['scheme'] ?? '';
$host = strtolower($parsed['host'] ?? '');
if (!in_array($scheme, ['http', 'https']) || !$host) {
return response()->json(['error' => 'Invalid URL'], 422);
}
// Block private/loopback ranges
if (preg_match('/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.0\.0\.0|::1)/i', $host)) {
return response()->json(['error' => 'Forbidden URL'], 403);
}
$ctx = stream_context_create(['http' => [
'method' => 'GET',
'header' => 'User-Agent: Mozilla/5.0' . "\r\n",
'timeout' => 15,
]]);
$raw = @file_get_contents($src, false, $ctx);
if ($raw === false || strlen($raw) < 500) {
return response()->json(['error' => 'Failed to fetch image'], 502);
}
// Resize to max 1280x720 using PHP GD (bundled — no Intervention Image needed)
$srcImg = @imagecreatefromstring($raw);
if (!$srcImg) {
return response()->json(['error' => 'Invalid image data'], 422);
}
$origW = imagesx($srcImg);
$origH = imagesy($srcImg);
$maxW = 1280;
$maxH = 720;
$ratio = min($maxW / $origW, $maxH / $origH, 1.0); // never upscale
$newW = (int) round($origW * $ratio);
$newH = (int) round($origH * $ratio);
$dstImg = imagescale($srcImg, $newW, $newH, IMG_BILINEAR_FIXED);
imagedestroy($srcImg);
ob_start();
imagejpeg($dstImg, null, 85);
$binary = ob_get_clean();
imagedestroy($dstImg);
// Save via existing pipeline — binary string branch in uploadFileContent handles this
$result = FilesMainController::uploadFileList(
$binary,
'stock-photo',
'stock_photo_' . time() . '.jpg',
'',
[],
'ProductMarket',
[],
0,
'image/jpeg'
);
if (!$result || empty($result->hashkey)) {
return response()->json(['error' => 'Save failed'], 500);
}
return response()->json([
'success' => true,
'hashkey' => $result->hashkey,
'url' => $result->resolvedUrl(),
]);
}
}