feat: implement server-side image compression with sharp and cleanup RLS policies

This commit is contained in:
2025-12-18 22:08:28 +01:00
parent d26ebc0b2e
commit 9eb9b41061
5 changed files with 153 additions and 26 deletions

View File

@@ -2,6 +2,11 @@
const nextConfig = {
output: 'standalone',
productionBrowserSourceMaps: false,
experimental: {
serverActions: {
bodySizeLimit: '10mb',
},
},
};
export default nextConfig;

View File

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

View File

@@ -62,6 +62,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
const [lastSavedId, setLastSavedId] = useState<string | null>(null);
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
const [isDiscovering, setIsDiscovering] = useState(false);
const [originalFile, setOriginalFile] = useState<File | null>(null);
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
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;

View File

@@ -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,

View File

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