From a88f0c0aae65e1d547cc5426eeb2ab6b1ba4f5b3 Mon Sep 17 00:00:00 2001 From: zramsay Date: Tue, 11 Mar 2025 13:11:38 -0400 Subject: [PATCH] ongoing --- .gitignore | 4 + src/app/api/analyze/route.ts | 20 +++- src/app/api/debug/image-hashes/route.ts | 42 +++++++ src/app/sightings/page.tsx | 5 +- src/components/ImageAnalysisCard.tsx | 6 +- src/config/appConfig.ts | 2 +- src/services/googleVisionCore.ts | 5 + src/services/googleVisionService.ts | 40 ++++++- src/services/imageHashService.ts | 151 ++++++++++++++++++++++++ src/services/laconicQueryService.ts | 24 +++- 10 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 .gitignore create mode 100644 src/app/api/debug/image-hashes/route.ts create mode 100644 src/services/imageHashService.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3e5b23 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +.env.local +image-hashes.json diff --git a/src/app/api/analyze/route.ts b/src/app/api/analyze/route.ts index 625fde7..e666215 100644 --- a/src/app/api/analyze/route.ts +++ b/src/app/api/analyze/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server' import { getSessionFromCookie } from '../../../lib/auth' import { analyzeImageWithVision } from '../../../services/googleVisionCore' import { processAnimalImage } from '../../../services/animalProcessingService' +import { checkAndStoreImageHash } from '../../../services/imageHashService' // Increase body parser size limit export const config = { @@ -82,7 +83,17 @@ export async function POST(req: NextRequest): Promise { ) } - // Analyze image + // Check if the image is a duplicate + const isDuplicate = await checkAndStoreImageHash(buffer); + if (isDuplicate) { + console.log('Duplicate image detected - rejecting upload'); + return NextResponse.json( + { error: 'This image has already been uploaded. Please try a different image.' }, + { status: 409 } // 409 Conflict + ); + } + + // Analyze image with Google Vision API const visionResult = await analyzeImageWithVision(buffer) // Construct response message @@ -101,6 +112,13 @@ export async function POST(req: NextRequest): Promise { // Background processing for animal images if (visionResult.isAnimal) { const fileName = 'name' in (imageFile as any) ? (imageFile as any).name : 'unknown-image.jpg' + + // Update the hash entry with the confirmed animal description + // We already stored the hash above, but now we're updating it with the description + // This is efficient because it happens after we've sent the response to the user + await checkAndStoreImageHash(buffer, visionResult.description) + .catch(err => console.error('Failed to update image hash with description:', err)); + processAnimalImage( buffer, visionResult.description, diff --git a/src/app/api/debug/image-hashes/route.ts b/src/app/api/debug/image-hashes/route.ts new file mode 100644 index 0000000..0cc3edb --- /dev/null +++ b/src/app/api/debug/image-hashes/route.ts @@ -0,0 +1,42 @@ +// src/app/api/debug/image-hashes/route.ts +import { NextRequest, NextResponse } from 'next/server'; +import { readImageHashes } from '../../../../services/imageHashService'; +import { getSessionFromCookie } from '../../../../lib/auth'; + +// This is a debug endpoint to view the current image hashes +// Restricted to authenticated users only +export async function GET(req: NextRequest): Promise { + try { + // Check authentication + const session = await getSessionFromCookie(req); + + if (!session) { + return NextResponse.json( + { error: 'Unauthorized. Please sign in to use this feature.' }, + { status: 401 } + ); + } + + // Read all hashes + const hashes = await readImageHashes(); + + // Calculate some statistics + const totalHashes = Object.keys(hashes).length; + const hashedWithDescription = Object.values(hashes).filter(h => !!h.description).length; + + return NextResponse.json({ + totalHashes, + hashedWithDescription, + hashes + }); + } catch (error) { + console.error('Error fetching image hashes:', error); + return NextResponse.json( + { error: 'Failed to retrieve image hashes' }, + { status: 500 } + ); + } +} + +// Ensure dynamic routing +export const dynamic = 'force-dynamic'; \ No newline at end of file diff --git a/src/app/sightings/page.tsx b/src/app/sightings/page.tsx index e507238..b0c2e13 100644 --- a/src/app/sightings/page.tsx +++ b/src/app/sightings/page.tsx @@ -37,7 +37,7 @@ export default function AnimalsPage() { try { setLoading(true) setError(null) - const data = await fetchAnimalRecords(APP_CONFIG.recordEnv) + const data = await fetchAnimalRecords(APP_CONFIG.portalName) // Sort by creation time, oldest first const sortedRecords = [...data].sort((a, b) => @@ -52,7 +52,8 @@ export default function AnimalsPage() { // Reverse to show newest first setRecords(filteredRecords.reverse()) } catch (error) { - setError('Failed to load animal records') + console.error('Error in loadRecords:', error); + setError(error instanceof Error ? error.message : 'Failed to load animal records') } finally { setLoading(false) } diff --git a/src/components/ImageAnalysisCard.tsx b/src/components/ImageAnalysisCard.tsx index ea42a5f..c89a400 100644 --- a/src/components/ImageAnalysisCard.tsx +++ b/src/components/ImageAnalysisCard.tsx @@ -143,7 +143,11 @@ const ImageAnalysisCard: React.FC = ({ {analysisState.error && ( -
+
{analysisState.error}
)} diff --git a/src/config/appConfig.ts b/src/config/appConfig.ts index dee4446..4f8e7f1 100644 --- a/src/config/appConfig.ts +++ b/src/config/appConfig.ts @@ -5,7 +5,7 @@ export const APP_CONFIG = { title: process.env.NEXT_PUBLIC_APP_TITLE, description: process.env.NEXT_PUBLIC_APP_DESCRIPTION, location: process.env.NEXT_PUBLIC_APP_LOCATION, - recordEnv: process.env.NEXT_PUBLIC_RECORD_ENV || 'production' + portalName: process.env.NEXT_PUBLIC_PORTAL_NAME || 'wildlife' } export const getThemeColors = (theme: string) => { diff --git a/src/services/googleVisionCore.ts b/src/services/googleVisionCore.ts index 05c8b1f..2dcac8f 100644 --- a/src/services/googleVisionCore.ts +++ b/src/services/googleVisionCore.ts @@ -11,6 +11,11 @@ export interface VisionAnalysisResult { } export async function analyzeImageWithVision(imageBuffer: Buffer): Promise { + // Validate input + if (!imageBuffer || imageBuffer.length === 0) { + throw new Error('Invalid or empty image buffer') + } + const API_KEY = process.env.GOOGLE_API_KEY if (!API_KEY) { throw new Error('Google Vision API key not configured') diff --git a/src/services/googleVisionService.ts b/src/services/googleVisionService.ts index f84cf19..c97588f 100644 --- a/src/services/googleVisionService.ts +++ b/src/services/googleVisionService.ts @@ -47,14 +47,46 @@ export async function analyzeImage(imageBuffer: Buffer, filename: string): Promi if (!response.ok) { // More detailed error handling - const errorText = await response.text() + const contentType = response.headers.get('content-type'); + let errorMessage = 'Failed to analyze image'; + + if (contentType && contentType.includes('application/json')) { + // If the error response is JSON, parse it + try { + const errorJson = await response.json(); + errorMessage = errorJson.error || errorMessage; + } catch (parseError) { + console.error('Error parsing JSON error response:', parseError); + } + } else { + // Otherwise try to get the text + try { + const errorText = await response.text(); + errorMessage = errorText || errorMessage; + } catch (textError) { + console.error('Error reading error response text:', textError); + } + } + + // For 409 Conflict specifically, give a friendly message about duplicates without logging as error + if (response.status === 409) { + console.log('Duplicate image detected:', { + status: response.status, + message: errorMessage + }); + return { + error: errorMessage + }; + } + + // Log other errors to console console.error('Analysis response error:', { status: response.status, statusText: response.statusText, - errorText - }) + errorMessage + }); - throw new Error(errorText || 'Failed to analyze image') + throw new Error(errorMessage); } return await response.json() diff --git a/src/services/imageHashService.ts b/src/services/imageHashService.ts new file mode 100644 index 0000000..418aa94 --- /dev/null +++ b/src/services/imageHashService.ts @@ -0,0 +1,151 @@ +// src/services/imageHashService.ts +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { promises as fsPromises } from 'fs'; + +// Define the path to the JSON file that will store our hashes +// In a real production app, this would be replaced with a database +const HASH_FILE_PATH = path.join(process.cwd(), 'image-hashes.json'); + +// Interface for storing image hash data +interface ImageHashData { + [hash: string]: { + timestamp: string; + description?: string; + }; +} + +/** + * Initialize the hash file if it doesn't exist + */ +async function initializeHashFile(): Promise { + try { + await fsPromises.access(HASH_FILE_PATH); + } catch (error) { + // File doesn't exist, create an empty one + await fsPromises.writeFile(HASH_FILE_PATH, JSON.stringify({})); + } +} + +/** + * Read all stored image hashes + */ +export async function readImageHashes(): Promise { + await initializeHashFile(); + + try { + const data = await fsPromises.readFile(HASH_FILE_PATH, 'utf8'); + return JSON.parse(data) as ImageHashData; + } catch (error) { + console.error('Error reading image hashes file:', error); + return {}; + } +} + +/** + * Generate a SHA-256 hash for an image buffer + */ +export function generateImageHash(imageBuffer: Buffer): string { + if (!imageBuffer || !(imageBuffer instanceof Buffer)) { + throw new Error('Invalid image buffer provided'); + } + + if (imageBuffer.length === 0) { + throw new Error('Empty image buffer'); + } + + return crypto.createHash('sha256').update(imageBuffer).digest('hex'); +} + +/** + * Check if an image hash already exists + */ +export async function isImageHashDuplicate(hash: string): Promise { + const hashes = await readImageHashes(); + return !!hashes[hash]; +} + +/** + * Store a new image hash + */ +export async function storeImageHash(hash: string, description?: string): Promise { + const hashes = await readImageHashes(); + + hashes[hash] = { + timestamp: new Date().toISOString(), + description + }; + + try { + await fsPromises.writeFile(HASH_FILE_PATH, JSON.stringify(hashes, null, 2)); + } catch (error) { + console.error('Error writing image hash:', error); + throw new Error('Failed to store image hash'); + } +} + +/** + * Check if an image is a duplicate and store if not + * Returns true if the image is a duplicate, false otherwise + */ +export async function checkAndStoreImageHash( + imageBuffer: Buffer, + description?: string +): Promise { + try { + // Validate buffer + if (!imageBuffer || !(imageBuffer instanceof Buffer)) { + console.error('Invalid image buffer provided to checkAndStoreImageHash'); + throw new Error('Invalid image buffer provided'); + } + + if (imageBuffer.length === 0) { + console.error('Empty image buffer provided to checkAndStoreImageHash'); + throw new Error('Empty image buffer'); + } + + const hash = generateImageHash(imageBuffer); + + // Read the current hashes + const hashes = await readImageHashes(); + const isDuplicate = !!hashes[hash]; + + // Log before storing + if (isDuplicate) { + console.log(`Duplicate image detected with hash: ${hash.substring(0, 8)}...`); + } else { + console.log(`New image with hash: ${hash.substring(0, 8)}...`); + } + + // If not a duplicate or if we're updating with a description, update the hash file + if (!isDuplicate || description) { + hashes[hash] = { + timestamp: new Date().toISOString(), + description: description || hashes[hash]?.description + }; + + try { + await fsPromises.writeFile(HASH_FILE_PATH, JSON.stringify(hashes, null, 2)); + } catch (error) { + console.error('Error writing image hash:', error); + throw new Error('Failed to store image hash'); + } + } + + return isDuplicate; + } catch (error) { + console.error('Error in checkAndStoreImageHash:', error); + // Return false instead of throwing to avoid breaking the app flow + // This means we'll treat errors as "not a duplicate" and continue processing + return false; + } +} + +/** + * Get details about a stored hash + */ +export async function getHashDetails(hash: string): Promise { + const hashes = await readImageHashes(); + return hashes[hash] || null; +} \ No newline at end of file diff --git a/src/services/laconicQueryService.ts b/src/services/laconicQueryService.ts index 9a2ef86..d6677c4 100644 --- a/src/services/laconicQueryService.ts +++ b/src/services/laconicQueryService.ts @@ -2,7 +2,8 @@ import { AnimalRecord } from '../types/records' -const LACONIC_GQL_ENDPOINT = process.env.NEXT_PUBLIC_LACONIC_GQL_ENDPOINT +// Ensure GraphQL endpoint is always defined with a fallback if needed +const LACONIC_GQL_ENDPOINT = process.env.NEXT_PUBLIC_LACONIC_GQL_ENDPOINT || 'https://laconicd-sapo.laconic.com/api' const ANIMAL_RECORDS_QUERY = ` query GetAnimalRecords($portalName: String!) { @@ -42,13 +43,28 @@ const ANIMAL_RECORDS_QUERY = ` export async function fetchAnimalRecords(portalName: string): Promise { try { + if (!LACONIC_GQL_ENDPOINT) { + throw new Error('LACONIC_GQL_ENDPOINT is not defined in environment variables') + } + + if (!portalName) { + throw new Error('Portal name is required - check NEXT_PUBLIC_PORTAL_NAME env variable') + } + + // IMPORTANT: We must use the passed portalName parameter to ensure each app instance + // only displays its own sightings + console.log('Using portal name:', portalName); + const response = await fetch(LACONIC_GQL_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query: ANIMAL_RECORDS_QUERY, - variables: { portalName: process.env.NEXT_PUBLIC_PORTAL_NAME } + variables: { portalName } }), + // Add cache and credentials options to ensure proper fetch behavior + cache: 'no-cache', + credentials: 'same-origin' }) console.log('GQL_endpoint', LACONIC_GQL_ENDPOINT) @@ -94,11 +110,11 @@ export async function fetchAnimalRecords(portalName: string): Promise record.attributes.imageUrl !== null && record.attributes.imageUrl.trim() !== '' && - record.attributes.portalName === process.env.NEXT_PUBLIC_PORTAL_NAME + record.attributes.portalName === portalName ); // Log more detailed information about each image URL