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

20 KiB
Raw Permalink Blame History

task, cycles, context, private, started, finished
task cycles context private started finished
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. 5 true false 2026-05-28T17:30:13Z 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:

    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
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(),
        ]);
    }
}
  1. routes/web.php — near line 515 (existing /File/Upload/{category} route), add:
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

  1. 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
<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>
  1. 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:
      <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):
      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.
  2. 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