481 lines
20 KiB
Markdown
481 lines
20 KiB
Markdown
---
|
||
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
|
||
<?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;
|
||
|
||
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;
|
||
// 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 `<div ref="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
|
||
<template>
|
||
<Teleport to="body">
|
||
<div v-if="modelValue" class="modal d-block" tabindex="-1" style="z-index:1055">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||
<div class="modal-content" style="background:var(--bg-card);color:var(--text-primary)">
|
||
<div class="modal-header border-0 pb-0">
|
||
<div class="d-flex align-items-center gap-2 w-100 me-2">
|
||
<i class="fas fa-images" style="color:var(--accent-color)"></i>
|
||
<input v-model="query" type="text" class="form-control rounded-pill form-control-sm"
|
||
placeholder="Search photos…" style="background:var(--bg-primary);color:var(--text-primary);border-color:rgba(128,128,128,.3)" />
|
||
</div>
|
||
<button type="button" class="btn-close" @click="$emit('update:modelValue', false)" />
|
||
</div>
|
||
<div class="modal-body pt-2">
|
||
<div v-if="error" class="alert alert-danger alert-dismissible py-2 mb-2">
|
||
{{ error }} <button type="button" class="btn-close" @click="error=null"/>
|
||
</div>
|
||
<!-- skeleton -->
|
||
<div v-if="loading && !photos.length" class="row g-2">
|
||
<div v-for="n in 9" :key="n" class="col-4">
|
||
<div class="ratio ratio-4x3 rounded" style="background:rgba(128,128,128,.15);animation:pulse 1.5s infinite" />
|
||
</div>
|
||
</div>
|
||
<!-- grid -->
|
||
<div v-else class="row g-2">
|
||
<div v-for="photo in photos" :key="photo.id" class="col-4" style="cursor:pointer" @click="selectPhoto(photo)">
|
||
<div class="ratio ratio-4x3 position-relative rounded overflow-hidden">
|
||
<img :src="photo.thumb" class="w-100 h-100 object-fit-cover" :alt="photo.title" loading="lazy" />
|
||
<div v-if="selecting===photo.id" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center" style="background:rgba(0,0,0,.45)">
|
||
<div class="spinner-border text-light spinner-border-sm" />
|
||
</div>
|
||
</div>
|
||
<small class="text-muted d-block text-truncate mt-1 px-1" style="font-size:.7rem">{{ photo.title }}</small>
|
||
</div>
|
||
</div>
|
||
<!-- sentinel for infinite scroll -->
|
||
<div ref="sentinel" class="py-2 text-center">
|
||
<div v-if="loadingMore" class="spinner-border spinner-border-sm text-muted" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-backdrop show" style="z-index:-1" @click="$emit('update:modelValue', false)" />
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, watch, nextTick } from 'vue'
|
||
import axios from 'axios'
|
||
|
||
const props = defineProps({ modelValue: Boolean, productName: { type: String, default: '' } })
|
||
const emit = defineEmits(['update:modelValue', 'photo-selected'])
|
||
|
||
const photos = ref([])
|
||
const query = ref('')
|
||
const page = ref(1)
|
||
const hasMore = ref(false)
|
||
const loading = ref(false)
|
||
const loadingMore = ref(false)
|
||
const selecting = ref(null)
|
||
const error = ref(null)
|
||
const sentinel = ref(null)
|
||
let observer = null
|
||
let debounceTimer = null
|
||
|
||
watch(() => props.modelValue, async (val) => {
|
||
if (val) { query.value = props.productName; await resetAndSearch() }
|
||
else { photos.value = []; page.value = 1; observer?.disconnect() }
|
||
})
|
||
|
||
watch(query, () => {
|
||
clearTimeout(debounceTimer)
|
||
debounceTimer = setTimeout(() => resetAndSearch(), 400)
|
||
})
|
||
|
||
async function resetAndSearch() {
|
||
observer?.disconnect()
|
||
photos.value = []; page.value = 1; loading.value = true; error.value = null
|
||
await fetchPage(1)
|
||
loading.value = false
|
||
await nextTick()
|
||
setupObserver()
|
||
}
|
||
|
||
async function fetchPage(p) {
|
||
try {
|
||
const res = await axios.get('/api/products/photo-search', { params: { q: query.value, page: p } })
|
||
if (res.data.success) {
|
||
photos.value.push(...res.data.photos)
|
||
hasMore.value = res.data.has_more
|
||
page.value = p
|
||
}
|
||
} catch {
|
||
error.value = 'Could not load photos. Try again.'
|
||
}
|
||
}
|
||
|
||
async function loadMore() {
|
||
if (!hasMore.value || loadingMore.value) return
|
||
loadingMore.value = true
|
||
await fetchPage(page.value + 1)
|
||
loadingMore.value = false
|
||
await nextTick()
|
||
setupObserver()
|
||
}
|
||
|
||
function setupObserver() {
|
||
observer?.disconnect()
|
||
if (!sentinel.value) return
|
||
observer = new IntersectionObserver(([e]) => { if (e.isIntersecting) loadMore() }, { threshold: 0.1 })
|
||
observer.observe(sentinel.value)
|
||
}
|
||
|
||
async function selectPhoto(photo) {
|
||
if (selecting.value) return
|
||
selecting.value = photo.id
|
||
error.value = null
|
||
try {
|
||
const res = await axios.post('/api/products/photo-download', { src: photo.src })
|
||
if (res.data.success) {
|
||
emit('photo-selected', { hashkey: res.data.hashkey, url: res.data.url })
|
||
emit('update:modelValue', false)
|
||
} else {
|
||
error.value = 'Failed to save photo. Try another.'
|
||
}
|
||
} catch {
|
||
error.value = 'Failed to save photo. Try another.'
|
||
} finally {
|
||
selecting.value = null
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
@keyframes pulse { 0%,100%{opacity:.6} 50%{opacity:.3} }
|
||
</style>
|
||
```
|
||
|
||
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 `<Dropzone>` component, add:
|
||
```html
|
||
<button type="button" class="btn btn-outline-secondary btn-sm rounded-pill mb-2"
|
||
@click="showPhotoPicker = true">
|
||
<i class="fas fa-images me-1"></i> Search Stock Photos
|
||
</button>
|
||
<StockPhotoPicker v-model="showPhotoPicker" :product-name="productName"
|
||
@photo-selected="onStockPhotoSelected" />
|
||
```
|
||
- 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
|