428 lines
10 KiB
Markdown
428 lines
10 KiB
Markdown
# File Upload Patterns
|
|
|
|
Streaming file handling with Bun.file and Bun.write.
|
|
|
|
## Basic Upload
|
|
|
|
```typescript
|
|
import { Hono } from 'hono';
|
|
import { HTTPException } from 'hono/http-exception';
|
|
|
|
const app = new Hono();
|
|
|
|
app.post('/upload', async (c) => {
|
|
const body = await c.req.parseBody();
|
|
const file = body.file as File;
|
|
|
|
if (!file) {
|
|
throw new HTTPException(400, { message: 'File is required' });
|
|
}
|
|
|
|
const filename = `${crypto.randomUUID()}-${file.name}`;
|
|
const filepath = `./uploads/${filename}`;
|
|
|
|
await Bun.write(filepath, file);
|
|
|
|
return c.json({
|
|
filename,
|
|
size: file.size,
|
|
type: file.type
|
|
}, 201);
|
|
});
|
|
```
|
|
|
|
## With Validation
|
|
|
|
```typescript
|
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
|
|
|
app.post('/upload/image', async (c) => {
|
|
const body = await c.req.parseBody();
|
|
const file = body.file as File;
|
|
|
|
if (!file) {
|
|
throw new HTTPException(400, { message: 'File is required' });
|
|
}
|
|
|
|
// Validate type
|
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
throw new HTTPException(400, {
|
|
message: `Invalid file type. Allowed: ${ALLOWED_TYPES.join(', ')}`
|
|
});
|
|
}
|
|
|
|
// Validate size
|
|
if (file.size > MAX_SIZE) {
|
|
throw new HTTPException(400, {
|
|
message: `File too large. Max size: ${MAX_SIZE / 1024 / 1024}MB`
|
|
});
|
|
}
|
|
|
|
// Generate safe filename
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
|
const filename = `${crypto.randomUUID()}.${ext}`;
|
|
const filepath = `./uploads/${filename}`;
|
|
|
|
await Bun.write(filepath, file);
|
|
|
|
return c.json({ filename, size: file.size, type: file.type }, 201);
|
|
});
|
|
```
|
|
|
|
## Multiple Files
|
|
|
|
```typescript
|
|
app.post('/upload/multiple', async (c) => {
|
|
const body = await c.req.parseBody({ all: true });
|
|
const files = body.files as File[];
|
|
|
|
if (!files || files.length === 0) {
|
|
throw new HTTPException(400, { message: 'At least one file is required' });
|
|
}
|
|
|
|
const results = [];
|
|
|
|
for (const file of files) {
|
|
if (file.size > MAX_SIZE) {
|
|
throw new HTTPException(400, {
|
|
message: `File ${file.name} exceeds max size`
|
|
});
|
|
}
|
|
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
|
const filename = `${crypto.randomUUID()}.${ext}`;
|
|
const filepath = `./uploads/${filename}`;
|
|
|
|
await Bun.write(filepath, file);
|
|
|
|
results.push({
|
|
original: file.name,
|
|
filename,
|
|
size: file.size,
|
|
type: file.type
|
|
});
|
|
}
|
|
|
|
return c.json({ files: results }, 201);
|
|
});
|
|
```
|
|
|
|
## Streaming Large Files
|
|
|
|
```typescript
|
|
app.post('/upload/large', async (c) => {
|
|
const body = await c.req.parseBody();
|
|
const file = body.file as File;
|
|
|
|
if (!file) {
|
|
throw new HTTPException(400, { message: 'File is required' });
|
|
}
|
|
|
|
const filename = `${crypto.randomUUID()}.bin`;
|
|
const filepath = `./uploads/${filename}`;
|
|
|
|
// Stream directly to disk — efficient for large files
|
|
await Bun.write(filepath, file.stream());
|
|
|
|
return c.json({ filename, size: file.size }, 201);
|
|
});
|
|
```
|
|
|
|
## Download Files
|
|
|
|
```typescript
|
|
app.get('/files/:filename', async (c) => {
|
|
const filename = c.req.param('filename');
|
|
|
|
// Prevent directory traversal
|
|
if (filename.includes('..') || filename.includes('/')) {
|
|
throw new HTTPException(400, { message: 'Invalid filename' });
|
|
}
|
|
|
|
const filepath = `./uploads/${filename}`;
|
|
const file = Bun.file(filepath);
|
|
|
|
if (!(await file.exists())) {
|
|
throw new HTTPException(404, { message: 'File not found' });
|
|
}
|
|
|
|
return c.body(file.stream(), {
|
|
headers: {
|
|
'Content-Type': file.type,
|
|
'Content-Length': file.size.toString(),
|
|
'Content-Disposition': `attachment; filename="${filename}"`
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
## Inline Display (Images)
|
|
|
|
```typescript
|
|
app.get('/images/:filename', async (c) => {
|
|
const filename = c.req.param('filename');
|
|
|
|
if (filename.includes('..') || filename.includes('/')) {
|
|
throw new HTTPException(400, { message: 'Invalid filename' });
|
|
}
|
|
|
|
const filepath = `./uploads/${filename}`;
|
|
const file = Bun.file(filepath);
|
|
|
|
if (!(await file.exists())) {
|
|
throw new HTTPException(404, { message: 'File not found' });
|
|
}
|
|
|
|
// Verify it's an image
|
|
if (!file.type.startsWith('image/')) {
|
|
throw new HTTPException(400, { message: 'Not an image' });
|
|
}
|
|
|
|
return c.body(file.stream(), {
|
|
headers: {
|
|
'Content-Type': file.type,
|
|
'Content-Length': file.size.toString(),
|
|
'Cache-Control': 'public, max-age=31536000' // 1 year cache
|
|
}
|
|
});
|
|
});
|
|
```
|
|
|
|
## With Database Metadata
|
|
|
|
```typescript
|
|
import { Database } from 'bun:sqlite';
|
|
|
|
type FileRecord = {
|
|
id: string;
|
|
filename: string;
|
|
originalName: string;
|
|
mimeType: string;
|
|
size: number;
|
|
uploadedAt: string;
|
|
userId: string;
|
|
};
|
|
|
|
class FileRepository {
|
|
constructor(private db: Database) {
|
|
this.db.run(`
|
|
CREATE TABLE IF NOT EXISTS files (
|
|
id TEXT PRIMARY KEY,
|
|
filename TEXT UNIQUE NOT NULL,
|
|
original_name TEXT NOT NULL,
|
|
mime_type TEXT NOT NULL,
|
|
size INTEGER NOT NULL,
|
|
uploaded_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
user_id TEXT NOT NULL
|
|
)
|
|
`);
|
|
}
|
|
|
|
create(data: Omit<FileRecord, 'id' | 'uploadedAt'>): FileRecord {
|
|
const id = crypto.randomUUID();
|
|
return this.db.prepare(`
|
|
INSERT INTO files (id, filename, original_name, mime_type, size, user_id)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
RETURNING *
|
|
`).get(id, data.filename, data.originalName, data.mimeType, data.size, data.userId) as FileRecord;
|
|
}
|
|
|
|
findById(id: string): FileRecord | null {
|
|
return this.db.prepare('SELECT * FROM files WHERE id = ?').get(id) as FileRecord | null;
|
|
}
|
|
|
|
findByUser(userId: string): FileRecord[] {
|
|
return this.db.prepare('SELECT * FROM files WHERE user_id = ? ORDER BY uploaded_at DESC').all(userId) as FileRecord[];
|
|
}
|
|
|
|
delete(id: string): boolean {
|
|
const result = this.db.prepare('DELETE FROM files WHERE id = ? RETURNING filename').get(id) as { filename: string } | null;
|
|
return result !== null;
|
|
}
|
|
}
|
|
|
|
// API with metadata
|
|
app.post('/files', async (c) => {
|
|
const userId = c.get('userId'); // From auth middleware
|
|
const body = await c.req.parseBody();
|
|
const file = body.file as File;
|
|
|
|
if (!file) {
|
|
throw new HTTPException(400, { message: 'File is required' });
|
|
}
|
|
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || 'bin';
|
|
const filename = `${crypto.randomUUID()}.${ext}`;
|
|
const filepath = `./uploads/${filename}`;
|
|
|
|
await Bun.write(filepath, file);
|
|
|
|
const files = c.get('files') as FileRepository;
|
|
const record = files.create({
|
|
filename,
|
|
originalName: file.name,
|
|
mimeType: file.type,
|
|
size: file.size,
|
|
userId
|
|
});
|
|
|
|
return c.json({ file: record }, 201);
|
|
});
|
|
|
|
app.delete('/files/:id', async (c) => {
|
|
const files = c.get('files') as FileRepository;
|
|
const record = files.findById(c.req.param('id'));
|
|
|
|
if (!record) {
|
|
throw new HTTPException(404, { message: 'File not found' });
|
|
}
|
|
|
|
// Delete from disk
|
|
const filepath = `./uploads/${record.filename}`;
|
|
const file = Bun.file(filepath);
|
|
if (await file.exists()) {
|
|
await Bun.write(filepath, ''); // Clear file
|
|
// Or use node:fs for actual deletion
|
|
}
|
|
|
|
// Delete from database
|
|
files.delete(record.id);
|
|
|
|
return c.json({ deleted: true });
|
|
});
|
|
```
|
|
|
|
## Image Processing
|
|
|
|
```typescript
|
|
import sharp from 'sharp'; // npm install sharp
|
|
|
|
const THUMBNAIL_SIZE = 200;
|
|
|
|
app.post('/upload/image-with-thumbnail', async (c) => {
|
|
const body = await c.req.parseBody();
|
|
const file = body.file as File;
|
|
|
|
if (!file) {
|
|
throw new HTTPException(400, { message: 'File is required' });
|
|
}
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
throw new HTTPException(400, { message: 'Must be an image' });
|
|
}
|
|
|
|
const id = crypto.randomUUID();
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
|
|
|
// Save original
|
|
const originalPath = `./uploads/${id}.${ext}`;
|
|
const buffer = await file.arrayBuffer();
|
|
await Bun.write(originalPath, buffer);
|
|
|
|
// Create thumbnail
|
|
const thumbnailPath = `./uploads/${id}-thumb.${ext}`;
|
|
await sharp(Buffer.from(buffer))
|
|
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'cover' })
|
|
.toFile(thumbnailPath);
|
|
|
|
return c.json({
|
|
id,
|
|
original: `${id}.${ext}`,
|
|
thumbnail: `${id}-thumb.${ext}`,
|
|
size: file.size,
|
|
type: file.type
|
|
}, 201);
|
|
});
|
|
```
|
|
|
|
## Presigned URLs (S3-style)
|
|
|
|
```typescript
|
|
import { sign, verify } from 'hono/jwt';
|
|
|
|
const SECRET = Bun.env.JWT_SECRET!;
|
|
const EXPIRY = 3600; // 1 hour
|
|
|
|
// Generate presigned URL
|
|
app.post('/files/:id/presign', async (c) => {
|
|
const fileId = c.req.param('id');
|
|
const files = c.get('files') as FileRepository;
|
|
|
|
const record = files.findById(fileId);
|
|
if (!record) {
|
|
throw new HTTPException(404, { message: 'File not found' });
|
|
}
|
|
|
|
const token = await sign({
|
|
fileId,
|
|
exp: Math.floor(Date.now() / 1000) + EXPIRY
|
|
}, SECRET);
|
|
|
|
const url = `${c.req.url.split('/files')[0]}/download?token=${token}`;
|
|
|
|
return c.json({ url, expiresIn: EXPIRY });
|
|
});
|
|
|
|
// Download with presigned URL
|
|
app.get('/download', async (c) => {
|
|
const token = c.req.query('token');
|
|
|
|
if (!token) {
|
|
throw new HTTPException(401, { message: 'Token required' });
|
|
}
|
|
|
|
try {
|
|
const payload = await verify(token, SECRET);
|
|
const fileId = payload.fileId as string;
|
|
|
|
const files = c.get('files') as FileRepository;
|
|
const record = files.findById(fileId);
|
|
|
|
if (!record) {
|
|
throw new HTTPException(404, { message: 'File not found' });
|
|
}
|
|
|
|
const filepath = `./uploads/${record.filename}`;
|
|
const file = Bun.file(filepath);
|
|
|
|
return c.body(file.stream(), {
|
|
headers: {
|
|
'Content-Type': record.mimeType,
|
|
'Content-Length': record.size.toString(),
|
|
'Content-Disposition': `attachment; filename="${record.originalName}"`
|
|
}
|
|
});
|
|
} catch {
|
|
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
|
}
|
|
});
|
|
```
|
|
|
|
## Cleanup Old Files
|
|
|
|
```typescript
|
|
import { readdir, unlink, stat } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
|
|
async function cleanupOldFiles(directory: string, maxAgeDays: number) {
|
|
const files = await readdir(directory);
|
|
const cutoff = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
|
|
|
for (const filename of files) {
|
|
const filepath = join(directory, filename);
|
|
const stats = await stat(filepath);
|
|
|
|
if (stats.mtimeMs < cutoff) {
|
|
await unlink(filepath);
|
|
console.log(`Deleted old file: ${filename}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run cleanup every hour
|
|
setInterval(() => {
|
|
cleanupOldFiles('./uploads', 30); // Delete files older than 30 days
|
|
}, 60 * 60 * 1000);
|
|
```
|