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
- Go to Storage in Supabase Dashboard
- Click "New bucket"
- 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
- Go to Storage > Policies
- Set file size limits per bucket:
avatars: 2MB maxplugin-attachments: 10MB maxexports: 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);