Drag-and-Drop-Upload-Zone mit schwebenden Datei-Icons und Cloud-Storage

    File-Uploads in Lovable-Formularen: Drag&Drop, Supabase Storage, RLS und signed URLs

    Till FreitagTill Freitag19. April 20265 min read
    Till Freitag

    TL;DR: „Drag&Drop UI mit react-dropzone, sicherer Upload in Supabase Storage, private Buckets mit RLS und signed URLs für temporären Zugriff – das komplette Datei-Upload-Setup."

    — Till Freitag

    📌 Lovable-Forms-Serie · Teil 6 von 6
    Der Abschluss der Serie: Wenn dein Formular Bewerbungen, Verträge, Screenshots oder PDFs entgegennehmen soll, brauchst du mehr als nur einen <input type="file">. So machst du es richtig.

    File-Uploads in Lovable-Formularen: Drag&Drop, Supabase Storage, RLS und signed URLs

    File-Uploads sind der Klassiker, an dem MVPs scheitern: Entweder das UX ist hässlich (Browser-Default), die Validierung fehlt (50 MB-Bewerbungs-PDF crasht den Server) oder die Sicherheit ist offen wie ein Scheunentor (öffentlicher Bucket mit allen Bewerber-Lebensläufen).

    In diesem Teil bauen wir den kompletten Stack: schicke Drag&Drop-UI, Client- und Server-Validierung, sichere Supabase Storage Buckets mit RLS und temporären Zugriff via signed URLs.


    Architektur

    [react-dropzone UI]
            ↓ (Client-Validierung)
    [Supabase Storage Upload]
            ↓ (RLS-Policies prüfen)
    [Bucket: form-uploads (private)]
            ↓ (Edge Function)
    [signed URL für Sales/HR-Team]

    Goldene Regel: Uploads landen IMMER in privaten Buckets. Niemals public, auch nicht „nur kurz".


    Schritt 1: Bucket + RLS in Lovable Cloud

    Lege in der Supabase-Konsole einen Bucket form-uploads an. Public off. Dann diese Migration:

    -- Bucket-Setup (falls nicht via UI angelegt)
    insert into storage.buckets (id, name, public)
    values ('form-uploads', 'form-uploads', false)
    on conflict (id) do nothing;
    
    -- RLS: anonyme User dürfen NUR in den eigenen Submission-Folder hochladen
    -- Folder-Struktur: form-uploads/{submission_id}/{filename}
    create policy "anon can upload to own submission folder"
    on storage.objects
    for insert
    to anon
    with check (
      bucket_id = 'form-uploads'
      and (storage.foldername(name))[1] = current_setting('request.jwt.claims', true)::json->>'submission_id'
    );
    
    -- Niemand außer service_role darf lesen (Downloads laufen via signed URL)
    create policy "no public read"
    on storage.objects
    for select
    to anon, authenticated
    using (false);

    ⚠️ Wichtig: Die submission_id kommt aus einem Kurzzeit-JWT, das die Edge Function ausstellt – nicht aus dem Frontend. Sonst könnte jeder beliebige IDs raten.


    Schritt 2: Upload-Token-Edge-Function

    Bevor der User hochlädt, holt das Frontend ein Upload-Token (gültig ~10 min) und eine frische submission_id.

    // supabase/functions/issue-upload-token/index.ts
    import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
    import { create } from "https://deno.land/x/djwt@v3.0.1/mod.ts";
    
    const corsHeaders = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
    };
    
    serve(async (req) => {
      if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
    
      const submissionId = crypto.randomUUID();
      const secret = Deno.env.get("SUPABASE_JWT_SECRET")!;
    
      const key = await crypto.subtle.importKey(
        "raw", new TextEncoder().encode(secret),
        { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
      );
    
      const jwt = await create(
        { alg: "HS256", typ: "JWT" },
        {
          role: "anon",
          submission_id: submissionId,
          exp: Math.floor(Date.now() / 1000) + 600, // 10 Min
        },
        key
      );
    
      return new Response(JSON.stringify({ submissionId, uploadToken: jwt }), {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    });

    Schritt 3: Drag&Drop-UI mit react-dropzone

    import { useDropzone } from "react-dropzone";
    import { Upload, X, FileText } from "lucide-react";
    
    const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
    const ACCEPTED = {
      "application/pdf": [".pdf"],
      "image/jpeg": [".jpg", ".jpeg"],
      "image/png": [".png"],
    };
    
    export function FileUploadField({ onChange }: { onChange: (files: UploadedFile[]) => void }) {
      const [files, setFiles] = useState<UploadedFile[]>([]);
    
      const onDrop = useCallback(async (accepted: File[]) => {
        // 1. Token holen
        const { data: tokenData } = await supabase.functions.invoke("issue-upload-token");
        const { submissionId, uploadToken } = tokenData;
    
        // 2. Hochladen
        const uploaded: UploadedFile[] = [];
        for (const file of accepted) {
          const path = `${submissionId}/${crypto.randomUUID()}-${file.name}`;
          const { error } = await supabase.storage
            .from("form-uploads")
            .upload(path, file, {
              headers: { Authorization: `Bearer ${uploadToken}` },
              cacheControl: "3600",
              upsert: false,
            });
          if (error) {
            toast.error(`Upload fehlgeschlagen: ${file.name}`);
            continue;
          }
          uploaded.push({ name: file.name, path, size: file.size });
        }
        const next = [...files, ...uploaded];
        setFiles(next);
        onChange(next);
      }, [files, onChange]);
    
      const { getRootProps, getInputProps, isDragActive } = useDropzone({
        onDrop,
        accept: ACCEPTED,
        maxSize: MAX_SIZE,
        multiple: true,
      });
    
      return (
        <div className="space-y-2">
          <div
            {...getRootProps()}
            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition
              ${isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/30"}`}
          >
            <input {...getInputProps()} />
            <Upload className="mx-auto h-8 w-8 text-muted-foreground" />
            <p className="mt-2 text-sm">
              {isDragActive ? "Loslassen zum Hochladen" : "PDF, JPG oder PNG hier ablegen oder klicken"}
            </p>
            <p className="text-xs text-muted-foreground">Max. 10 MB pro Datei</p>
          </div>
    
          {files.map((f, i) => (
            <div key={i} className="flex items-center justify-between rounded border p-2">
              <span className="flex items-center gap-2 text-sm"><FileText className="h-4 w-4" />{f.name}</span>
              <button onClick={() => removeFile(i)}><X className="h-4 w-4" /></button>
            </div>
          ))}
        </div>
      );
    }

    Schritt 4: Server-seitige Validierung (Pflicht)

    Client-Validierung ist nur UX. Die echte Prüfung passiert in der Submit-Edge-Function:

    // supabase/functions/submit-application/index.ts
    const ALLOWED_TYPES = ["application/pdf", "image/jpeg", "image/png"];
    const MAX_BYTES = 10 * 1024 * 1024;
    
    // Für jede angegebene Datei:
    const { data: head } = await supabaseAdmin.storage
      .from("form-uploads")
      .info(file.path); // metadata holen
    
    if (!ALLOWED_TYPES.includes(head.contentType)) {
      await supabaseAdmin.storage.from("form-uploads").remove([file.path]);
      throw new Error("Invalid file type");
    }
    if (head.size > MAX_BYTES) {
      await supabaseAdmin.storage.from("form-uploads").remove([file.path]);
      throw new Error("File too large");
    }

    💡 Bonus für regulierte Branchen: Hänge einen Virus-Scan via ClamAV als zweite Edge Function dahinter. Bei infected → File löschen + Submission rejecten.


    Schritt 5: Signed URLs für Lese-Zugriff

    Wenn das HR-Team die Bewerbung im internen Dashboard öffnen will, niemals den Bucket öffentlich machen. Stattdessen:

    const { data, error } = await supabaseAdmin.storage
      .from("form-uploads")
      .createSignedUrl(file.path, 60 * 5); // 5 Minuten gültig
    
    return data.signedUrl;

    URL hat einen einmaligen Token, läuft ab, lässt sich nicht teilen. Perfekt für „Klicke hier, um den Lebenslauf zu öffnen".


    Pitfalls

    Problem Ursache Lösung
    50 MB-Limit (Supabase Free) Bucket-Default Plan upgraden oder Multi-Part-Upload
    CORS-Error beim direkten Upload Bucket-CORS-Config fehlt In Supabase-Dashboard: Storage → Configuration → Allowed Origins
    User sieht Bucket-Listing Falsche RLS-Policy Explizit for select using (false)
    Datei bleibt liegen nach Form-Abbruch Kein Cleanup Cron-Edge-Function: lösche form-uploads/* älter als 24h ohne zugehörigen DB-Eintrag
    Mimetype-Spoof (.jpg = .exe) Nur Extension geprüft Server-seitig head.contentType prüfen + Magic-Number-Check

    Fazit

    File-Uploads in Lovable sind nicht „kompliziert" – sie haben nur viele Schichten, die alle stimmen müssen: UI, Token, Bucket, RLS, Server-Validierung, signed URLs. Wer eine davon vergisst, hat entweder kein Upload (UI/Token) oder ein Datenleck (RLS).

    Mit dem hier gezeigten Stack hast du beides: schickes Drag&Drop UX und HR-/DSGVO-taugliche Sicherheit.


    Damit ist die Serie komplett

    Du hast jetzt das volle Spektrum:

    Teil Fokus
    1 SaaS-Tools (Tally, Typeform, WorkForms)
    2 Custom-Bau (RHF + zod + Lovable Cloud)
    3 Production-Ready Best Practices
    4 monday.com-Anbindung via GraphQL
    5 Smart Forms mit AI
    6 File-Uploads & Storage (du bist hier)

    Du willst die ganze Serie als Beratungs-Workshop? Sprich mit uns – wir bauen Form-Stacks von der ersten Zeile bis zum CRM-Eintrag.

    TeilenLinkedInWhatsAppE-Mail

    Related Articles

    Web-Formular verbunden mit einem monday.com Board über GraphQL-Pfeil
    April 19, 20265 min

    Formulare an monday.com anbinden: Lead-Form → Item via GraphQL API

    Teil 4 der Lovable-Forms-Serie: So schreibst du Lovable-Formulare direkt in monday-Boards – mit GraphQL, Edge Function u…

    Read more
    Glasmorphes Formular mit AI-Sparkle-Icon und Auto-Complete-Vorschlägen
    April 19, 20265 min

    Smart Forms mit AI in Lovable: Auto-Complete, AI-Validierung & Conversational Forms

    Teil 5 der Lovable-Forms-Serie: Wie du Formulare mit dem Lovable AI Gateway aufwertest – Auto-Complete, AI-gestützte Val…

    Read more
    Drei Formular-Tools als schwebende UI-Karten mit Verbindungslinien zu einer Lovable-App
    April 16, 20263 min

    Formular-Tools für Lovable-Projekte: Typeform, Tally & monday WorkForms im Vergleich

    Teil 1 der Lovable-Forms-Serie: Welches SaaS-Formular-Tool passt zu deinem Lovable-Projekt? Tally, Typeform & monday Wor…

    Read more
    Formulare in Lovable selber bauen: React Hook Form, zod & Lovable Cloud Schritt für Schritt
    March 19, 20265 min

    Formulare in Lovable selber bauen: React Hook Form, zod & Lovable Cloud Schritt für Schritt

    Teil 2 der Lovable-Forms-Serie: Wie du Formulare direkt in Lovable baust – mit React Hook Form, zod, shadcn/ui und Lovab…

    Read more
    Glasmorphes Kontaktformular mit farbigen Eingabefeldern und Checkbox auf pastellfarbenem Hintergrund
    March 4, 20264 min

    Kontaktformulare in Lovable – Best Practices für professionelle Formulare

    Teil 3 der Lovable-Forms-Serie: Production-Ready Best Practices für Kontaktformulare in Lovable – Validierung, DSGVO, Sp…

    Read more
    Lovable Cloud vs Supabase Vergleich – rosafarbene Cloud mit Herz gegen grüne Supabase-Datenbank
    March 4, 20264 min

    Lovable Cloud vs. Supabase – Warum wir (fast) immer Supabase direkt nutzen

    Lovable Cloud basiert auf Supabase – aber wann lohnt sich die direkte Supabase-Anbindung? Wir zeigen, warum wir in Kunde…

    Read more
    Google Login in Lovable einrichten – SSO & Auth Schritt für Schritt
    March 19, 20266 min

    Google Login in Lovable einrichten – SSO & Auth Schritt für Schritt

    So richtest du Google Sign-In für dein Lovable-Projekt ein – von der Cloud Console bis zur fertigen Login-Seite. Komplet…

    Read more
    Die besten Lovable Ressourcen – Dein ultimativer Guide 2026
    March 18, 20263 min

    Die besten Lovable Ressourcen – Dein ultimativer Guide 2026

    Alle wichtigen Lovable Ressourcen auf einen Blick: offizielle Docs, Community, YouTube-Tutorials, Pricing und unsere bes…

    Read more
    Architektur-Diagramm eines modernen Vibe Coding Stacks mit Lovable, Supabase und Resend als Kernkomponenten
    March 16, 20265 min

    Der Vibe Coding Stack 2026: Lovable, Supabase, Resend – und was noch fehlt

    Das ist der Tech-Stack, mit dem wir 2026 Full-Stack-Apps bauen – ohne klassisches Dev-Team. Drei Tools im Kern, zwei für…

    Read more