This commit is contained in:
zramsay 2025-03-11 13:11:38 -04:00
parent 5a1842a7ae
commit a88f0c0aae
10 changed files with 286 additions and 13 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
.next
.env.local
image-hashes.json

View File

@ -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<NextResponse> {
)
}
// 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<NextResponse> {
// 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,

View File

@ -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<NextResponse> {
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';

View File

@ -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)
}

View File

@ -143,7 +143,11 @@ const ImageAnalysisCard: React.FC<ImageAnalysisCardProps> = ({
</div>
{analysisState.error && (
<div className="mt-4 bg-red-900/20 border border-red-500/20 text-red-400 px-4 py-3 rounded-xl text-center">
<div className={`mt-4 px-4 py-3 rounded-xl text-center ${
analysisState.error.includes('already been uploaded')
? 'bg-amber-900/20 border border-amber-500/20 text-amber-400'
: 'bg-red-900/20 border border-red-500/20 text-red-400'
}`}>
{analysisState.error}
</div>
)}

View File

@ -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) => {

View File

@ -11,6 +11,11 @@ export interface VisionAnalysisResult {
}
export async function analyzeImageWithVision(imageBuffer: Buffer): Promise<VisionAnalysisResult> {
// 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')

View File

@ -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()

View File

@ -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<void> {
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<ImageHashData> {
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<boolean> {
const hashes = await readImageHashes();
return !!hashes[hash];
}
/**
* Store a new image hash
*/
export async function storeImageHash(hash: string, description?: string): Promise<void> {
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<boolean> {
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<any | null> {
const hashes = await readImageHashes();
return hashes[hash] || null;
}

View File

@ -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<AnimalRecord[]> {
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<AnimalReco
createTime: record.createTime || ''
}
})
// Filter out records without an imageUrl or non matching portalName
// Filter out records without an imageUrl or non-matching portalName
.filter((record: AnimalRecord) =>
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