Files
BarangaySystem/.claude/plans/8870909b404440261cd2c2b72ee8e463-complete.md
2026-06-06 18:43:00 +08:00

481 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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