initial: bootstrap from BukidBountyApp base
This commit is contained in:
190
app/Http/Controllers/Market/ProductPhotoSearchController.php
Normal file
190
app/Http/Controllers/Market/ProductPhotoSearchController.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user