From fc244783a54555a20c8b47dc92c14cd60884f141 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 26 May 2026 07:48:51 +0900 Subject: [PATCH 1/6] feat: implement HeritageImageController with proxyImage Fetches the UNESCO image URL from world_heritage_site_images via the Image model and streams the binary response back to the browser, bypassing CORS/CORP restrictions that block direct browser requests. Closes #471 Co-Authored-By: Claude Sonnet 4.6 --- .../Controller/HeritageImageController.php | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/app/Packages/Features/Controller/HeritageImageController.php diff --git a/src/app/Packages/Features/Controller/HeritageImageController.php b/src/app/Packages/Features/Controller/HeritageImageController.php new file mode 100644 index 0000000..cf93c35 --- /dev/null +++ b/src/app/Packages/Features/Controller/HeritageImageController.php @@ -0,0 +1,34 @@ +json(['error' => 'Image not found'], 404); + } + + $upstream = Http::withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ])->get($image->url); + + if ($upstream->failed()) { + return response()->json(['error' => 'Failed to fetch image from upstream'], 502); + } + + return response($upstream->body(), 200) + ->header('Content-Type', $upstream->header('Content-Type')) + ->header('Access-Control-Allow-Origin', '*'); + } +} \ No newline at end of file From 19bfcda657b6ff52d8487212ce71a8da26d7afa4 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 26 May 2026 07:54:38 +0900 Subject: [PATCH 2/6] fix: remove manual CORS header from HeritageImageController Access-Control-Allow-Origin is already managed by HandleCors middleware via cors.php. Keeping it in the controller caused duplicate headers. Co-Authored-By: Claude Sonnet 4.6 --- .../Packages/Features/Controller/HeritageImageController.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/Packages/Features/Controller/HeritageImageController.php b/src/app/Packages/Features/Controller/HeritageImageController.php index cf93c35..97fcfb1 100644 --- a/src/app/Packages/Features/Controller/HeritageImageController.php +++ b/src/app/Packages/Features/Controller/HeritageImageController.php @@ -28,7 +28,6 @@ public function proxyImage(Request $request, int $id): Response|JsonResponse } return response($upstream->body(), 200) - ->header('Content-Type', $upstream->header('Content-Type')) - ->header('Access-Control-Allow-Origin', '*'); + ->header('Content-Type', $upstream->header('Content-Type')); } } \ No newline at end of file From 457bb393bc4836bbcfae3c77220da4f6ff9bd88b Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 26 May 2026 08:02:26 +0900 Subject: [PATCH 3/6] fix: look up image by world_heritage_site_id instead of image PK WorldHeritageVm.id is the site ID, not the image record's PK. Querying by world_heritage_site_id with is_primary/sort_order ordering returns the primary image for the site, which is what the frontend passes. Co-Authored-By: Claude Sonnet 4.6 --- .../Packages/Features/Controller/HeritageImageController.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/Packages/Features/Controller/HeritageImageController.php b/src/app/Packages/Features/Controller/HeritageImageController.php index 97fcfb1..5c39b0b 100644 --- a/src/app/Packages/Features/Controller/HeritageImageController.php +++ b/src/app/Packages/Features/Controller/HeritageImageController.php @@ -13,7 +13,10 @@ class HeritageImageController extends Controller { public function proxyImage(Request $request, int $id): Response|JsonResponse { - $image = Image::find($id); + $image = Image::where('world_heritage_site_id', $id) + ->orderByDesc('is_primary') + ->orderBy('sort_order') + ->first(); if ($image === null) { return response()->json(['error' => 'Image not found'], 404); From 9b53db25001b84ca259a9e292e41fd4e3af34ee1 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 26 May 2026 08:04:28 +0900 Subject: [PATCH 4/6] feat: add proxyImageById endpoint for detail page images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/v1/heritage-image/image/{imageId} fetches a single image by its own PK, allowing the detail page to proxy each image in the images[] array independently. - /heritage-image/{siteId} → primary image (list page) - /heritage-image/image/{imageId} → individual image by PK (detail page) Co-Authored-By: Claude Sonnet 4.6 --- .../Controller/HeritageImageController.php | 20 +++++++++++++++++++ src/routes/api.php | 1 + 2 files changed, 21 insertions(+) diff --git a/src/app/Packages/Features/Controller/HeritageImageController.php b/src/app/Packages/Features/Controller/HeritageImageController.php index 5c39b0b..14a852a 100644 --- a/src/app/Packages/Features/Controller/HeritageImageController.php +++ b/src/app/Packages/Features/Controller/HeritageImageController.php @@ -33,4 +33,24 @@ public function proxyImage(Request $request, int $id): Response|JsonResponse return response($upstream->body(), 200) ->header('Content-Type', $upstream->header('Content-Type')); } + + public function proxyImageById(Request $request, int $imageId): Response|JsonResponse + { + $image = Image::find($imageId); + + if ($image === null) { + return response()->json(['error' => 'Image not found'], 404); + } + + $upstream = Http::withHeaders([ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ])->get($image->url); + + if ($upstream->failed()) { + return response()->json(['error' => 'Failed to fetch image from upstream'], 502); + } + + return response($upstream->body(), 200) + ->header('Content-Type', $upstream->header('Content-Type')); + } } \ No newline at end of file diff --git a/src/routes/api.php b/src/routes/api.php index 1d56fce..309082c 100644 --- a/src/routes/api.php +++ b/src/routes/api.php @@ -10,4 +10,5 @@ Route::get('heritages/region-count', [WorldHeritageController::class, 'getWorldHeritagesCountByRegion']); Route::get('/heritages/{id}', [WorldHeritageController::class, 'getWorldHeritageById']); Route::get('/heritage-image/{id}', [HeritageImageController::class, 'proxyImage']); + Route::get('/heritage-image/image/{imageId}', [HeritageImageController::class, 'proxyImageById']); }); \ No newline at end of file From db1305147d1a4620ab90ee76b9def2d89a082314 Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 26 May 2026 23:33:57 +0900 Subject: [PATCH 5/6] feat: fetch YouTube thumbnail via UNESCO API for proxy image endpoint Instead of proxying UNESCO /document/ URLs (blocked by Cloudflare as subresource requests), fetch main_video_url from data.unesco.org API on each request, extract the YouTube video ID, and return the YouTube hqdefault thumbnail binary. Co-Authored-By: Claude Sonnet 4.6 --- .../Controller/HeritageImageController.php | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/src/app/Packages/Features/Controller/HeritageImageController.php b/src/app/Packages/Features/Controller/HeritageImageController.php index 14a852a..84e677b 100644 --- a/src/app/Packages/Features/Controller/HeritageImageController.php +++ b/src/app/Packages/Features/Controller/HeritageImageController.php @@ -13,27 +13,55 @@ class HeritageImageController extends Controller { public function proxyImage(Request $request, int $id): Response|JsonResponse { - $image = Image::where('world_heritage_site_id', $id) - ->orderByDesc('is_primary') - ->orderBy('sort_order') - ->first(); + $videoUrl = $this->fetchVideoUrlFromUnesco($id); - if ($image === null) { - return response()->json(['error' => 'Image not found'], 404); + if ($videoUrl === null) { + return response()->json(['error' => 'No video URL found for this site'], 404); } - $upstream = Http::withHeaders([ - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', - ])->get($image->url); + $thumbnailUrl = $this->buildYoutubeThumbnailUrl($videoUrl); + + if ($thumbnailUrl === null) { + return response()->json(['error' => 'Could not extract YouTube video ID'], 422); + } + + $upstream = Http::get($thumbnailUrl); if ($upstream->failed()) { - return response()->json(['error' => 'Failed to fetch image from upstream'], 502); + return response()->json(['error' => 'Failed to fetch thumbnail from YouTube'], 502); } return response($upstream->body(), 200) ->header('Content-Type', $upstream->header('Content-Type')); } + private function fetchVideoUrlFromUnesco(int $idNo): ?string + { + $response = Http::acceptJson() + ->get('https://data.unesco.org/api/explore/v2.1/catalog/datasets/whc001/records', [ + 'where' => "id_no={$idNo}", + 'limit' => 1, + 'select' => 'main_video_url', + ]); + + if ($response->failed()) { + return null; + } + + $results = $response->json('results'); + + return $results[0]['main_video_url'] ?? null; + } + + private function buildYoutubeThumbnailUrl(string $videoUrl): ?string + { + if (preg_match('/(?:embed\/|v=|youtu\.be\/)([A-Za-z0-9_-]{11})/', $videoUrl, $m)) { + return "https://img.youtube.com/vi/{$m[1]}/hqdefault.jpg"; + } + + return null; + } + public function proxyImageById(Request $request, int $imageId): Response|JsonResponse { $image = Image::find($imageId); From 1aeaddd2937d95a17bd4110322daba57fcc4eebf Mon Sep 17 00:00:00 2001 From: Application-drop-up Date: Tue, 26 May 2026 23:37:18 +0900 Subject: [PATCH 6/6] feat: store and serve YouTube thumbnails for heritage images - Migration: add main_video_url column to world_heritage_sites - Command: world-heritage:sync-video-urls fetches main_video_url from data.unesco.org API and populates DB (1248 records synced) - Model: add main_video_url to WorldHeritage fillable - Controller: look up main_video_url from DB instead of calling UNESCO API on every request, then proxy YouTube hqdefault thumbnail Co-Authored-By: Claude Sonnet 4.6 --- .../Commands/SyncWorldHeritageVideoUrls.php | 96 +++++++++++++++++++ src/app/Models/WorldHeritage.php | 1 + .../Controller/HeritageImageController.php | 16 +--- ...main_video_url_to_world_heritage_sites.php | 22 +++++ 4 files changed, 122 insertions(+), 13 deletions(-) create mode 100644 src/app/Console/Commands/SyncWorldHeritageVideoUrls.php create mode 100644 src/database/migrations/2026_05_26_232121_add_main_video_url_to_world_heritage_sites.php diff --git a/src/app/Console/Commands/SyncWorldHeritageVideoUrls.php b/src/app/Console/Commands/SyncWorldHeritageVideoUrls.php new file mode 100644 index 0000000..bb3e202 --- /dev/null +++ b/src/app/Console/Commands/SyncWorldHeritageVideoUrls.php @@ -0,0 +1,96 @@ +option('limit')); + $dryRun = (bool) $this->option('dry-run'); + + $first = $this->fetch(1, 0); + if ($first === null) { + return self::FAILURE; + } + + $total = (int) ($first['total_count'] ?? 0); + $offset = 0; + $updated = 0; + $skipped = 0; + + $this->info("Total UNESCO records: {$total}"); + + while ($offset < $total) { + $response = $this->fetch($limit, $offset); + if ($response === null) { + return self::FAILURE; + } + + $results = $response['results'] ?? []; + if ($results === []) { + break; + } + + foreach ($results as $row) { + $idNo = (int) ($row['id_no'] ?? 0); + $videoUrl = $row['main_video_url'] ?? null; + + if ($idNo === 0) { + $skipped++; + continue; + } + + if (!$dryRun) { + DB::table('world_heritage_sites') + ->where('id', $idNo) + ->update(['main_video_url' => $videoUrl]); + } + + $updated++; + } + + $offset += count($results); + $this->line("Processed {$offset}/{$total}..."); + } + + if ($dryRun) { + $this->info("Dry-run: would update {$updated} records, skipped {$skipped}."); + } else { + $this->info("Done. Updated {$updated} records, skipped {$skipped}."); + } + + return self::SUCCESS; + } + + private function fetch(int $limit, int $offset): ?array + { + $response = Http::acceptJson() + ->retry(3, 500) + ->get(self::API_URL, [ + 'select' => 'id_no,main_video_url', + 'limit' => $limit, + 'offset' => $offset, + 'order_by' => 'id_no asc', + ]); + + if ($response->failed()) { + $this->error("UNESCO API error: HTTP {$response->status()} offset={$offset}"); + return null; + } + + return $response->json(); + } +} \ No newline at end of file diff --git a/src/app/Models/WorldHeritage.php b/src/app/Models/WorldHeritage.php index f0722e6..85121a6 100644 --- a/src/app/Models/WorldHeritage.php +++ b/src/app/Models/WorldHeritage.php @@ -36,6 +36,7 @@ class WorldHeritage extends Model 'short_description', 'unesco_site_url', 'main_image_url', + 'main_video_url', ]; protected $hidden = [ diff --git a/src/app/Packages/Features/Controller/HeritageImageController.php b/src/app/Packages/Features/Controller/HeritageImageController.php index 84e677b..06e24ab 100644 --- a/src/app/Packages/Features/Controller/HeritageImageController.php +++ b/src/app/Packages/Features/Controller/HeritageImageController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\Image; +use App\Models\WorldHeritage; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Http\JsonResponse; @@ -37,20 +38,9 @@ public function proxyImage(Request $request, int $id): Response|JsonResponse private function fetchVideoUrlFromUnesco(int $idNo): ?string { - $response = Http::acceptJson() - ->get('https://data.unesco.org/api/explore/v2.1/catalog/datasets/whc001/records', [ - 'where' => "id_no={$idNo}", - 'limit' => 1, - 'select' => 'main_video_url', - ]); - - if ($response->failed()) { - return null; - } - - $results = $response->json('results'); + $site = WorldHeritage::find($idNo); - return $results[0]['main_video_url'] ?? null; + return $site?->main_video_url; } private function buildYoutubeThumbnailUrl(string $videoUrl): ?string diff --git a/src/database/migrations/2026_05_26_232121_add_main_video_url_to_world_heritage_sites.php b/src/database/migrations/2026_05_26_232121_add_main_video_url_to_world_heritage_sites.php new file mode 100644 index 0000000..54cdd75 --- /dev/null +++ b/src/database/migrations/2026_05_26_232121_add_main_video_url_to_world_heritage_sites.php @@ -0,0 +1,22 @@ +string('main_video_url')->nullable()->after('main_image_url'); + }); + } + + public function down(): void + { + Schema::table('world_heritage_sites', function (Blueprint $table) { + $table->dropColumn('main_video_url'); + }); + } +}; \ No newline at end of file