mirror of
https://github.com/greedychipmunk/agent-skills.git
synced 2026-09-14 20:46:42 +08:00
11 KiB
11 KiB
Storage
Overview
Supabase Storage provides S3-compatible object storage with:
- Bucket-based organization
- Row Level Security integration
- Image transformations
- Resumable uploads
- CDN delivery
Bucket Management
Creating Buckets
-- Via SQL (recommended for version control)
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true);
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'documents',
'documents',
false,
10485760, -- 10MB limit
array['application/pdf', 'application/msword', 'text/plain']
);
Via Client
// Create bucket (requires service role)
const { data, error } = await supabase.storage.createBucket('avatars', {
public: true,
fileSizeLimit: 1024 * 1024 * 2, // 2MB
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif']
})
// List buckets
const { data: buckets } = await supabase.storage.listBuckets()
// Delete bucket
const { error } = await supabase.storage.deleteBucket('old-bucket')
Storage Policies
Public Bucket Policies
-- Anyone can view files in public bucket
create policy "Public avatars are viewable by everyone"
on storage.objects
for select
using (bucket_id = 'avatars');
-- Authenticated users can upload to their folder
create policy "Users can upload their avatar"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- Users can update their own avatar
create policy "Users can update their avatar"
on storage.objects
for update
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- Users can delete their own avatar
create policy "Users can delete their avatar"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
Private Bucket Policies
-- Users can only access their own files
create policy "Users can access own documents"
on storage.objects
for all
to authenticated
using (
bucket_id = 'documents'
and owner_id = auth.uid()
)
with check (
bucket_id = 'documents'
and owner_id = auth.uid()
);
Organization-Based Storage
-- Org members can access org files
create policy "Org members can access files"
on storage.objects
for select
to authenticated
using (
bucket_id = 'org-files'
and exists (
select 1 from public.organization_members
where organization_id = (storage.foldername(name))[1]::uuid
and user_id = auth.uid()
)
);
File Operations
Upload Files
// Simple upload
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.png`, file, {
cacheControl: '3600',
upsert: true, // Overwrite if exists
contentType: 'image/png'
})
// Upload from base64
const base64Data = 'data:image/png;base64,iVBORw0KGgo...'
const { data, error } = await supabase.storage
.from('avatars')
.upload('path/file.png', decode(base64Data), {
contentType: 'image/png'
})
// Resumable upload (large files)
const { data, error } = await supabase.storage
.from('videos')
.uploadToSignedUrl(path, token, file, {
cacheControl: '3600'
})
Upload with Progress
// Using XMLHttpRequest for progress
async function uploadWithProgress(
bucket: string,
path: string,
file: File,
onProgress: (percent: number) => void
) {
const { data: { session } } = await supabase.auth.getSession()
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress((e.loaded / e.total) * 100)
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new Error(xhr.statusText))
}
})
xhr.open('POST', `${supabaseUrl}/storage/v1/object/${bucket}/${path}`)
xhr.setRequestHeader('Authorization', `Bearer ${session?.access_token}`)
xhr.send(file)
})
}
Download Files
// Download file
const { data, error } = await supabase.storage
.from('documents')
.download('report.pdf')
// data is a Blob
const url = URL.createObjectURL(data)
// Download as array buffer
const { data } = await supabase.storage
.from('documents')
.download('report.pdf')
const arrayBuffer = await data.arrayBuffer()
Get URLs
// Public URL (for public buckets)
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl('user123/avatar.png')
// Signed URL (for private buckets)
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrl('private/report.pdf', 3600) // 1 hour expiry
// Signed URLs for multiple files
const { data, error } = await supabase.storage
.from('documents')
.createSignedUrls(['file1.pdf', 'file2.pdf'], 3600)
// Signed upload URL
const { data, error } = await supabase.storage
.from('uploads')
.createSignedUploadUrl('path/to/file.pdf')
List Files
// List files in folder
const { data, error } = await supabase.storage
.from('documents')
.list('folder', {
limit: 100,
offset: 0,
sortBy: { column: 'name', order: 'asc' }
})
// List with search
const { data, error } = await supabase.storage
.from('documents')
.list('folder', {
search: 'report'
})
Move and Copy
// Move file
const { error } = await supabase.storage
.from('documents')
.move('old/path/file.pdf', 'new/path/file.pdf')
// Copy file
const { error } = await supabase.storage
.from('documents')
.copy('source/file.pdf', 'destination/file.pdf')
Delete Files
// Delete single file
const { error } = await supabase.storage
.from('documents')
.remove(['path/to/file.pdf'])
// Delete multiple files
const { error } = await supabase.storage
.from('documents')
.remove(['file1.pdf', 'file2.pdf', 'file3.pdf'])
// Delete folder (all contents)
const { data: files } = await supabase.storage
.from('documents')
.list('folder-to-delete')
const filePaths = files?.map(f => `folder-to-delete/${f.name}`) || []
await supabase.storage.from('documents').remove(filePaths)
Image Transformations
Transform on Request
// Get transformed public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl('user123/avatar.png', {
transform: {
width: 200,
height: 200,
resize: 'cover'
}
})
// Transform options
const options = {
width: 800, // Target width
height: 600, // Target height
resize: 'cover', // cover, contain, fill
format: 'webp', // origin, webp
quality: 80 // 1-100
}
Signed URL with Transform
const { data, error } = await supabase.storage
.from('photos')
.createSignedUrl('photo.jpg', 3600, {
transform: {
width: 400,
height: 300,
resize: 'contain'
}
})
Common Transformations
// Thumbnail
const thumbnail = supabase.storage
.from('photos')
.getPublicUrl('photo.jpg', {
transform: { width: 150, height: 150, resize: 'cover' }
})
// Profile picture (square)
const profile = supabase.storage
.from('avatars')
.getPublicUrl('avatar.jpg', {
transform: { width: 200, height: 200, resize: 'cover', format: 'webp' }
})
// Banner image
const banner = supabase.storage
.from('banners')
.getPublicUrl('banner.jpg', {
transform: { width: 1200, height: 400, resize: 'cover' }
})
React Integration
File Upload Component
function FileUpload({ bucket, path, onUpload }) {
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setProgress(0)
const fileExt = file.name.split('.').pop()
const filePath = `${path}/${Date.now()}.${fileExt}`
const { data, error } = await supabase.storage
.from(bucket)
.upload(filePath, file, {
cacheControl: '3600',
upsert: false
})
setUploading(false)
if (error) {
console.error('Upload error:', error)
} else {
onUpload(data.path)
}
}
return (
<div>
<input
type="file"
onChange={handleUpload}
disabled={uploading}
/>
{uploading && <progress value={progress} max="100" />}
</div>
)
}
Avatar Component
function Avatar({ userId, size = 100 }) {
const [avatarUrl, setAvatarUrl] = useState<string | null>(null)
useEffect(() => {
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`, {
transform: {
width: size,
height: size,
resize: 'cover'
}
})
setAvatarUrl(publicUrl)
}, [userId, size])
return (
<img
src={avatarUrl || '/default-avatar.png'}
alt="Avatar"
width={size}
height={size}
onError={(e) => {
e.currentTarget.src = '/default-avatar.png'
}}
/>
)
}
Best Practices
File Organization
bucket/
├── {user_id}/ # User-specific files
│ ├── avatar.png
│ └── documents/
├── {org_id}/ # Organization files
│ └── shared/
└── public/ # Publicly accessible
Security
- Always set appropriate bucket visibility (public/private)
- Implement RLS policies for all buckets
- Use signed URLs for sensitive files
- Validate file types before upload
- Set file size limits on buckets
- Sanitize filenames to prevent path traversal
Performance
- Use image transformations for responsive images
- Set appropriate cache control headers
- Use CDN for frequently accessed files
- Implement lazy loading for images
- Consider using signed URLs with caching
File Validation
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
function validateFile(file: File): { valid: boolean; error?: string } {
if (!ALLOWED_TYPES.includes(file.type)) {
return { valid: false, error: 'Invalid file type' }
}
if (file.size > MAX_SIZE) {
return { valid: false, error: 'File too large' }
}
return { valid: true }
}
Cleanup Orphaned Files
-- Find files not referenced in database
select o.name
from storage.objects o
left join public.profiles p on o.name like p.id::text || '/%'
where o.bucket_id = 'avatars'
and p.id is null;