feat: implement server-side image compression with sharp and cleanup RLS policies
This commit is contained in:
@@ -2,6 +2,11 @@
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
productionBrowserSourceMaps: false,
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '10mb',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
69
src/app/api/upload/route.ts
Normal file
69
src/app/api/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user