mirror of
https://github.com/mito-systems/ranger-app.git
synced 2026-05-05 00:52:04 +00:00
ongoing
This commit is contained in:
parent
5a1842a7ae
commit
a88f0c0aae
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.env.local
|
||||
image-hashes.json
|
||||
@ -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,
|
||||
|
||||
42
src/app/api/debug/image-hashes/route.ts
Normal file
42
src/app/api/debug/image-hashes/route.ts
Normal 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';
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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()
|
||||
|
||||
151
src/services/imageHashService.ts
Normal file
151
src/services/imageHashService.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user