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 avatarUpload 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