213 lines
5.4 KiB
TypeScript
213 lines
5.4 KiB
TypeScript
import type { RequestHandler } from './$types'
|
|
import { prisma } from '$lib/server/database'
|
|
import { uploadFile, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
|
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
|
import { logger } from '$lib/server/logger'
|
|
import { dev } from '$app/environment'
|
|
import exifr from 'exifr'
|
|
|
|
// Helper function to extract and format EXIF data
|
|
async function extractExifData(file: File): Promise<any> {
|
|
try {
|
|
const buffer = await file.arrayBuffer()
|
|
const exif = await exifr.parse(buffer, {
|
|
pick: [
|
|
'Make',
|
|
'Model',
|
|
'LensModel',
|
|
'FocalLength',
|
|
'FNumber',
|
|
'ExposureTime',
|
|
'ISO',
|
|
'DateTime',
|
|
'DateTimeOriginal',
|
|
'CreateDate',
|
|
'GPSLatitude',
|
|
'GPSLongitude',
|
|
'GPSAltitude',
|
|
'Orientation',
|
|
'ColorSpace'
|
|
]
|
|
})
|
|
|
|
if (!exif) return null
|
|
|
|
// Format the data into a more usable structure
|
|
const formattedExif: any = {}
|
|
|
|
if (exif.Make && exif.Model) {
|
|
formattedExif.camera = `${exif.Make} ${exif.Model}`.trim()
|
|
}
|
|
|
|
if (exif.LensModel) {
|
|
formattedExif.lens = exif.LensModel
|
|
}
|
|
|
|
if (exif.FocalLength) {
|
|
formattedExif.focalLength = `${exif.FocalLength}mm`
|
|
}
|
|
|
|
if (exif.FNumber) {
|
|
formattedExif.aperture = `f/${exif.FNumber}`
|
|
}
|
|
|
|
if (exif.ExposureTime) {
|
|
if (exif.ExposureTime < 1) {
|
|
formattedExif.shutterSpeed = `1/${Math.round(1 / exif.ExposureTime)}`
|
|
} else {
|
|
formattedExif.shutterSpeed = `${exif.ExposureTime}s`
|
|
}
|
|
}
|
|
|
|
if (exif.ISO) {
|
|
formattedExif.iso = `ISO ${exif.ISO}`
|
|
}
|
|
|
|
// Use the most reliable date field available
|
|
const dateField = exif.DateTimeOriginal || exif.CreateDate || exif.DateTime
|
|
if (dateField) {
|
|
formattedExif.dateTaken = dateField.toISOString()
|
|
}
|
|
|
|
// GPS coordinates
|
|
if (exif.GPSLatitude && exif.GPSLongitude) {
|
|
formattedExif.coordinates = {
|
|
latitude: exif.GPSLatitude,
|
|
longitude: exif.GPSLongitude,
|
|
altitude: exif.GPSAltitude || null
|
|
}
|
|
}
|
|
|
|
// Additional metadata
|
|
if (exif.Orientation) {
|
|
formattedExif.orientation = exif.Orientation
|
|
}
|
|
|
|
if (exif.ColorSpace) {
|
|
formattedExif.colorSpace = exif.ColorSpace
|
|
}
|
|
|
|
return Object.keys(formattedExif).length > 0 ? formattedExif : null
|
|
} catch (error) {
|
|
logger.warn('Failed to extract EXIF data', {
|
|
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 (skip in dev mode)
|
|
if (!dev && !isCloudinaryConfigured()) {
|
|
return errorResponse('Media upload service not configured', 503)
|
|
}
|
|
|
|
try {
|
|
const formData = await event.request.formData()
|
|
const file = formData.get('file') as File
|
|
const context = (formData.get('context') as string) || 'media'
|
|
const description = (formData.get('description') as string) || null
|
|
const isPhotography = formData.get('isPhotography') === 'true'
|
|
|
|
if (!file || !(file instanceof File)) {
|
|
return errorResponse('No file provided', 400)
|
|
}
|
|
|
|
// Validate file type
|
|
const allowedTypes = [
|
|
'image/jpeg',
|
|
'image/jpg',
|
|
'image/png',
|
|
'image/webp',
|
|
'image/gif',
|
|
'image/svg+xml'
|
|
]
|
|
if (!allowedTypes.includes(file.type)) {
|
|
return errorResponse('Invalid file type. Allowed types: JPEG, PNG, WebP, GIF, SVG', 400)
|
|
}
|
|
|
|
// Validate file size (max 10MB)
|
|
const maxSize = 10 * 1024 * 1024 // 10MB
|
|
if (file.size > maxSize) {
|
|
return errorResponse('File too large. Maximum size is 10MB', 400)
|
|
}
|
|
|
|
// Extract EXIF data for image files (but don't block upload if it fails)
|
|
let exifData = null
|
|
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
|
exifData = await extractExifData(file)
|
|
}
|
|
|
|
// Upload to Cloudinary
|
|
const uploadResult = await uploadFile(file, context as 'media' | 'photos' | 'projects')
|
|
|
|
if (!uploadResult.success) {
|
|
return errorResponse(uploadResult.error || 'Upload failed', 500)
|
|
}
|
|
|
|
// Save to database
|
|
const media = await prisma.media.create({
|
|
data: {
|
|
filename: file.name,
|
|
originalName: file.name,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
url: uploadResult.secureUrl!,
|
|
thumbnailUrl: uploadResult.thumbnailUrl,
|
|
width: uploadResult.width,
|
|
height: uploadResult.height,
|
|
exifData: exifData,
|
|
description: description?.trim() || null,
|
|
isPhotography: isPhotography
|
|
}
|
|
})
|
|
|
|
logger.info('Media uploaded successfully', {
|
|
id: media.id,
|
|
filename: media.filename,
|
|
size: media.size
|
|
})
|
|
|
|
return jsonResponse(
|
|
{
|
|
id: media.id,
|
|
url: media.url,
|
|
thumbnailUrl: media.thumbnailUrl,
|
|
width: media.width,
|
|
height: media.height,
|
|
filename: media.filename,
|
|
originalName: media.originalName,
|
|
mimeType: media.mimeType,
|
|
size: media.size,
|
|
description: media.description,
|
|
createdAt: media.createdAt,
|
|
updatedAt: media.updatedAt
|
|
},
|
|
201
|
|
)
|
|
} catch (error) {
|
|
logger.error('Media upload error', error as Error)
|
|
console.error('Detailed upload error:', error)
|
|
return errorResponse(
|
|
`Upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
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'
|
|
}
|
|
})
|
|
}
|