274 lines
7.1 KiB
TypeScript
274 lines
7.1 KiB
TypeScript
import type { RequestHandler } from './$types'
|
|
import { prisma } from '$lib/server/database'
|
|
import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
|
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
|
import { logger } from '$lib/server/logger'
|
|
import exifr from 'exifr'
|
|
|
|
// Extract EXIF data from image file
|
|
async function extractExifData(file: File) {
|
|
try {
|
|
logger.info(`Starting EXIF extraction for ${file.name}`, {
|
|
size: file.size,
|
|
type: file.type
|
|
})
|
|
|
|
const buffer = await file.arrayBuffer()
|
|
logger.info(`Buffer created for ${file.name}`, { bufferSize: buffer.byteLength })
|
|
|
|
// Try parsing without pick first to see all available data
|
|
const fullExif = await exifr.parse(buffer)
|
|
logger.info(`Full EXIF data available for ${file.name}:`, {
|
|
hasData: !!fullExif,
|
|
availableFields: fullExif ? Object.keys(fullExif).slice(0, 10) : [] // First 10 fields
|
|
})
|
|
|
|
// Now parse with specific fields
|
|
const exif = await exifr.parse(buffer, {
|
|
pick: [
|
|
'Make',
|
|
'Model',
|
|
'LensModel',
|
|
'FocalLength',
|
|
'FNumber',
|
|
'ExposureTime',
|
|
'ISO',
|
|
'ISOSpeedRatings', // Alternative ISO field
|
|
'DateTimeOriginal',
|
|
'DateTime', // Alternative date field
|
|
'GPSLatitude',
|
|
'GPSLongitude',
|
|
'Orientation',
|
|
'ColorSpace'
|
|
]
|
|
})
|
|
|
|
logger.info(`EXIF parse result for ${file.name}:`, {
|
|
hasExif: !!exif,
|
|
exifKeys: exif ? Object.keys(exif) : []
|
|
})
|
|
|
|
if (!exif) return null
|
|
|
|
// Format EXIF data
|
|
const formattedExif: any = {}
|
|
|
|
// Camera info
|
|
if (exif.Make && exif.Model) {
|
|
formattedExif.camera = `${exif.Make} ${exif.Model}`.replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
// Lens info
|
|
if (exif.LensModel) {
|
|
formattedExif.lens = exif.LensModel
|
|
}
|
|
|
|
// Settings
|
|
if (exif.FocalLength) {
|
|
formattedExif.focalLength = `${exif.FocalLength}mm`
|
|
}
|
|
|
|
if (exif.FNumber) {
|
|
formattedExif.aperture = `f/${exif.FNumber}`
|
|
}
|
|
|
|
if (exif.ExposureTime) {
|
|
formattedExif.shutterSpeed =
|
|
exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}` : `${exif.ExposureTime}s`
|
|
}
|
|
|
|
if (exif.ISO) {
|
|
formattedExif.iso = `ISO ${exif.ISO}`
|
|
} else if (exif.ISOSpeedRatings) {
|
|
// Handle alternative ISO field
|
|
const iso = Array.isArray(exif.ISOSpeedRatings)
|
|
? exif.ISOSpeedRatings[0]
|
|
: exif.ISOSpeedRatings
|
|
formattedExif.iso = `ISO ${iso}`
|
|
}
|
|
|
|
// Date taken
|
|
if (exif.DateTimeOriginal) {
|
|
formattedExif.dateTaken = exif.DateTimeOriginal
|
|
} else if (exif.DateTime) {
|
|
// Fallback to DateTime if DateTimeOriginal not available
|
|
formattedExif.dateTaken = exif.DateTime
|
|
}
|
|
|
|
// GPS coordinates
|
|
if (exif.GPSLatitude && exif.GPSLongitude) {
|
|
formattedExif.coordinates = {
|
|
latitude: exif.GPSLatitude,
|
|
longitude: exif.GPSLongitude
|
|
}
|
|
}
|
|
|
|
// Additional metadata
|
|
if (exif.Orientation) {
|
|
formattedExif.orientation =
|
|
exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})`
|
|
}
|
|
|
|
if (exif.ColorSpace) {
|
|
formattedExif.colorSpace = exif.ColorSpace
|
|
}
|
|
|
|
const result = Object.keys(formattedExif).length > 0 ? formattedExif : null
|
|
logger.info(`Final EXIF result for ${file.name}:`, {
|
|
hasData: !!result,
|
|
fields: result ? Object.keys(result) : []
|
|
})
|
|
return result
|
|
} catch (error) {
|
|
logger.warn('Failed to extract EXIF data', {
|
|
filename: file.name,
|
|
error: error instanceof Error ? error.message : 'Unknown error'
|
|
})
|
|
return null
|
|
}
|
|
}
|
|
|
|
export const POST: RequestHandler = async (event) => {
|
|
// Check authentication
|
|
if (!checkAdminAuth(event)) {
|
|
return errorResponse('Unauthorized', 401)
|
|
}
|
|
|
|
// Check if Cloudinary is configured
|
|
if (!isCloudinaryConfigured()) {
|
|
return errorResponse('Media upload service not configured', 503)
|
|
}
|
|
|
|
try {
|
|
const formData = await event.request.formData()
|
|
const files = formData.getAll('files') as File[]
|
|
const context = (formData.get('context') as string) || 'media'
|
|
|
|
if (!files || files.length === 0) {
|
|
return errorResponse('No files provided', 400)
|
|
}
|
|
|
|
// Validate all files
|
|
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
|
const maxSize = 10 * 1024 * 1024 // 10MB per file
|
|
const maxFiles = 50 // Maximum 50 files at once
|
|
|
|
if (files.length > maxFiles) {
|
|
return errorResponse(`Too many files. Maximum ${maxFiles} files allowed`, 400)
|
|
}
|
|
|
|
for (const file of files) {
|
|
if (!(file instanceof File)) {
|
|
return errorResponse('Invalid file format', 400)
|
|
}
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
return errorResponse(
|
|
`Invalid file type for ${file.name}. Allowed types: JPEG, PNG, WebP`,
|
|
400
|
|
)
|
|
}
|
|
|
|
if (file.size > maxSize) {
|
|
return errorResponse(`File ${file.name} is too large. Maximum size is 10MB`, 400)
|
|
}
|
|
}
|
|
|
|
logger.info(`Starting bulk upload of ${files.length} files`)
|
|
|
|
// Extract EXIF data before uploading (files might not be readable after upload)
|
|
const exifDataMap = new Map()
|
|
for (const file of files) {
|
|
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
|
logger.info(`Pre-extracting EXIF data for ${file.name}`)
|
|
const exifData = await extractExifData(file)
|
|
if (exifData) {
|
|
exifDataMap.set(file.name, exifData)
|
|
logger.info(`EXIF data found for ${file.name}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Upload all files to Cloudinary
|
|
const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects')
|
|
|
|
// Process results and save to database
|
|
const mediaRecords = []
|
|
const errors = []
|
|
|
|
for (let i = 0; i < uploadResults.length; i++) {
|
|
const result = uploadResults[i]
|
|
const file = files[i]
|
|
|
|
if (result.success) {
|
|
try {
|
|
// Get pre-extracted EXIF data
|
|
const exifData = exifDataMap.get(file.name) || null
|
|
|
|
const media = await prisma.media.create({
|
|
data: {
|
|
filename: file.name,
|
|
originalName: file.name,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
url: result.secureUrl!,
|
|
thumbnailUrl: result.thumbnailUrl,
|
|
width: result.width,
|
|
height: result.height,
|
|
exifData: exifData,
|
|
usedIn: []
|
|
}
|
|
})
|
|
|
|
mediaRecords.push({
|
|
id: media.id,
|
|
url: media.url,
|
|
thumbnailUrl: media.thumbnailUrl,
|
|
width: media.width,
|
|
height: media.height,
|
|
filename: media.filename,
|
|
exifData: media.exifData
|
|
})
|
|
} catch (dbError) {
|
|
errors.push({
|
|
filename: file.name,
|
|
error: 'Failed to save to database'
|
|
})
|
|
}
|
|
} else {
|
|
errors.push({
|
|
filename: file.name,
|
|
error: result.error || 'Upload failed'
|
|
})
|
|
}
|
|
}
|
|
|
|
logger.info(`Bulk upload completed: ${mediaRecords.length} successful, ${errors.length} failed`)
|
|
|
|
return jsonResponse(
|
|
{
|
|
success: mediaRecords.length,
|
|
failed: errors.length,
|
|
total: files.length,
|
|
media: mediaRecords,
|
|
errors: errors.length > 0 ? errors : undefined
|
|
},
|
|
201
|
|
)
|
|
} catch (error) {
|
|
logger.error('Bulk upload error', error as Error)
|
|
return errorResponse('Failed to upload files', 500)
|
|
}
|
|
}
|
|
|
|
// Handle preflight requests
|
|
export const OPTIONS: RequestHandler = async () => {
|
|
return new Response(null, {
|
|
status: 204,
|
|
headers: {
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
|
}
|
|
})
|
|
}
|