feat: implement server-side image compression with sharp and cleanup RLS policies
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
productionBrowserSourceMaps: false,
|
productionBrowserSourceMaps: false,
|
||||||
|
experimental: {
|
||||||
|
serverActions: {
|
||||||
|
bodySizeLimit: '10mb',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
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 [lastSavedId, setLastSavedId] = useState<string | null>(null);
|
||||||
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
|
const [wbDiscovery, setWbDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
|
||||||
const [isDiscovering, setIsDiscovering] = useState(false);
|
const [isDiscovering, setIsDiscovering] = useState(false);
|
||||||
|
const [originalFile, setOriginalFile] = useState<File | null>(null);
|
||||||
|
|
||||||
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCapture = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -88,13 +89,14 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
quality: 0.8
|
quality: 0.8
|
||||||
});
|
});
|
||||||
|
|
||||||
// heic2any can return an array if the file contains multiple images
|
|
||||||
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
const blob = Array.isArray(convertedBlob) ? convertedBlob[0] : convertedBlob;
|
||||||
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
fileToProcess = new File([blob], file.name.replace(/\.(heic|heif)$/i, '.jpg'), {
|
||||||
type: 'image/jpeg'
|
type: 'image/jpeg'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOriginalFile(fileToProcess);
|
||||||
|
|
||||||
const compressedBase64 = await compressImage(fileToProcess);
|
const compressedBase64 = await compressImage(fileToProcess);
|
||||||
setPreviewUrl(compressedBase64);
|
setPreviewUrl(compressedBase64);
|
||||||
|
|
||||||
@@ -151,7 +153,21 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
throw new Error(t('camera.authRequired'));
|
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) {
|
if (response.success && response.data) {
|
||||||
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
const url = `/bottles/${response.data.id}${validatedSessionId ? `?session_id=${validatedSessionId}` : ''}`;
|
||||||
@@ -174,13 +190,26 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current user (simple check for now, can be improved with Auth)
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t('camera.authRequired'));
|
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) {
|
if (response.success && response.data) {
|
||||||
setLastSavedId(response.data.id);
|
setLastSavedId(response.data.id);
|
||||||
@@ -232,7 +261,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
img.src = event.target?.result as string;
|
img.src = event.target?.result as string;
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const MAX_WIDTH = 1024;
|
const MAX_WIDTH = 1200;
|
||||||
let width = img.width;
|
let width = img.width;
|
||||||
let height = img.height;
|
let height = img.height;
|
||||||
|
|
||||||
@@ -251,7 +280,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.drawImage(img, 0, 0, width, height);
|
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);
|
resolve(base64);
|
||||||
};
|
};
|
||||||
img.onerror = reject;
|
img.onerror = reject;
|
||||||
|
|||||||
@@ -7,38 +7,47 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
|
|
||||||
export async function saveBottle(
|
export async function saveBottle(
|
||||||
metadata: BottleMetadata,
|
metadata: BottleMetadata,
|
||||||
base64Image: string,
|
base64Image: string | null,
|
||||||
_ignoredUserId: string // Keeping for signature compatibility if needed, but using session internally
|
_ignoredUserId: string, // Keeping for signature compatibility
|
||||||
|
preUploadedUrl?: string
|
||||||
) {
|
) {
|
||||||
const supabase = createServerActionClient({ cookies });
|
const supabase = createServerActionClient({ cookies });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify user session and get ID from the server side (secure)
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error('Nicht autorisiert oder Session abgelaufen.');
|
throw new Error('Nicht autorisiert oder Session abgelaufen.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = session.user.id;
|
const userId = session.user.id;
|
||||||
|
let finalImageUrl = preUploadedUrl;
|
||||||
|
|
||||||
// 1. Upload Image to Storage
|
// 1. Upload Image to Storage if not already uploaded
|
||||||
const base64Data = base64Image.split(',')[1] || base64Image;
|
if (!finalImageUrl && base64Image) {
|
||||||
const buffer = Buffer.from(base64Data, 'base64');
|
const base64Data = base64Image.split(',')[1] || base64Image;
|
||||||
const fileName = `${userId}/${uuidv4()}.jpg`;
|
const buffer = Buffer.from(base64Data, 'base64');
|
||||||
|
const fileName = `${userId}/${uuidv4()}.jpg`;
|
||||||
|
|
||||||
const { data: uploadData, error: uploadError } = await supabase.storage
|
const { error: uploadError } = await supabase.storage
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
.upload(fileName, buffer, {
|
.upload(fileName, buffer, {
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
upsert: true,
|
upsert: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (uploadError) throw new Error(`Upload Error: ${uploadError.message}`);
|
if (uploadError) throw new Error(`Upload Error: ${uploadError.message}`);
|
||||||
|
|
||||||
// Get Public URL
|
// Get Public URL
|
||||||
const { data: { publicUrl } } = supabase.storage
|
const { data: { publicUrl } } = supabase.storage
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
.getPublicUrl(fileName);
|
.getPublicUrl(fileName);
|
||||||
|
|
||||||
|
finalImageUrl = publicUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalImageUrl) {
|
||||||
|
throw new Error('Kein Bild zum Speichern vorhanden.');
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Save Metadata to Database
|
// 2. Save Metadata to Database
|
||||||
const { data: bottleData, error: dbError } = await supabase
|
const { data: bottleData, error: dbError } = await supabase
|
||||||
@@ -51,8 +60,8 @@ export async function saveBottle(
|
|||||||
abv: metadata.abv,
|
abv: metadata.abv,
|
||||||
age: metadata.age,
|
age: metadata.age,
|
||||||
whiskybase_id: metadata.whiskybaseId,
|
whiskybase_id: metadata.whiskybaseId,
|
||||||
image_url: publicUrl,
|
image_url: finalImageUrl,
|
||||||
status: 'sealed', // Default status
|
status: 'sealed',
|
||||||
is_whisky: metadata.is_whisky ?? true,
|
is_whisky: metadata.is_whisky ?? true,
|
||||||
confidence: metadata.confidence ?? 100,
|
confidence: metadata.confidence ?? 100,
|
||||||
distilled_at: metadata.distilled_at,
|
distilled_at: metadata.distilled_at,
|
||||||
|
|||||||
@@ -105,12 +105,14 @@ ALTER TABLE bottles ENABLE ROW LEVEL SECURITY;
|
|||||||
ALTER TABLE tastings ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE tastings ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Policies for Profiles
|
-- Policies for Profiles
|
||||||
|
DROP POLICY IF EXISTS "profiles_select_policy" ON profiles;
|
||||||
CREATE POLICY "profiles_select_policy" ON profiles
|
CREATE POLICY "profiles_select_policy" ON profiles
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
(SELECT auth.uid()) = id OR
|
(SELECT auth.uid()) = id OR
|
||||||
EXISTS (SELECT 1 FROM admin_users WHERE user_id = (SELECT auth.uid()))
|
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
|
CREATE POLICY "profiles_update_policy" ON profiles
|
||||||
FOR UPDATE USING (
|
FOR UPDATE USING (
|
||||||
(SELECT auth.uid()) = id OR
|
(SELECT auth.uid()) = id OR
|
||||||
@@ -118,10 +120,14 @@ CREATE POLICY "profiles_update_policy" ON profiles
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Policies for Bottles
|
-- 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
|
CREATE POLICY "bottles_owner_policy" ON bottles
|
||||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
-- Policies for Tastings
|
-- 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
|
CREATE POLICY "tastings_select_policy" ON tastings
|
||||||
FOR SELECT USING (
|
FOR SELECT USING (
|
||||||
(SELECT auth.uid()) = user_id OR
|
(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
|
CREATE POLICY "tastings_modify_policy" ON tastings
|
||||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
-- Policies for Buddies
|
-- Policies for Buddies
|
||||||
ALTER TABLE buddies ENABLE ROW LEVEL SECURITY;
|
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
|
CREATE POLICY "buddies_access_policy" ON buddies
|
||||||
FOR ALL USING (
|
FOR ALL USING (
|
||||||
(SELECT auth.uid()) = user_id OR
|
(SELECT auth.uid()) = user_id OR
|
||||||
@@ -144,6 +153,8 @@ CREATE POLICY "buddies_access_policy" ON buddies
|
|||||||
|
|
||||||
-- Policies for Tasting Sessions
|
-- Policies for Tasting Sessions
|
||||||
ALTER TABLE tasting_sessions ENABLE ROW LEVEL SECURITY;
|
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
|
CREATE POLICY "sessions_access_policy" ON tasting_sessions
|
||||||
FOR ALL USING (
|
FOR ALL USING (
|
||||||
(SELECT auth.uid()) = user_id OR
|
(SELECT auth.uid()) = user_id OR
|
||||||
@@ -155,11 +166,15 @@ CREATE POLICY "sessions_access_policy" ON tasting_sessions
|
|||||||
|
|
||||||
-- SESSION PARTICIPANTS
|
-- SESSION PARTICIPANTS
|
||||||
ALTER TABLE session_participants ENABLE ROW LEVEL SECURITY;
|
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
|
CREATE POLICY "session_participants_owner_policy" ON session_participants
|
||||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
-- TASTING TAGS
|
-- TASTING TAGS
|
||||||
ALTER TABLE tasting_tags ENABLE ROW LEVEL SECURITY;
|
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
|
CREATE POLICY "tasting_tags_owner_policy" ON tasting_tags
|
||||||
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
FOR ALL USING ((SELECT auth.uid()) = user_id);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user