Initial commit
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
# File Handling
|
||||
|
||||
**Impact: MEDIUM**
|
||||
|
||||
File uploads, URL generation, thumbnail creation, and validation patterns.
|
||||
|
||||
---
|
||||
|
||||
## 1. Generate File URLs Correctly
|
||||
|
||||
**Impact: MEDIUM (Proper URLs with thumbnails and access control)**
|
||||
|
||||
Use the SDK's `getURL` method to generate proper file URLs with thumbnail support and access tokens for protected files.
|
||||
|
||||
**Incorrect (manually constructing URLs):**
|
||||
|
||||
```javascript
|
||||
// Hardcoded URL construction - brittle
|
||||
const imageUrl = `http://localhost:8090/api/files/${record.collectionId}/${record.id}/${record.image}`;
|
||||
|
||||
// Missing token for protected files
|
||||
const privateUrl = pb.files.getURL(record, record.document);
|
||||
// Returns URL but file access denied if protected!
|
||||
|
||||
// Wrong thumbnail syntax
|
||||
const thumb = `${imageUrl}?thumb=100x100`; // Wrong format
|
||||
```
|
||||
|
||||
**Correct (using SDK methods):**
|
||||
|
||||
```javascript
|
||||
// Basic file URL
|
||||
const imageUrl = pb.files.getURL(record, record.image);
|
||||
// Returns: http://host/api/files/COLLECTION/RECORD_ID/filename.jpg
|
||||
|
||||
// With thumbnail (for images only)
|
||||
const thumbUrl = pb.files.getURL(record, record.image, {
|
||||
thumb: '100x100' // Width x Height
|
||||
});
|
||||
|
||||
// Thumbnail options
|
||||
const thumbs = {
|
||||
square: pb.files.getURL(record, record.image, { thumb: '100x100' }),
|
||||
fit: pb.files.getURL(record, record.image, { thumb: '100x0' }), // Fit width
|
||||
fitHeight: pb.files.getURL(record, record.image, { thumb: '0x100' }), // Fit height
|
||||
crop: pb.files.getURL(record, record.image, { thumb: '100x100t' }), // Top crop
|
||||
cropBottom: pb.files.getURL(record, record.image, { thumb: '100x100b' }), // Bottom
|
||||
force: pb.files.getURL(record, record.image, { thumb: '100x100f' }), // Force exact
|
||||
};
|
||||
|
||||
// Protected files (require auth)
|
||||
async function getProtectedFileUrl(record, filename) {
|
||||
// Get file access token (valid for limited time)
|
||||
const token = await pb.files.getToken();
|
||||
|
||||
// Include token in URL
|
||||
return pb.files.getURL(record, filename, { token });
|
||||
}
|
||||
|
||||
// Example with protected document
|
||||
async function downloadDocument(record) {
|
||||
const token = await pb.files.getToken();
|
||||
const url = pb.files.getURL(record, record.document, { token });
|
||||
|
||||
// Token is appended: ...?token=xxx
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
```
|
||||
|
||||
**React component example:**
|
||||
|
||||
```jsx
|
||||
function UserAvatar({ user, size = 50 }) {
|
||||
if (!user.avatar) {
|
||||
return <DefaultAvatar size={size} />;
|
||||
}
|
||||
|
||||
const avatarUrl = pb.files.getURL(user, user.avatar, {
|
||||
thumb: `${size}x${size}`
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={user.name}
|
||||
width={size}
|
||||
height={size}
|
||||
loading="lazy"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageGallery({ record }) {
|
||||
// Record has multiple images
|
||||
const images = record.images || [];
|
||||
|
||||
return (
|
||||
<div className="gallery">
|
||||
{images.map((filename, index) => (
|
||||
<img
|
||||
key={filename}
|
||||
src={pb.files.getURL(record, filename, { thumb: '200x200' })}
|
||||
onClick={() => openFullSize(record, filename)}
|
||||
loading="lazy"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function openFullSize(record, filename) {
|
||||
const fullUrl = pb.files.getURL(record, filename);
|
||||
window.open(fullUrl, '_blank');
|
||||
}
|
||||
```
|
||||
|
||||
**Handling file URLs in lists:**
|
||||
|
||||
```javascript
|
||||
// Efficiently generate URLs for list of records
|
||||
const posts = await pb.collection('posts').getList(1, 20, {
|
||||
expand: 'author'
|
||||
});
|
||||
|
||||
const postsWithUrls = posts.items.map(post => ({
|
||||
...post,
|
||||
thumbnailUrl: post.image
|
||||
? pb.files.getURL(post, post.image, { thumb: '300x200' })
|
||||
: null,
|
||||
authorAvatarUrl: post.expand?.author?.avatar
|
||||
? pb.files.getURL(post.expand.author, post.expand.author.avatar, { thumb: '40x40' })
|
||||
: null
|
||||
}));
|
||||
```
|
||||
|
||||
**Thumbnail format reference:**
|
||||
|
||||
| Format | Description |
|
||||
|--------|-------------|
|
||||
| `WxH` | Fit within dimensions |
|
||||
| `Wx0` | Fit width, auto height |
|
||||
| `0xH` | Auto width, fit height |
|
||||
| `WxHt` | Crop from top |
|
||||
| `WxHb` | Crop from bottom |
|
||||
| `WxHf` | Force exact dimensions |
|
||||
|
||||
**Performance and caching:**
|
||||
|
||||
```javascript
|
||||
// File URLs are effectively immutable (randomized filenames on upload).
|
||||
// This makes them ideal for aggressive caching.
|
||||
|
||||
// Configure Cache-Control via reverse proxy (Nginx/Caddy):
|
||||
// location /api/files/ { add_header Cache-Control "public, immutable, max-age=86400"; }
|
||||
|
||||
// Thumbnails are generated on first request and cached by PocketBase.
|
||||
// Pre-generate expected thumb sizes after upload to avoid cold-start latency:
|
||||
async function uploadWithThumbs(record, file) {
|
||||
const updated = await pb.collection('posts').update(record.id, { image: file });
|
||||
|
||||
// Pre-warm thumbnail cache by requesting expected sizes
|
||||
const sizes = ['100x100', '300x200', '800x600'];
|
||||
await Promise.all(sizes.map(size =>
|
||||
fetch(pb.files.getURL(updated, updated.image, { thumb: size }))
|
||||
));
|
||||
|
||||
return updated;
|
||||
}
|
||||
```
|
||||
|
||||
**S3 file serving optimization:**
|
||||
|
||||
When using S3 storage, PocketBase proxies all file requests through the server. For better performance with public files, serve directly from your S3 CDN:
|
||||
|
||||
```javascript
|
||||
// Default: All file requests proxy through PocketBase
|
||||
const url = pb.files.getURL(record, record.image);
|
||||
// -> https://myapp.com/api/files/COLLECTION/ID/filename.jpg (proxied)
|
||||
|
||||
// For public files with S3 + CDN, construct CDN URL directly:
|
||||
const cdnBase = 'https://cdn.myapp.com'; // Your S3 CDN domain
|
||||
const cdnUrl = `${cdnBase}/${record.collectionId}/${record.id}/${record.image}`;
|
||||
// Bypasses PocketBase, served directly from CDN edge
|
||||
|
||||
// NOTE: This only works for public files (no access token needed).
|
||||
// Protected files must go through PocketBase for token validation.
|
||||
```
|
||||
|
||||
Reference: [PocketBase Files](https://pocketbase.io/docs/files-handling/)
|
||||
|
||||
> **Note (JS SDK v0.26.7):** `pb.files.getURL()` now serializes query parameters the same way as the fetch methods — passing `null` or `undefined` as a query param value is silently skipped from the generated URL, so you no longer need to guard optional params before passing them to `getURL()`.
|
||||
|
||||
## 2. Upload Files Correctly
|
||||
|
||||
**Impact: MEDIUM (Reliable uploads with progress tracking and validation)**
|
||||
|
||||
File uploads can use plain objects or FormData. Handle large files properly with progress tracking and appropriate error handling.
|
||||
|
||||
**Incorrect (naive file upload):**
|
||||
|
||||
```javascript
|
||||
// Missing error handling
|
||||
async function uploadFile(file) {
|
||||
await pb.collection('documents').create({
|
||||
title: file.name,
|
||||
file: file
|
||||
});
|
||||
// No error handling, no progress feedback
|
||||
}
|
||||
|
||||
// Uploading without validation
|
||||
async function uploadAvatar(file) {
|
||||
await pb.collection('users').update(userId, {
|
||||
avatar: file // No size/type check - might fail server-side
|
||||
});
|
||||
}
|
||||
|
||||
// Base64 upload (inefficient)
|
||||
async function uploadImage(base64) {
|
||||
await pb.collection('images').create({
|
||||
image: base64 // Wrong! PocketBase expects File/Blob
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (proper file uploads):**
|
||||
|
||||
```javascript
|
||||
// Basic upload with object (auto-converts to FormData)
|
||||
async function uploadDocument(file, metadata) {
|
||||
try {
|
||||
const record = await pb.collection('documents').create({
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
file: file // File object from input
|
||||
});
|
||||
return record;
|
||||
} catch (error) {
|
||||
if (error.response?.data?.file) {
|
||||
throw new Error(`File error: ${error.response.data.file.message}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload multiple files
|
||||
async function uploadGallery(files, albumId) {
|
||||
const record = await pb.collection('albums').update(albumId, {
|
||||
images: files // Array of File objects
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
// FormData for more control
|
||||
async function uploadWithProgress(file, onProgress) {
|
||||
const formData = new FormData();
|
||||
formData.append('title', file.name);
|
||||
formData.append('file', file);
|
||||
|
||||
// Using fetch directly for progress (SDK doesn't expose progress)
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Upload failed')));
|
||||
|
||||
xhr.open('POST', `${pb.baseURL}/api/collections/documents/records`);
|
||||
xhr.setRequestHeader('Authorization', pb.authStore.token);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Client-side validation before upload
|
||||
function validateFile(file, options = {}) {
|
||||
const {
|
||||
maxSize = 10 * 1024 * 1024, // 10MB default
|
||||
allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf'],
|
||||
maxNameLength = 100
|
||||
} = options;
|
||||
|
||||
const errors = [];
|
||||
|
||||
if (file.size > maxSize) {
|
||||
errors.push(`File too large. Max: ${maxSize / 1024 / 1024}MB`);
|
||||
}
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push(`Invalid file type: ${file.type}`);
|
||||
}
|
||||
|
||||
if (file.name.length > maxNameLength) {
|
||||
errors.push(`Filename too long`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// Complete upload flow
|
||||
async function handleFileUpload(inputEvent) {
|
||||
const file = inputEvent.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate
|
||||
const validation = validateFile(file, {
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
allowedTypes: ['image/jpeg', 'image/png']
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
showError(validation.errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload with progress
|
||||
try {
|
||||
setUploading(true);
|
||||
const record = await uploadWithProgress(file, setProgress);
|
||||
showSuccess('Upload complete!');
|
||||
return record;
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Deleting files:**
|
||||
|
||||
```javascript
|
||||
// Remove specific file(s) from record
|
||||
await pb.collection('albums').update(albumId, {
|
||||
'images-': ['filename1.jpg', 'filename2.jpg'] // Remove these files
|
||||
});
|
||||
|
||||
// Clear all files
|
||||
await pb.collection('documents').update(docId, {
|
||||
file: null // Removes the file
|
||||
});
|
||||
```
|
||||
|
||||
Reference: [PocketBase File Upload](https://pocketbase.io/docs/files-handling/)
|
||||
|
||||
## 3. Validate File Uploads
|
||||
|
||||
**Impact: MEDIUM (Prevents invalid uploads, improves security and UX)**
|
||||
|
||||
Validate files on both client and server side. Client validation improves UX; server validation (via collection settings) enforces security.
|
||||
|
||||
**Incorrect (no validation):**
|
||||
|
||||
```javascript
|
||||
// Accepting any file without checks
|
||||
async function uploadFile(file) {
|
||||
return pb.collection('uploads').create({ file });
|
||||
// Could upload 1GB executable!
|
||||
}
|
||||
|
||||
// Only checking extension (easily bypassed)
|
||||
function validateFile(file) {
|
||||
const ext = file.name.split('.').pop();
|
||||
return ['jpg', 'png'].includes(ext);
|
||||
// User can rename virus.exe to virus.jpg
|
||||
}
|
||||
|
||||
// Client-only validation (can be bypassed)
|
||||
async function uploadAvatar(file) {
|
||||
if (file.size > 1024 * 1024) {
|
||||
throw new Error('Too large');
|
||||
}
|
||||
// Attacker can bypass this with dev tools
|
||||
return pb.collection('users').update(userId, { avatar: file });
|
||||
}
|
||||
```
|
||||
|
||||
**Correct (comprehensive validation):**
|
||||
|
||||
```javascript
|
||||
// 1. Configure server-side validation in collection settings
|
||||
// In Admin UI or via API:
|
||||
const collectionConfig = {
|
||||
schema: [
|
||||
{
|
||||
name: 'avatar',
|
||||
type: 'file',
|
||||
options: {
|
||||
maxSelect: 1, // Single file only
|
||||
maxSize: 5242880, // 5MB in bytes
|
||||
mimeTypes: [ // Allowed types
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp'
|
||||
],
|
||||
thumbs: ['100x100', '200x200'] // Auto-generate thumbnails
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'documents',
|
||||
type: 'file',
|
||||
options: {
|
||||
maxSelect: 10,
|
||||
maxSize: 10485760, // 10MB
|
||||
mimeTypes: [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 2. Client-side validation for better UX
|
||||
const FILE_CONSTRAINTS = {
|
||||
avatar: {
|
||||
maxSize: 5 * 1024 * 1024,
|
||||
allowedTypes: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
maxFiles: 1
|
||||
},
|
||||
documents: {
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
allowedTypes: ['application/pdf'],
|
||||
maxFiles: 10
|
||||
}
|
||||
};
|
||||
|
||||
function validateFiles(files, constraintKey) {
|
||||
const constraints = FILE_CONSTRAINTS[constraintKey];
|
||||
const errors = [];
|
||||
const validFiles = [];
|
||||
|
||||
if (files.length > constraints.maxFiles) {
|
||||
errors.push(`Maximum ${constraints.maxFiles} file(s) allowed`);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fileErrors = [];
|
||||
|
||||
// Check size
|
||||
if (file.size > constraints.maxSize) {
|
||||
const maxMB = constraints.maxSize / 1024 / 1024;
|
||||
fileErrors.push(`${file.name}: exceeds ${maxMB}MB limit`);
|
||||
}
|
||||
|
||||
// Check MIME type (more reliable than extension, but still spoofable)
|
||||
// Client-side file.type is based on extension, not file content.
|
||||
// Always enforce mimeTypes in PocketBase collection settings for server-side validation.
|
||||
if (!constraints.allowedTypes.includes(file.type)) {
|
||||
fileErrors.push(`${file.name}: invalid file type (${file.type || 'unknown'})`);
|
||||
}
|
||||
|
||||
// Check for suspicious patterns
|
||||
if (file.name.includes('..') || file.name.includes('/')) {
|
||||
fileErrors.push(`${file.name}: invalid filename`);
|
||||
}
|
||||
|
||||
if (fileErrors.length === 0) {
|
||||
validFiles.push(file);
|
||||
} else {
|
||||
errors.push(...fileErrors);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
validFiles
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Complete upload with validation
|
||||
async function handleAvatarUpload(inputElement) {
|
||||
const files = Array.from(inputElement.files);
|
||||
|
||||
// Client validation
|
||||
const validation = validateFiles(files, 'avatar');
|
||||
if (!validation.valid) {
|
||||
showErrors(validation.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Upload (server will also validate)
|
||||
try {
|
||||
const updated = await pb.collection('users').update(userId, {
|
||||
avatar: validation.validFiles[0]
|
||||
});
|
||||
showSuccess('Avatar updated!');
|
||||
return updated;
|
||||
} catch (error) {
|
||||
// Handle server validation errors
|
||||
if (error.response?.data?.avatar) {
|
||||
showError(error.response.data.avatar.message);
|
||||
} else {
|
||||
showError('Upload failed');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Image-specific validation
|
||||
async function validateImage(file, options = {}) {
|
||||
const { minWidth = 0, minHeight = 0, maxWidth = Infinity, maxHeight = Infinity } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const errors = [];
|
||||
|
||||
if (img.width < minWidth || img.height < minHeight) {
|
||||
errors.push(`Image must be at least ${minWidth}x${minHeight}px`);
|
||||
}
|
||||
if (img.width > maxWidth || img.height > maxHeight) {
|
||||
errors.push(`Image must be at most ${maxWidth}x${maxHeight}px`);
|
||||
}
|
||||
|
||||
resolve({ valid: errors.length === 0, errors, width: img.width, height: img.height });
|
||||
};
|
||||
img.onerror = () => resolve({ valid: false, errors: ['Invalid image file'] });
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [PocketBase Files Configuration](https://pocketbase.io/docs/files-handling/)
|
||||
|
||||
Reference in New Issue
Block a user