Skip to content

File Storage API

Firebase Storage patterns for upload, versioning, and offline caching.


Overview

On Book Pro uses Firebase Storage for file management with:

  • Versioned uploads (automatic backup of previous versions)
  • Offline caching via IndexedDB
  • Trash and recovery workflow
  • Visibility permissions per file

Storage Paths

gs://on-book-pro/
├── projects/{projectId}/
│   ├── files/
│   │   ├── {fileId}/
│   │   │   ├── current         # Current version
│   │   │   └── versions/
│   │   │       ├── v1          # Previous versions
│   │   │       └── v2
│   │   └── thumbnails/
│   │       └── {fileId}.jpg    # Generated thumbnails
│   └── assets/
│       ├── script.pdf          # Production script
│       └── logo.png            # Production logo
└── users/{userId}/
    └── profile.jpg             # User avatar

Upload Workflow

Standard Upload

typescript
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { storage } from '@/lib/firebase';

export const uploadFile = async (
    projectId: string,
    file: File,
    options?: { visibility?: 'all' | 'team' | 'owner' }
) => {
    const fileId = crypto.randomUUID();
    const storageRef = ref(storage, `projects/${projectId}/files/${fileId}/current`);
    
    // Upload file
    const snapshot = await uploadBytes(storageRef, file, {
        customMetadata: {
            originalName: file.name,
            uploadedBy: auth.currentUser?.uid || '',
            visibility: options?.visibility || 'all',
        },
    });
    
    // Get download URL
    const downloadUrl = await getDownloadURL(snapshot.ref);
    
    return {
        fileId,
        downloadUrl,
        name: file.name,
        size: file.size,
        type: file.type,
    };
};

Versioned Upload (Replace)

typescript
export const replaceFile = async (
    projectId: string,
    fileId: string,
    newFile: File
) => {
    // 1. Copy current to versions
    const currentRef = ref(storage, `projects/${projectId}/files/${fileId}/current`);
    const versionNumber = await getNextVersionNumber(projectId, fileId);
    const versionRef = ref(storage, `projects/${projectId}/files/${fileId}/versions/v${versionNumber}`);
    
    // Copy operation requires downloading and re-uploading
    const currentUrl = await getDownloadURL(currentRef);
    const response = await fetch(currentUrl);
    const blob = await response.blob();
    await uploadBytes(versionRef, blob);
    
    // 2. Upload new file as current
    await uploadBytes(currentRef, newFile, {
        customMetadata: {
            originalName: newFile.name,
            version: String(versionNumber + 1),
        },
    });
    
    return { versionNumber: versionNumber + 1 };
};

Thumbnail Generation

Thumbnails are generated client-side for images:

typescript
export const generateThumbnail = async (file: File): Promise<Blob> => {
    return new Promise((resolve) => {
        const img = new Image();
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        
        img.onload = () => {
            const MAX_SIZE = 200;
            let width = img.width;
            let height = img.height;
            
            if (width > height) {
                height = (height / width) * MAX_SIZE;
                width = MAX_SIZE;
            } else {
                width = (width / height) * MAX_SIZE;
                height = MAX_SIZE;
            }
            
            canvas.width = width;
            canvas.height = height;
            ctx?.drawImage(img, 0, 0, width, height);
            
            canvas.toBlob((blob) => resolve(blob!), 'image/jpeg', 0.7);
        };
        
        img.src = URL.createObjectURL(file);
    });
};

Trash & Recovery

Files are soft-deleted by moving to a trash folder:

typescript
// Move to trash (soft delete)
export const trashFile = async (projectId: string, fileId: string) => {
    // Update Firestore metadata
    await updateDoc(doc(db, `projects/${projectId}/files/${fileId}`), {
        trashed: true,
        trashedAt: serverTimestamp(),
    });
};

// Recover from trash
export const recoverFile = async (projectId: string, fileId: string) => {
    await updateDoc(doc(db, `projects/${projectId}/files/${fileId}`), {
        trashed: false,
        trashedAt: deleteField(),
    });
};

// Permanent delete (after 30 days or manual)
export const permanentlyDelete = async (projectId: string, fileId: string) => {
    // Delete all versions from storage
    const fileRef = ref(storage, `projects/${projectId}/files/${fileId}`);
    await deleteObject(fileRef);
    
    // Delete Firestore document
    await deleteDoc(doc(db, `projects/${projectId}/files/${fileId}`));
};

Offline Caching

Files are cached in IndexedDB for offline access:

typescript
import { openDB } from 'idb';

const initFileCache = async () => {
    return openDB('file-cache', 1, {
        upgrade(db) {
            db.createObjectStore('files');
        },
    });
};

export const cacheFile = async (fileId: string, blob: Blob) => {
    const db = await initFileCache();
    await db.put('files', blob, fileId);
};

export const getCachedFile = async (fileId: string): Promise<Blob | undefined> => {
    const db = await initFileCache();
    return db.get('files', fileId);
};

// Check for cached version first, then fetch from Storage
export const getFile = async (fileId: string, downloadUrl: string): Promise<Blob> => {
    const cached = await getCachedFile(fileId);
    if (cached) return cached;
    
    const response = await fetch(downloadUrl);
    const blob = await response.blob();
    await cacheFile(fileId, blob);
    
    return blob;
};

Visibility Permissions

Files can have restricted visibility:

typescript
type FileVisibility = 'all' | 'team' | 'owner';

// Check if user can view file
export const canViewFile = (
    file: FileMetadata,
    userId: string,
    userRole: Role
): boolean => {
    switch (file.visibility) {
        case 'owner':
            return file.uploadedBy === userId;
        case 'team':
            return userRole >= Role.TEAM_MEMBER;
        case 'all':
        default:
            return true;
    }
};

Storage Quota

Track storage usage per project:

typescript
export const getStorageUsage = async (projectId: string): Promise<number> => {
    const filesRef = ref(storage, `projects/${projectId}/files`);
    const list = await listAll(filesRef);
    
    let totalBytes = 0;
    for (const item of list.items) {
        const metadata = await getMetadata(item);
        totalBytes += metadata.size;
    }
    
    return totalBytes;
};

Last updated: January 2026