Cursorist Docs
Supabase

Storage Configuration

Supabase Storage setup for avatars, plugin attachments, and data exports.

Cursorist uses Supabase Storage for three types of files: user avatars, plugin attachments, and data exports. Each lives in its own bucket with dedicated RLS policies that control who can upload, view, and delete files.

The avatars bucket is public — anyone can view profile pictures. The plugin-assets and exports buckets are private — access is restricted to team members (for plugins) or the file owner (for exports). All storage policies are created by the supabase/setup.sql migration.

Storage Buckets

Create Buckets

-- Via SQL (run in SQL Editor)
INSERT INTO storage.buckets (id, name, public)
VALUES 
  ('avatars', 'avatars', true),
  ('plugin-attachments', 'plugin-attachments', false),
  ('exports', 'exports', false);

Via Dashboard

  1. Go to Storage in Supabase Dashboard
  2. Click "New bucket"
  3. Create buckets:
    • avatars (public)
    • plugin-attachments (private)
    • exports (private)

Storage Policies

Avatars Bucket (Public)

-- Anyone can view avatars
CREATE POLICY "Avatars are publicly accessible"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');

-- Users can upload their own avatar
CREATE POLICY "Users can upload own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Users can update their own avatar
CREATE POLICY "Users can update own avatar"
ON storage.objects FOR UPDATE
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

-- Users can delete their own avatar
CREATE POLICY "Users can delete own avatar"
ON storage.objects FOR DELETE
USING (
  bucket_id = 'avatars'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

Plugin Attachments Bucket (Private)

-- Team members can view attachments
CREATE POLICY "Team members can view plugin attachments"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'plugin-attachments'
  AND EXISTS (
    SELECT 1 FROM team_members tm
    JOIN plugins p ON p.team_id = tm.team_id
    WHERE tm.user_id = auth.uid()
    AND p.id::text = (storage.foldername(name))[1]
  )
);

-- Team members can upload attachments
CREATE POLICY "Team members can upload plugin attachments"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'plugin-attachments'
  AND EXISTS (
    SELECT 1 FROM team_members tm
    JOIN plugins p ON p.team_id = tm.team_id
    WHERE tm.user_id = auth.uid()
    AND p.id::text = (storage.foldername(name))[1]
  )
);

-- Plugin authors can delete attachments
CREATE POLICY "Authors can delete plugin attachments"
ON storage.objects FOR DELETE
USING (
  bucket_id = 'plugin-attachments'
  AND EXISTS (
    SELECT 1 FROM plugins p
    WHERE p.author_id = auth.uid()
    AND p.id::text = (storage.foldername(name))[1]
  )
);

Exports Bucket (Private)

-- Users can only access their own exports
CREATE POLICY "Users can view own exports"
ON storage.objects FOR SELECT
USING (
  bucket_id = 'exports'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

CREATE POLICY "Users can create own exports"
ON storage.objects FOR INSERT
WITH CHECK (
  bucket_id = 'exports'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

CREATE POLICY "Users can delete own exports"
ON storage.objects FOR DELETE
USING (
  bucket_id = 'exports'
  AND auth.uid()::text = (storage.foldername(name))[1]
);

Frontend Implementation

Avatar Upload Component

import { createClient } from "@/lib/supabase/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Upload } from "lucide-react";
import { useState } from "react";

interface AvatarUploadProps {
  userId: string;
  currentAvatarUrl?: string;
  onUploadComplete: (url: string) => void;
}

export function AvatarUpload({
  userId,
  currentAvatarUrl,
  onUploadComplete,
}: AvatarUploadProps) {
  const [uploading, setUploading] = useState(false);
  const supabase = createClient();

  const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
    try {
      setUploading(true);

      if (!event.target.files || event.target.files.length === 0) {
        return;
      }

      const file = event.target.files[0];
      const fileExt = file.name.split(".").pop();
      const filePath = `${userId}/avatar.${fileExt}`;

      // Upload file
      const { error: uploadError } = await supabase.storage
        .from("avatars")
        .upload(filePath, file, { upsert: true });

      if (uploadError) {
        throw uploadError;
      }

      // Get public URL
      const { data } = supabase.storage
        .from("avatars")
        .getPublicUrl(filePath);

      onUploadComplete(data.publicUrl);
    } catch (error) {
      console.error("Error uploading avatar:", error);
    } finally {
      setUploading(false);
    }
  };

  return (
    <div className="flex items-center gap-4">
      {currentAvatarUrl && (
        <img
          src={currentAvatarUrl}
          alt="Avatar"
          className="h-16 w-16 rounded-full"
        />
      )}
      <div>
        <Input
          type="file"
          accept="image/*"
          onChange={handleUpload}
          disabled={uploading}
          className="hidden"
          id="avatar-upload"
        />
        <Button asChild disabled={uploading}>
          <label htmlFor="avatar-upload" className="cursor-pointer">
            <Upload className="mr-2 h-4 w-4" />
            {uploading ? "Uploading..." : "Upload Avatar"}
          </label>
        </Button>
      </div>
    </div>
  );
}

Plugin Attachment Upload

export async function uploadPluginAttachment(
  pluginId: string,
  file: File
): Promise<string> {
  const supabase = createClient();
  
  const fileExt = file.name.split(".").pop();
  const fileName = `${Date.now()}.${fileExt}`;
  const filePath = `${pluginId}/${fileName}`;

  const { error } = await supabase.storage
    .from("plugin-attachments")
    .upload(filePath, file);

  if (error) {
    throw error;
  }

  // Get signed URL for private bucket
  const { data } = await supabase.storage
    .from("plugin-attachments")
    .createSignedUrl(filePath, 3600); // 1 hour expiry

  return data?.signedUrl || "";
}

export async function listPluginAttachments(pluginId: string) {
  const supabase = createClient();

  const { data, error } = await supabase.storage
    .from("plugin-attachments")
    .list(pluginId);

  if (error) {
    throw error;
  }

  return data;
}

export async function deletePluginAttachment(pluginId: string, fileName: string) {
  const supabase = createClient();

  const { error } = await supabase.storage
    .from("plugin-attachments")
    .remove([`${pluginId}/${fileName}`]);

  if (error) {
    throw error;
  }
}

Export User Data

// Edge function for data export
// supabase/functions/export-user-data/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { supabaseAdmin } from "../_shared/supabase.ts";
import { corsHeaders, handleCors } from "../_shared/cors.ts";

serve(async (req) => {
  const corsResponse = handleCors(req);
  if (corsResponse) return corsResponse;

  try {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader) {
      return new Response(
        JSON.stringify({ error: "Unauthorized" }),
        { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    const token = authHeader.replace("Bearer ", "");
    const { data: { user } } = await supabaseAdmin.auth.getUser(token);

    if (!user) {
      return new Response(
        JSON.stringify({ error: "Invalid token" }),
        { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
      );
    }

    // Gather all user data
    const [profile, plugins, favorites, organizations, teams] = await Promise.all([
      supabaseAdmin.from("users").select("*").eq("id", user.id).single(),
      supabaseAdmin.from("plugins").select("*").eq("author_id", user.id),
      supabaseAdmin.from("plugin_favorites").select("*, plugin:plugins(*)").eq("user_id", user.id),
      supabaseAdmin.from("organization_members").select("*, organization:organizations(*)").eq("user_id", user.id),
      supabaseAdmin.from("team_members").select("*, team:teams(*)").eq("user_id", user.id),
    ]);

    const exportData = {
      exported_at: new Date().toISOString(),
      user: profile.data,
      plugins: plugins.data,
      favorites: favorites.data,
      organizations: organizations.data,
      teams: teams.data,
    };

    // Save to storage
    const fileName = `${user.id}/export-${Date.now()}.json`;
    const { error: uploadError } = await supabaseAdmin.storage
      .from("exports")
      .upload(fileName, JSON.stringify(exportData, null, 2), {
        contentType: "application/json",
      });

    if (uploadError) {
      throw uploadError;
    }

    // Get signed download URL
    const { data: signedUrl } = await supabaseAdmin.storage
      .from("exports")
      .createSignedUrl(fileName, 86400); // 24 hours

    return new Response(
      JSON.stringify({ downloadUrl: signedUrl?.signedUrl }),
      { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message }),
      { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
    );
  }
});

File Size Limits

Configure in Dashboard

  1. Go to Storage > Policies
  2. Set file size limits per bucket:
    • avatars: 2MB max
    • plugin-attachments: 10MB max
    • exports: 50MB max

Validate on Upload

const MAX_AVATAR_SIZE = 2 * 1024 * 1024; // 2MB

function validateFile(file: File, maxSize: number): boolean {
  if (file.size > maxSize) {
    toast.error(`File size must be less than ${maxSize / 1024 / 1024}MB`);
    return false;
  }
  return true;
}

Image Transformation

Using Supabase Image Transform

function getTransformedAvatarUrl(
  originalUrl: string,
  width: number = 100,
  height: number = 100
): string {
  const url = new URL(originalUrl);
  url.searchParams.set("width", width.toString());
  url.searchParams.set("height", height.toString());
  url.searchParams.set("resize", "cover");
  return url.toString();
}

// Usage
const thumbnailUrl = getTransformedAvatarUrl(avatarUrl, 48, 48);
const fullSizeUrl = getTransformedAvatarUrl(avatarUrl, 200, 200);