diff --git a/next.config.mjs b/next.config.mjs index bf2e3d0..acb52d6 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,6 +2,11 @@ const nextConfig = { output: 'standalone', productionBrowserSourceMaps: false, + experimental: { + serverActions: { + bodySizeLimit: '10mb', + }, + }, }; export default nextConfig; diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts new file mode 100644 index 0000000..2962a3f --- /dev/null +++ b/src/app/api/upload/route.ts @@ -0,0 +1,69 @@ +import sharp from 'sharp'; +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; + +export async function POST(req: Request) { + try { + const supabase = createRouteHandlerClient({ cookies }); + + // Check session + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: 'Nicht autorisiert' }, { status: 401 }); + } + + const userId = session.user.id; + const formData = await req.formData(); + const file = formData.get('file') as File; + + if (!file) { + return NextResponse.json({ error: 'Keine Datei empfangen' }, { status: 400 }); + } + + // 1. Buffer aus dem Upload erstellen + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // 2. Mit Sharp optimieren + const optimizedBuffer = await sharp(buffer) + .resize(1200, 1200, { + fit: 'inside', + withoutEnlargement: true + }) + .webp({ quality: 80 }) + .toBuffer(); + + // 3. Optimiertes Bild an Supabase Storage senden + // Wir nutzen den userId/uuid.webp Pfad wie im Schema gefordert + const fileName = `${userId}/${uuidv4()}.webp`; + + const { data, error } = await supabase.storage + .from('bottles') + .upload(fileName, optimizedBuffer, { + contentType: 'image/webp', + upsert: true + }); + + if (error) { + console.error('Upload Error:', error); + throw error; + } + + // 4. Public URL generieren + const { data: { publicUrl } } = supabase.storage + .from('bottles') + .getPublicUrl(fileName); + + return NextResponse.json({ + path: data.path, + url: publicUrl + }); + } catch (error) { + console.error('Optimization error:', error); + return NextResponse.json({ + error: error instanceof Error ? error.message : 'Fehler bei der Bildverarbeitung' + }, { status: 500 }); + } +} diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 495e133..e0918a1 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -62,6 +62,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS const [lastSavedId, setLastSavedId] = useState(null); const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null); const [isDiscovering, setIsDiscovering] = useState(false); + const [originalFile, setOriginalFile] = useState(null); const handleCapture = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -88,13 +89,14 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS quality: 0.8 }); - // heic2any can return an array if the file contains multiple images const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob; fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), { type: 'image/jpeg' }); } + setOriginalFile(fileToProcess); + const compressedBase64 = await compressImage(fileToProcess); setPreviewUrl(compressedBase64); @@ -151,7 +153,21 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS throw new Error(t('camera.authRequired')); } - const response = await saveBottle(analysisResult, previewUrl, user.id); + let imageUrl = undefined; + if (originalFile) { + const formData = new FormData(); + formData.append('file', originalFile); + const uploadRes = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + const uploadData = await uploadRes.json(); + if (uploadData.url) { + imageUrl = uploadData.url; + } + } + + const response = await saveBottle(analysisResult, previewUrl, user.id, imageUrl); if (response.success && response.data) { const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`; @@ -174,13 +190,26 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS setError(null); try { - // Get current user (simple check for now, can be improved with Auth) const { data: { user } } = await supabase.auth.getUser(); if (!user) { throw new Error(t('camera.authRequired')); } - const response = await saveBottle(analysisResult, previewUrl, user.id); + let imageUrl = undefined; + if (originalFile) { + const formData = new FormData(); + formData.append('file', originalFile); + const uploadRes = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + const uploadData = await uploadRes.json(); + if (uploadData.url) { + imageUrl = uploadData.url; + } + } + + const response = await saveBottle(analysisResult, previewUrl, user.id, imageUrl); if (response.success && response.data) { setLastSavedId(response.data.id); @@ -232,7 +261,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS img.src = event.target?.result as string; img.onload = () => { const canvas = document.createElement('canvas'); - const MAX_WIDTH = 1024; + const MAX_WIDTH = 1200; let width = img.width; let height = img.height; @@ -251,7 +280,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS } ctx.drawImage(img, 0, 0, width, height); - const base64 = canvas.toDataURL('image/jpeg', 0.8); + const base64 = canvas.toDataURL('image/jpeg', 0.9); resolve(base64); }; img.onerror = reject; diff --git a/src/services/save-bottle.ts b/src/services/save-bottle.ts index 3180563..fb702b9 100644 --- a/src/services/save-bottle.ts +++ b/src/services/save-bottle.ts @@ -7,38 +7,47 @@ import { v4 as uuidv4 } from 'uuid'; export async function saveBottle( metadata: BottleMetadata, - base64Image: string, - _ignoredUserId: string // Keeping for signature compatibility if needed, but using session internally + base64Image: string | null, + _ignoredUserId: string, // Keeping for signature compatibility + preUploadedUrl?: string ) { const supabase = createServerActionClient({ cookies }); try { - // Verify user session and get ID from the server side (secure) const { data: { session } } = await supabase.auth.getSession(); if (!session) { throw new Error('Nicht autorisiert oder Session abgelaufen.'); } const userId = session.user.id; + let finalImageUrl = preUploadedUrl; - // 1. Upload Image to Storage - const base64Data = base64Image.split(',')[1] || base64Image; - const buffer = Buffer.from(base64Data, 'base64'); - const fileName = `${userId}/${uuidv4()}.jpg`; + // 1. Upload Image to Storage if not already uploaded + if (!finalImageUrl && base64Image) { + const base64Data = base64Image.split(',')[1] || base64Image; + const buffer = Buffer.from(base64Data, 'base64'); + const fileName = `${userId}/${uuidv4()}.jpg`; - const { data: uploadData, error: uploadError } = await supabase.storage - .from('bottles') - .upload(fileName, buffer, { - contentType: 'image/jpeg', - upsert: true, - }); + const { error: uploadError } = await supabase.storage + .from('bottles') + .upload(fileName, buffer, { + contentType: 'image/jpeg', + upsert: true, + }); - if (uploadError) throw new Error(`Upload Error: ${uploadError.message}`); + if (uploadError) throw new Error(`Upload Error: ${uploadError.message}`); - // Get Public URL - const { data: { publicUrl } } = supabase.storage - .from('bottles') - .getPublicUrl(fileName); + // Get Public URL + const { data: { publicUrl } } = supabase.storage + .from('bottles') + .getPublicUrl(fileName); + + finalImageUrl = publicUrl; + } + + if (!finalImageUrl) { + throw new Error('Kein Bild zum Speichern vorhanden.'); + } // 2. Save Metadata to Database const { data: bottleData, error: dbError } = await supabase @@ -51,8 +60,8 @@ export async function saveBottle( abv: metadata.abv, age: metadata.age, whiskybase_id: metadata.whiskybaseId, - image_url: publicUrl, - status: 'sealed', // Default status + image_url: finalImageUrl, + status: 'sealed', is_whisky: metadata.is_whisky ?? true, confidence: metadata.confidence ?? 100, distilled_at: metadata.distilled_at, diff --git a/supa_schema.sql b/supa_schema.sql index eb84627..038371a 100644 --- a/supa_schema.sql +++ b/supa_schema.sql @@ -105,12 +105,14 @@ ALTER TABLE bottles ENABLE ROW LEVEL SECURITY; ALTER TABLE tastings ENABLE ROW LEVEL SECURITY; -- Policies for Profiles +DROP POLICY IF EXISTS "profiles_select_policy" ON profiles; CREATE POLICY "profiles_select_policy" ON profiles FOR SELECT USING ( (SELECT auth.uid()) = id OR EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid())) ); +DROP POLICY IF EXISTS "profiles_update_policy" ON profiles; CREATE POLICY "profiles_update_policy" ON profiles FOR UPDATE USING ( (SELECT auth.uid()) = id OR @@ -118,10 +120,14 @@ CREATE POLICY "profiles_update_policy" ON profiles ); -- Policies for Bottles +DROP POLICY IF EXISTS "Relaxed bottles access" ON bottles; +DROP POLICY IF EXISTS "bottles_owner_policy" ON bottles; CREATE POLICY "bottles_owner_policy" ON bottles FOR ALL USING ((SELECT auth.uid()) = user_id); -- Policies for Tastings +DROP POLICY IF EXISTS "tastings_owner_all" ON tastings; +DROP POLICY IF EXISTS "tastings_select_policy" ON tastings; CREATE POLICY "tastings_select_policy" ON tastings FOR SELECT USING ( (SELECT auth.uid()) = user_id OR @@ -131,11 +137,14 @@ CREATE POLICY "tastings_select_policy" ON tastings ) ); +DROP POLICY IF EXISTS "tastings_modify_policy" ON tastings; CREATE POLICY "tastings_modify_policy" ON tastings FOR ALL USING ((SELECT auth.uid()) = user_id); -- Policies for Buddies ALTER TABLE buddies ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Manage own buddies" ON buddies; +DROP POLICY IF EXISTS "buddies_access_policy" ON buddies; CREATE POLICY "buddies_access_policy" ON buddies FOR ALL USING ( (SELECT auth.uid()) = user_id OR @@ -144,6 +153,8 @@ CREATE POLICY "buddies_access_policy" ON buddies -- Policies for Tasting Sessions ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "Manage own sessions" ON tasting_sessions; +DROP POLICY IF EXISTS "sessions_access_policy" ON tasting_sessions; CREATE POLICY "sessions_access_policy" ON tasting_sessions FOR ALL USING ( (SELECT auth.uid()) = user_id OR @@ -155,11 +166,15 @@ CREATE POLICY "sessions_access_policy" ON tasting_sessions -- SESSION PARTICIPANTS ALTER TABLE session_participants ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "session_owner_all" ON session_participants; +DROP POLICY IF EXISTS "session_participants_owner_policy" ON session_participants; CREATE POLICY "session_participants_owner_policy" ON session_participants FOR ALL USING ((SELECT auth.uid()) = user_id); -- TASTING TAGS ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS "tags_owner_all" ON tasting_tags; +DROP POLICY IF EXISTS "tasting_tags_owner_policy" ON tasting_tags; CREATE POLICY "tasting_tags_owner_policy" ON tasting_tags FOR ALL USING ((SELECT auth.uid()) = user_id);