--- task: Add DuckDuckGo image search stock photo picker to product creation forms (CreateProductUltimate + CreateProductStoreOwner). Button near dropzone opens modal that auto-searches DDG from product name. Grid uses IntersectionObserver infinite scroll. Selecting a photo downloads the source image + resizes to max 1280x720 server-side (PHP GD), saves via existing FilesMainController pipeline, returns hashkey identical to normal upload. No API key required — DDG is scraped via a 2-step token+JSON approach. cycles: 5 context: true private: false started: 2026-05-28T17:30:13Z finished: 2026-05-28T17:31:50Z --- ## files - `app/Enums/UserActions.php` [last line ~ManageQrphPaymentCode] — add two new actions - `app/Http/Controllers/Helpers/Permissions/UserPermissions.php` — add new actions to role grants - `app/Http/Controllers/Market/ProductPhotoSearchController.php` — NEW controller (search + download) - `app/Http/Controllers/FilesMainController.php` [lines 118-189, 222-228, 328-372] — reuse `uploadFileList()` static; accepts binary string via `isLikelyBinary` branch - `routes/web.php` [line 515 area, near `/File/Upload/{category}`] — add 2 new routes - `resources/js/Components/Core/StockPhotoPicker.vue` — NEW reusable modal component - `resources/js/Pages/CreateProductUltimate.vue` [productName ref line 26] — import picker, add button near dropzone, handle event - `resources/js/Pages/CreateProductStoreOwner.vue` [newProduct.name ref ~line 57+] — import picker, add button near dropzone, handle event ## steps ### Backend 1. **`app/Enums/UserActions.php`** — before the closing `}`, add: ```php case SearchStockPhotos = 'searchstockphotos'; case DownloadStockPhoto = 'downloadstockphoto'; ``` 2. **`app/Http/Controllers/Helpers/UserPermissions.php`** — in `roles()`, grant both new actions to `storeowner`, `cooperative`, and `ultimate` roles (same pattern as existing product actions). Do NOT grant to `customer` or guest. 3. **Create `app/Http/Controllers/Market/ProductPhotoSearchController.php`**: ```php [ 'method' => 'GET', 'header' => implode("\r\n", self::DDG_BROWSER_HEADERS), 'timeout' => 10, ]]); $html = @file_get_contents($url, false, $ctx); if (!$html) return null; // vqd token appears as vqd=4-xxxxxxxxxx or vqd=4-xx-xx in the page JS if (preg_match('/vqd=([0-9a-zA-Z\-]+)/', $html, $m)) { return $m[1]; } return null; } // GET /api/products/photo-search?q=...&page=1 public function search(Request $request) { if (!UserPermissions::can(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::can(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(), ]); } } ``` 4. **`routes/web.php`** — near line 515 (existing `/File/Upload/{category}` route), add: ```php Route::get('/api/products/photo-search', [\App\Http\Controllers\Market\ProductPhotoSearchController::class, 'search'], ['middleware' => 'auth']); Route::post('/api/products/photo-download', [\App\Http\Controllers\Market\ProductPhotoSearchController::class, 'download'], ['middleware' => 'auth']); ``` ### Frontend 5. **Create `resources/js/Components/Core/StockPhotoPicker.vue`**: Props: `modelValue: Boolean` (v-model for show/hide), `productName: String` Emits: `update:modelValue`, `photo-selected({ hashkey, url })` Behavior: - On `modelValue` becoming true: set `query` to `productName`, call `resetAndSearch()` - On `modelValue` becoming false: clear photos, reset page - Search input pre-filled with `productName`, debounce 400ms on manual edits - Photo grid: 3-column responsive grid of cards (thumbnail, title truncated to 1 line) - IntersectionObserver on a sentinel `
` after the last card — when visible and `hasMore && !loadingMore`, call `loadMore()` - Selecting a photo: show spinner overlay on that card, POST to `/api/products/photo-download` with `{ src: photo.src }`, on success emit `photo-selected` and close modal - Error state: dismissible inline alert inside modal body - Initial loading: 9 skeleton cards (3×3) while `loading && photos.length === 0` - Style: CSS variables only — `var(--bg-card)`, `var(--bg-primary)`, `var(--text-primary)`, `var(--accent-color)`; no `bg-white`, `text-dark` ```vue ``` 6. **`resources/js/Pages/CreateProductUltimate.vue`**: - Import `StockPhotoPicker` at the top with other component imports - Add `const showPhotoPicker = ref(false)` in the script - In the template, just above or below the `` component, add: ```html ``` - Add handler in script (after the existing `uploadFile` handlers): ```js function onStockPhotoSelected({ hashkey, url }) { // Mirror the shape used in the dropzoneFiles watch (~lines 108-130) // Check the Dropzone component's expected file object shape and match it exactly. // At minimum push to both dropzoneFiles (for UI) and photoHashes (for form submit). dropzoneFiles.value.push({ file: null, hashkey, url, uploading: false, progress: 100, error: null }) photoHashes.value.push(hashkey) } ``` - IMPORTANT: inspect the existing `dropzoneFiles` watch block at ~lines 108-130 to confirm the exact object shape expected. The above is a best-guess — adjust field names if needed. 7. **`resources/js/Pages/CreateProductStoreOwner.vue`**: - Same as step 6 but: - Product name comes from `newProduct.value.name` — pass as `:product-name="newProduct.name"` - Confirm the name of the dropzone ref and dropzoneFiles ref in this file (likely same as Ultimate but verify) - Same `onStockPhotoSelected` handler ## context ``` // DuckDuckGo 2-step search flow: // 1. GET https://duckduckgo.com/?q={query}&iax=images&ia=images → extract vqd token from HTML // 2. GET https://duckduckgo.com/i.js?q={query}&vqd={vqd}&o=json&p=1&f=,,,&l=us-en&s={offset} // → JSON: { results: [{ image, thumbnail, title, url }], next: "..." } // thumbnail = DDG-proxied small preview (displayed in grid, safe) // image = actual source URL on original domain (used for download + resize) // offset = (page-1)*15 for pagination; has_more = results.length >= 15 // SSRF guard in download(): // - scheme must be http or https // - host must NOT match: localhost, 127.x, 10.x, 192.168.x, 172.16-31.x, 0.0.0.0, ::1 // useFileUpload.js:54 — upload endpoint axios.post(`/File/Upload/${category}`, formData) → { success: true, hashkey: "...", url: "..." } // FilesMainController.php:135-140 — binary string branch (used by download route) } elseif (self::isLikelyBinary($fileData)) { $fileHash = hash('sha256', $fileData); $fileSize = strlen($fileData); $fileContent = $fileData; $path = Storage::put("files/{$fileHash}", $fileContent); } // FilesMainController.php:222-228 — uploadFileList signature public static function uploadFileList( string|UploadedFile $fileData, string $title, string $filename, ?string $description = null, ?array $details = [], $categories = null, $tags = [], $hidden = 0, ?string $file_type = null ): FileList // FilesMainController.php:364 — return shape return response()->json(['success' => true, 'hashkey' => $result->hashkey, 'url' => $file_url, ...]) // UserActions.php last entries (before closing brace): case ManageQrphPaymentCode = 'manageqrphpaymentcode'; } // CreateProductUltimate.vue:20-23 const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading } = useFileUpload({ category: 'ProductMarket', maxSizeMB: 10 }) // productName ref: line 26 — const productName = ref('') // dropzoneFiles watch: lines ~108-130 // CreateProductStoreOwner.vue:21-24 const { uploadFile, removeHash, photoHashes, isUploading: isFileUploading, uploadError } = useFileUpload({ category: 'ProductMarket', maxSizeMB: 10, }) // newProduct ref: line ~57+ — newProduct.value.name // routes/web.php:515 Route::post('/File/Upload/{category}', [FilesMainController::class, 'UploadFilefromRequest'], ['middleware' => 'auth']); ``` ## notes - dictionary: ai-docs/dictionary.md - linters: none detected (eslint:no, phpcs:no, tsc:no) - constraints: - **No API key required** — DuckDuckGo is scraped with browser User-Agent headers; no .env changes needed - Project uses Hypervel (Hyperf + Swoole) — use `Hypervel\*` namespaces, NOT `Illuminate\*` - No Intervention Image installed — use PHP GD (`imagecreatefromstring`, `imagescale`, `imagejpeg`) - No Guzzle — use `file_get_contents` with `stream_context_create` - SSRF guard is on the `download` route (not search) — validate scheme is http/https and host is not a private/loopback address; DO NOT restrict to a specific domain since DDG results link to arbitrary image hosts - No new VueRouteMap entries needed — StockPhotoPicker is a component, not a page - Theme: CSS variables only — no hardcoded `bg-white`, `bg-light`, `text-dark` - The `onStockPhotoSelected` handler must push to BOTH `dropzoneFiles` (UI) AND `photoHashes` (form submit) — verify exact dropzoneFiles entry shape from the Dropzone component before writing - DDG's vqd token format may change; if `getDdgVqd()` returns null, the search returns 502 gracefully — no crash