@everystack/storage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +222 -0
- package/package.json +64 -0
- package/src/adapters/filesystem.ts +93 -0
- package/src/adapters/index.ts +10 -0
- package/src/adapters/s3.ts +190 -0
- package/src/client/index.ts +258 -0
- package/src/handler/index.ts +213 -0
- package/src/index.ts +15 -0
- package/src/key-generator.ts +10 -0
- package/src/schema.ts +87 -0
- package/src/types.ts +48 -0
- package/src/validation.ts +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# @everystack/storage
|
|
2
|
+
|
|
3
|
+
File upload handler with pluggable storage backends (filesystem, S3), presigned URLs, validation, and upload metadata tracking via Drizzle ORM.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @everystack/storage drizzle-orm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
For S3 storage:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Entry Points
|
|
18
|
+
|
|
19
|
+
| Import | Description |
|
|
20
|
+
|--------|-------------|
|
|
21
|
+
| `@everystack/storage` | Handler, adapters, validation |
|
|
22
|
+
| `@everystack/storage/schema` | Drizzle `uploads` table |
|
|
23
|
+
| `@everystack/storage/client` | Client-side presigned URL helpers |
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { createStorageHandler, createObjectStorage } from '@everystack/storage';
|
|
29
|
+
import { db } from './db';
|
|
30
|
+
|
|
31
|
+
const storage = createObjectStorage({
|
|
32
|
+
type: 's3',
|
|
33
|
+
bucket: 'my-uploads',
|
|
34
|
+
region: 'us-east-1',
|
|
35
|
+
cdnUrl: 'https://cdn.example.com',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const handler = createStorageHandler({
|
|
39
|
+
db,
|
|
40
|
+
storage,
|
|
41
|
+
basePath: '/api/storage',
|
|
42
|
+
auth: {
|
|
43
|
+
verifyToken: async (token) => verifyJWT(token),
|
|
44
|
+
},
|
|
45
|
+
validation: {
|
|
46
|
+
maxSize: 10 * 1024 * 1024, // 10MB
|
|
47
|
+
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Mounting as an Expo Router API Route
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
// app/api/storage/[...path]+api.ts
|
|
56
|
+
export function GET(request: Request) { return handler(request); }
|
|
57
|
+
export function POST(request: Request) { return handler(request); }
|
|
58
|
+
export function DELETE(request: Request) { return handler(request); }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Handler Endpoints
|
|
62
|
+
|
|
63
|
+
| Method | Path | Description |
|
|
64
|
+
|--------|------|-------------|
|
|
65
|
+
| `POST` | `/presign` | Get a presigned upload URL |
|
|
66
|
+
| `POST` | `/upload` | Direct multipart file upload |
|
|
67
|
+
| `GET` | `/files/:key` | Download a file (or redirect to presigned URL) |
|
|
68
|
+
| `GET` | `/uploads` | List upload metadata (paginated) |
|
|
69
|
+
| `GET` | `/uploads/:id` | Get upload metadata |
|
|
70
|
+
| `DELETE` | `/uploads/:id` | Delete upload + file |
|
|
71
|
+
|
|
72
|
+
### Presigned Upload Flow
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
// 1. Client requests presigned URL
|
|
76
|
+
const res = await fetch('/api/storage/presign', {
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
79
|
+
body: JSON.stringify({ name: 'photo.jpg', type: 'image/jpeg', size: 1024000 }),
|
|
80
|
+
});
|
|
81
|
+
const { url, key } = await res.json();
|
|
82
|
+
|
|
83
|
+
// 2. Client uploads directly to S3
|
|
84
|
+
await fetch(url, { method: 'PUT', body: file, headers: { 'Content-Type': 'image/jpeg' } });
|
|
85
|
+
|
|
86
|
+
// 3. Use the `key` to reference the file
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Direct Upload
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
const formData = new FormData();
|
|
93
|
+
formData.append('file', file);
|
|
94
|
+
|
|
95
|
+
const res = await fetch('/api/storage/upload', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
98
|
+
body: formData,
|
|
99
|
+
});
|
|
100
|
+
const { id, key } = await res.json();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Handler Options
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
interface StorageHandlerOptions {
|
|
107
|
+
db: DrizzleDb;
|
|
108
|
+
storage: ObjectStorageAdapter;
|
|
109
|
+
basePath?: string;
|
|
110
|
+
bucket?: string; // Logical bucket name for metadata (default: 'default')
|
|
111
|
+
auth?: {
|
|
112
|
+
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
113
|
+
};
|
|
114
|
+
validation?: {
|
|
115
|
+
maxSize?: number; // Max file size in bytes
|
|
116
|
+
allowedTypes?: string[]; // MIME types (e.g., ['image/jpeg', 'image/png'])
|
|
117
|
+
allowedExtensions?: string[]; // File extensions (e.g., ['.jpg', '.png'])
|
|
118
|
+
};
|
|
119
|
+
keyGenerator?: (file: { name: string; type: string; userId?: string }) => string;
|
|
120
|
+
publishJob?: (type: string, payload: unknown) => Promise<string>;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Custom Key Generator
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
const handler = createStorageHandler({
|
|
128
|
+
db,
|
|
129
|
+
storage,
|
|
130
|
+
keyGenerator: ({ name, type, userId }) => {
|
|
131
|
+
const ext = name.split('.').pop();
|
|
132
|
+
return `users/${userId}/${crypto.randomUUID()}.${ext}`;
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Image Processing Integration
|
|
138
|
+
|
|
139
|
+
Wire storage uploads to `@everystack/jobs` for automatic image processing:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
import { createJobClient, PostgresJobAdapter } from '@everystack/jobs';
|
|
143
|
+
|
|
144
|
+
const jobClient = createJobClient(new PostgresJobAdapter(db));
|
|
145
|
+
|
|
146
|
+
const handler = createStorageHandler({
|
|
147
|
+
db,
|
|
148
|
+
storage,
|
|
149
|
+
publishJob: (type, payload) => jobClient.publish(type, payload),
|
|
150
|
+
});
|
|
151
|
+
// Image uploads automatically publish a 'process-image' job
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Storage Adapters
|
|
155
|
+
|
|
156
|
+
### Filesystem
|
|
157
|
+
|
|
158
|
+
```typescript
|
|
159
|
+
import { createObjectStorage } from '@everystack/storage';
|
|
160
|
+
|
|
161
|
+
const storage = createObjectStorage({
|
|
162
|
+
type: 'filesystem',
|
|
163
|
+
directory: './uploads',
|
|
164
|
+
baseUrl: 'http://localhost:3000/files',
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### S3
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
const storage = createObjectStorage({
|
|
172
|
+
type: 's3',
|
|
173
|
+
bucket: 'my-app-uploads',
|
|
174
|
+
region: 'us-east-1',
|
|
175
|
+
endpoint: 'https://s3.us-east-1.amazonaws.com', // Optional custom endpoint
|
|
176
|
+
cdnUrl: 'https://cdn.example.com', // Optional CDN prefix
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Custom Adapter
|
|
181
|
+
|
|
182
|
+
Implement the `ObjectStorageAdapter` interface:
|
|
183
|
+
|
|
184
|
+
```typescript
|
|
185
|
+
interface ObjectStorageAdapter {
|
|
186
|
+
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
187
|
+
get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
|
|
188
|
+
exists(key: string): Promise<boolean>;
|
|
189
|
+
list(prefix: string): Promise<string[]>;
|
|
190
|
+
delete(key: string): Promise<void>;
|
|
191
|
+
deleteMany?(keys: string[]): Promise<void>;
|
|
192
|
+
getPresignedUploadUrl?(key: string, contentType: string, expiresIn?: number): Promise<string>;
|
|
193
|
+
getPresignedDownloadUrl?(key: string, expiresIn?: number): Promise<string>;
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Schema
|
|
198
|
+
|
|
199
|
+
Add the uploads table to your Drizzle migrations:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
import { uploads } from '@everystack/storage/schema';
|
|
203
|
+
// Use in your Drizzle schema alongside your app tables
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The `uploads` table tracks: `id`, `key`, `bucket`, `contentType`, `size`, `originalName`, `metadata`, `userId`, `createdAt`.
|
|
207
|
+
|
|
208
|
+
## Peer Dependencies
|
|
209
|
+
|
|
210
|
+
| Package | Version | Required |
|
|
211
|
+
|---------|---------|----------|
|
|
212
|
+
| `drizzle-orm` | `>=0.30.0` | Yes |
|
|
213
|
+
| `@aws-sdk/client-s3` | `>=3.0.0` | For S3 adapter |
|
|
214
|
+
| `@aws-sdk/s3-request-presigner` | `>=3.0.0` | For S3 presigned URLs |
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@everystack/storage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "File uploads via S3 + CloudFront CDN with presigned URLs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./src/index.ts",
|
|
17
|
+
"default": "./src/index.ts"
|
|
18
|
+
},
|
|
19
|
+
"./schema": {
|
|
20
|
+
"types": "./src/schema.ts",
|
|
21
|
+
"default": "./src/schema.ts"
|
|
22
|
+
},
|
|
23
|
+
"./client": {
|
|
24
|
+
"types": "./src/client/index.ts",
|
|
25
|
+
"default": "./src/client/index.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"drizzle-orm": ">=0.30.0",
|
|
30
|
+
"@aws-sdk/client-s3": ">=3.0.0",
|
|
31
|
+
"@aws-sdk/s3-request-presigner": ">=3.0.0",
|
|
32
|
+
"react": ">=18.0.0",
|
|
33
|
+
"react-native": ">=0.72.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependenciesMeta": {
|
|
36
|
+
"@aws-sdk/client-s3": {
|
|
37
|
+
"optional": true
|
|
38
|
+
},
|
|
39
|
+
"@aws-sdk/s3-request-presigner": {
|
|
40
|
+
"optional": true
|
|
41
|
+
},
|
|
42
|
+
"react": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"react-native": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
51
|
+
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
|
52
|
+
"@types/jest": "^29.5.14",
|
|
53
|
+
"@types/node": "^22.0.0",
|
|
54
|
+
"drizzle-orm": "^0.41.0",
|
|
55
|
+
"jest": "^29.7.0",
|
|
56
|
+
"ts-jest": "^29.3.0",
|
|
57
|
+
"typescript": "^5.7.0"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
61
|
+
"build": "tsc --build",
|
|
62
|
+
"lint": "tsc --noEmit"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import type { ObjectStorageAdapter } from '../types';
|
|
4
|
+
|
|
5
|
+
const MIME_MAP: Record<string, string> = {
|
|
6
|
+
'.jpg': 'image/jpeg',
|
|
7
|
+
'.jpeg': 'image/jpeg',
|
|
8
|
+
'.png': 'image/png',
|
|
9
|
+
'.gif': 'image/gif',
|
|
10
|
+
'.webp': 'image/webp',
|
|
11
|
+
'.avif': 'image/avif',
|
|
12
|
+
'.svg': 'image/svg+xml',
|
|
13
|
+
'.pdf': 'application/pdf',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
'.txt': 'text/plain',
|
|
16
|
+
'.html': 'text/html',
|
|
17
|
+
'.css': 'text/css',
|
|
18
|
+
'.js': 'application/javascript',
|
|
19
|
+
'.mp4': 'video/mp4',
|
|
20
|
+
'.mp3': 'audio/mpeg',
|
|
21
|
+
'.zip': 'application/zip',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function guessContentType(key: string): string {
|
|
25
|
+
const ext = path.extname(key).toLowerCase();
|
|
26
|
+
return MIME_MAP[ext] || 'application/octet-stream';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class FilesystemObjectStorage implements ObjectStorageAdapter {
|
|
30
|
+
constructor(
|
|
31
|
+
private directory: string,
|
|
32
|
+
private baseUrl?: string
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
private resolve(key: string): string {
|
|
36
|
+
return path.join(this.directory, key);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void> {
|
|
40
|
+
const filePath = this.resolve(key);
|
|
41
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
42
|
+
await fs.writeFile(filePath, data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async get(key: string): Promise<{ data: Buffer; contentType: string } | null> {
|
|
46
|
+
try {
|
|
47
|
+
const filePath = this.resolve(key);
|
|
48
|
+
const data = await fs.readFile(filePath);
|
|
49
|
+
const contentType = guessContentType(key);
|
|
50
|
+
return { data, contentType };
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async exists(key: string): Promise<boolean> {
|
|
57
|
+
try {
|
|
58
|
+
await fs.access(this.resolve(key));
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async list(prefix: string): Promise<string[]> {
|
|
66
|
+
const dir = this.resolve(prefix);
|
|
67
|
+
try {
|
|
68
|
+
const entries = await fs.readdir(dir, { recursive: true });
|
|
69
|
+
return entries.map((e) => path.join(prefix, String(e)));
|
|
70
|
+
} catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async delete(key: string): Promise<void> {
|
|
76
|
+
try {
|
|
77
|
+
await fs.unlink(this.resolve(key));
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore if file doesn't exist
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string> {
|
|
84
|
+
// Filesystem doesn't support real presigned URLs — return handler upload path
|
|
85
|
+
const base = this.baseUrl || '';
|
|
86
|
+
return `${base}/upload?key=${encodeURIComponent(key)}&contentType=${encodeURIComponent(contentType)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getPresignedDownloadUrl(key: string, expiresIn?: number): Promise<string> {
|
|
90
|
+
const base = this.baseUrl || '';
|
|
91
|
+
return `${base}/files/${encodeURIComponent(key)}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ObjectStorageAdapter, ObjectStorageOptions } from '../types';
|
|
2
|
+
|
|
3
|
+
export function createObjectStorage(options: ObjectStorageOptions): ObjectStorageAdapter {
|
|
4
|
+
if (options.type === 'filesystem') {
|
|
5
|
+
const { FilesystemObjectStorage } = require('./filesystem') as typeof import('./filesystem');
|
|
6
|
+
return new FilesystemObjectStorage(options.directory, options.baseUrl);
|
|
7
|
+
}
|
|
8
|
+
const { S3ObjectStorage } = require('./s3') as typeof import('./s3');
|
|
9
|
+
return new S3ObjectStorage(options.bucket, options.region, options.endpoint, options.cdnUrl);
|
|
10
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { ObjectStorageAdapter } from '../types';
|
|
2
|
+
|
|
3
|
+
let S3ClientClass: any;
|
|
4
|
+
let PutObjectCommand: any;
|
|
5
|
+
let GetObjectCommand: any;
|
|
6
|
+
let DeleteObjectCommand: any;
|
|
7
|
+
let DeleteObjectsCommand: any;
|
|
8
|
+
let ListObjectsV2Command: any;
|
|
9
|
+
let HeadObjectCommand: any;
|
|
10
|
+
let getSignedUrl: any;
|
|
11
|
+
|
|
12
|
+
async function loadS3(): Promise<void> {
|
|
13
|
+
if (S3ClientClass) return;
|
|
14
|
+
try {
|
|
15
|
+
const s3Mod = await import('@aws-sdk/client-s3');
|
|
16
|
+
S3ClientClass = s3Mod.S3Client;
|
|
17
|
+
PutObjectCommand = s3Mod.PutObjectCommand;
|
|
18
|
+
GetObjectCommand = s3Mod.GetObjectCommand;
|
|
19
|
+
DeleteObjectCommand = s3Mod.DeleteObjectCommand;
|
|
20
|
+
DeleteObjectsCommand = s3Mod.DeleteObjectsCommand;
|
|
21
|
+
ListObjectsV2Command = s3Mod.ListObjectsV2Command;
|
|
22
|
+
HeadObjectCommand = s3Mod.HeadObjectCommand;
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'@aws-sdk/client-s3 is required for S3 storage. Install it: pnpm add @aws-sdk/client-s3'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadPresigner(): Promise<void> {
|
|
31
|
+
if (getSignedUrl) return;
|
|
32
|
+
try {
|
|
33
|
+
const mod = await import('@aws-sdk/s3-request-presigner');
|
|
34
|
+
getSignedUrl = mod.getSignedUrl;
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(
|
|
37
|
+
'@aws-sdk/s3-request-presigner is required for presigned URLs. Install it: pnpm add @aws-sdk/s3-request-presigner'
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class S3ObjectStorage implements ObjectStorageAdapter {
|
|
43
|
+
private client: any = null;
|
|
44
|
+
private bucket: string;
|
|
45
|
+
private region?: string;
|
|
46
|
+
private endpoint?: string;
|
|
47
|
+
private cdnUrl?: string;
|
|
48
|
+
|
|
49
|
+
constructor(bucket: string, region?: string, endpoint?: string, cdnUrl?: string) {
|
|
50
|
+
this.bucket = bucket;
|
|
51
|
+
this.region = region;
|
|
52
|
+
this.endpoint = endpoint;
|
|
53
|
+
this.cdnUrl = cdnUrl;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async getClient(): Promise<any> {
|
|
57
|
+
if (!this.client) {
|
|
58
|
+
await loadS3();
|
|
59
|
+
this.client = new S3ClientClass({
|
|
60
|
+
region: this.region,
|
|
61
|
+
...(this.endpoint && {
|
|
62
|
+
endpoint: this.endpoint,
|
|
63
|
+
forcePathStyle: true,
|
|
64
|
+
}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return this.client;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void> {
|
|
71
|
+
const client = await this.getClient();
|
|
72
|
+
await client.send(
|
|
73
|
+
new PutObjectCommand({
|
|
74
|
+
Bucket: this.bucket,
|
|
75
|
+
Key: key,
|
|
76
|
+
Body: data,
|
|
77
|
+
ContentType: contentType || 'application/octet-stream',
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async get(key: string): Promise<{ data: Buffer; contentType: string } | null> {
|
|
83
|
+
const client = await this.getClient();
|
|
84
|
+
try {
|
|
85
|
+
const response = await client.send(
|
|
86
|
+
new GetObjectCommand({ Bucket: this.bucket, Key: key })
|
|
87
|
+
);
|
|
88
|
+
const chunks: Uint8Array[] = [];
|
|
89
|
+
for await (const chunk of response.Body) {
|
|
90
|
+
chunks.push(chunk);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
data: Buffer.concat(chunks),
|
|
94
|
+
contentType: response.ContentType || 'application/octet-stream',
|
|
95
|
+
};
|
|
96
|
+
} catch (err: any) {
|
|
97
|
+
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async exists(key: string): Promise<boolean> {
|
|
105
|
+
const client = await this.getClient();
|
|
106
|
+
try {
|
|
107
|
+
await client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
108
|
+
return true;
|
|
109
|
+
} catch {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async list(prefix: string): Promise<string[]> {
|
|
115
|
+
const client = await this.getClient();
|
|
116
|
+
const keys: string[] = [];
|
|
117
|
+
let continuationToken: string | undefined;
|
|
118
|
+
|
|
119
|
+
do {
|
|
120
|
+
const response = await client.send(
|
|
121
|
+
new ListObjectsV2Command({
|
|
122
|
+
Bucket: this.bucket,
|
|
123
|
+
Prefix: prefix,
|
|
124
|
+
ContinuationToken: continuationToken,
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
if (response.Contents) {
|
|
129
|
+
for (const obj of response.Contents) {
|
|
130
|
+
if (obj.Key) keys.push(obj.Key);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
|
|
135
|
+
} while (continuationToken);
|
|
136
|
+
|
|
137
|
+
return keys;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async delete(key: string): Promise<void> {
|
|
141
|
+
const client = await this.getClient();
|
|
142
|
+
await client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async deleteMany(keys: string[]): Promise<void> {
|
|
146
|
+
if (keys.length === 0) return;
|
|
147
|
+
const client = await this.getClient();
|
|
148
|
+
|
|
149
|
+
// S3 DeleteObjects supports up to 1000 keys per request
|
|
150
|
+
for (let i = 0; i < keys.length; i += 1000) {
|
|
151
|
+
const batch = keys.slice(i, i + 1000);
|
|
152
|
+
await client.send(
|
|
153
|
+
new DeleteObjectsCommand({
|
|
154
|
+
Bucket: this.bucket,
|
|
155
|
+
Delete: {
|
|
156
|
+
Objects: batch.map((Key: string) => ({ Key })),
|
|
157
|
+
Quiet: true,
|
|
158
|
+
},
|
|
159
|
+
})
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getPresignedUploadUrl(key: string, contentType: string, expiresIn: number = 3600): Promise<string> {
|
|
165
|
+
const client = await this.getClient();
|
|
166
|
+
await loadPresigner();
|
|
167
|
+
return getSignedUrl(
|
|
168
|
+
client,
|
|
169
|
+
new PutObjectCommand({
|
|
170
|
+
Bucket: this.bucket,
|
|
171
|
+
Key: key,
|
|
172
|
+
ContentType: contentType,
|
|
173
|
+
}),
|
|
174
|
+
{ expiresIn }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async getPresignedDownloadUrl(key: string, expiresIn: number = 3600): Promise<string> {
|
|
179
|
+
if (this.cdnUrl) {
|
|
180
|
+
return `${this.cdnUrl}/${key}`;
|
|
181
|
+
}
|
|
182
|
+
const client = await this.getClient();
|
|
183
|
+
await loadPresigner();
|
|
184
|
+
return getSignedUrl(
|
|
185
|
+
client,
|
|
186
|
+
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
187
|
+
{ expiresIn }
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { UploadMetadata } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface StorageClientOptions {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
getToken?: () => string | null | Promise<string | null>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UploadProgress {
|
|
9
|
+
loaded: number;
|
|
10
|
+
total: number;
|
|
11
|
+
percentage: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface UploadResult {
|
|
15
|
+
id: string;
|
|
16
|
+
key: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useUpload(clientOptions: StorageClientOptions) {
|
|
20
|
+
let React: any;
|
|
21
|
+
try {
|
|
22
|
+
React = require('react');
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error('react is required for useUpload hook');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { useState, useCallback, useRef } = React;
|
|
28
|
+
const [progress, setProgress] = useState(null as UploadProgress | null);
|
|
29
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
30
|
+
const [error, setError] = useState(null as Error | null);
|
|
31
|
+
const [result, setResult] = useState(null as UploadResult | null);
|
|
32
|
+
const abortRef = useRef(null as (() => void) | null);
|
|
33
|
+
|
|
34
|
+
const cancel = useCallback(() => {
|
|
35
|
+
abortRef.current?.();
|
|
36
|
+
abortRef.current = null;
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const upload = useCallback(async (file: File) => {
|
|
40
|
+
setIsUploading(true);
|
|
41
|
+
setError(null);
|
|
42
|
+
setProgress(null);
|
|
43
|
+
abortRef.current = null;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const headers: Record<string, string> = {};
|
|
47
|
+
if (clientOptions.getToken) {
|
|
48
|
+
const token = await clientOptions.getToken();
|
|
49
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Try presigned URL first
|
|
53
|
+
const presignRes = await fetch(`${clientOptions.baseUrl}/presign`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (presignRes.ok) {
|
|
60
|
+
const { url, key } = await presignRes.json() as { url: string; key: string };
|
|
61
|
+
|
|
62
|
+
// Upload via XHR for real progress tracking
|
|
63
|
+
await new Promise<void>((resolve, reject) => {
|
|
64
|
+
const xhr = new XMLHttpRequest();
|
|
65
|
+
xhr.upload.onprogress = (e: ProgressEvent) => {
|
|
66
|
+
if (e.lengthComputable) {
|
|
67
|
+
setProgress({
|
|
68
|
+
loaded: e.loaded,
|
|
69
|
+
total: e.total,
|
|
70
|
+
percentage: Math.round((e.loaded / e.total) * 100),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
xhr.onload = () => {
|
|
75
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
76
|
+
resolve();
|
|
77
|
+
} else {
|
|
78
|
+
reject(new Error(`Upload failed: ${xhr.status}`));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
xhr.onerror = () => reject(new Error('Upload failed'));
|
|
82
|
+
xhr.onabort = () => reject(new Error('Upload cancelled'));
|
|
83
|
+
xhr.open('PUT', url);
|
|
84
|
+
xhr.setRequestHeader('content-type', file.type);
|
|
85
|
+
abortRef.current = () => xhr.abort();
|
|
86
|
+
xhr.send(file);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
setProgress({ loaded: file.size, total: file.size, percentage: 100 });
|
|
90
|
+
const uploadResult = { id: key, key };
|
|
91
|
+
setResult(uploadResult);
|
|
92
|
+
return uploadResult;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback to multipart upload
|
|
96
|
+
const formData = new FormData();
|
|
97
|
+
formData.append('file', file);
|
|
98
|
+
|
|
99
|
+
const uploadRes = await fetch(`${clientOptions.baseUrl}/upload`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers,
|
|
102
|
+
body: formData,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!uploadRes.ok) {
|
|
106
|
+
const err = await uploadRes.json().catch(() => ({ error: 'Upload failed' }));
|
|
107
|
+
throw new Error((err as any).error || 'Upload failed');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const uploadResult = await uploadRes.json() as UploadResult;
|
|
111
|
+
setProgress({ loaded: file.size, total: file.size, percentage: 100 });
|
|
112
|
+
setResult(uploadResult);
|
|
113
|
+
return uploadResult;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
116
|
+
setError(error);
|
|
117
|
+
throw error;
|
|
118
|
+
} finally {
|
|
119
|
+
setIsUploading(false);
|
|
120
|
+
abortRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
}, [clientOptions.baseUrl, clientOptions.getToken]);
|
|
123
|
+
|
|
124
|
+
return { upload, cancel, progress, isUploading, error, result };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function usePresignedUrl(clientOptions: StorageClientOptions, key: string | null) {
|
|
128
|
+
let React: any;
|
|
129
|
+
try {
|
|
130
|
+
React = require('react');
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error('react is required for usePresignedUrl hook');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const { useState, useEffect } = React;
|
|
136
|
+
const [url, setUrl] = useState(null as string | null);
|
|
137
|
+
const [loading, setLoading] = useState(false);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
if (!key) return;
|
|
141
|
+
let cancelled = false;
|
|
142
|
+
|
|
143
|
+
async function load() {
|
|
144
|
+
setLoading(true);
|
|
145
|
+
try {
|
|
146
|
+
const fileUrl = `${clientOptions.baseUrl}/files/${encodeURIComponent(key!)}`;
|
|
147
|
+
if (!cancelled) {
|
|
148
|
+
setUrl(fileUrl);
|
|
149
|
+
setLoading(false);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
if (!cancelled) setLoading(false);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
load();
|
|
157
|
+
return () => { cancelled = true; };
|
|
158
|
+
}, [clientOptions.baseUrl, key]);
|
|
159
|
+
|
|
160
|
+
return { url, loading };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function useUploads(clientOptions: StorageClientOptions, options?: { limit?: number; offset?: number }) {
|
|
164
|
+
let React: any;
|
|
165
|
+
try {
|
|
166
|
+
React = require('react');
|
|
167
|
+
} catch {
|
|
168
|
+
throw new Error('react is required for useUploads hook');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { useState, useEffect } = React;
|
|
172
|
+
const [data, setData] = useState([] as UploadMetadata[]);
|
|
173
|
+
const [loading, setLoading] = useState(true);
|
|
174
|
+
const [error, setError] = useState(null as Error | null);
|
|
175
|
+
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
let cancelled = false;
|
|
178
|
+
|
|
179
|
+
async function load() {
|
|
180
|
+
try {
|
|
181
|
+
const params = new URLSearchParams();
|
|
182
|
+
if (options?.limit) params.set('limit', String(options.limit));
|
|
183
|
+
if (options?.offset) params.set('offset', String(options.offset));
|
|
184
|
+
|
|
185
|
+
const query = params.toString();
|
|
186
|
+
const url = `${clientOptions.baseUrl}/uploads${query ? `?${query}` : ''}`;
|
|
187
|
+
|
|
188
|
+
const headers: Record<string, string> = {};
|
|
189
|
+
if (clientOptions.getToken) {
|
|
190
|
+
const token = await clientOptions.getToken();
|
|
191
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const res = await fetch(url, { headers });
|
|
195
|
+
if (!res.ok) throw new Error('Failed to fetch uploads');
|
|
196
|
+
|
|
197
|
+
const result = await res.json();
|
|
198
|
+
if (!cancelled) {
|
|
199
|
+
setData(result as UploadMetadata[]);
|
|
200
|
+
setLoading(false);
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (!cancelled) {
|
|
204
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
205
|
+
setLoading(false);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
load();
|
|
211
|
+
return () => { cancelled = true; };
|
|
212
|
+
}, [clientOptions.baseUrl, options?.limit, options?.offset]);
|
|
213
|
+
|
|
214
|
+
return { data, loading, error };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Image URL builder — constructs CloudFront image Lambda URLs with transform params.
|
|
218
|
+
// Mirrors the contract from the Image Lambda's parseParams function.
|
|
219
|
+
export interface ImageUrlOptions {
|
|
220
|
+
width?: number;
|
|
221
|
+
height?: number;
|
|
222
|
+
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
|
|
223
|
+
format?: 'webp' | 'avif' | 'jpeg' | 'png';
|
|
224
|
+
quality?: number;
|
|
225
|
+
dpr?: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function useImageUrl(baseUrl: string, key: string | null, options?: ImageUrlOptions): string | null {
|
|
229
|
+
let React: any;
|
|
230
|
+
try {
|
|
231
|
+
React = require('react');
|
|
232
|
+
} catch {
|
|
233
|
+
throw new Error('react is required for useImageUrl hook');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const { useMemo } = React;
|
|
237
|
+
|
|
238
|
+
return useMemo(() => {
|
|
239
|
+
if (!key) return null;
|
|
240
|
+
|
|
241
|
+
const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
242
|
+
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
|
|
243
|
+
const url = `${base}/media/${encodedKey}`;
|
|
244
|
+
|
|
245
|
+
if (!options) return url;
|
|
246
|
+
|
|
247
|
+
const params = new URLSearchParams();
|
|
248
|
+
if (options.width != null) params.set('w', String(Math.min(Math.max(1, Math.round(options.width)), 4096)));
|
|
249
|
+
if (options.height != null) params.set('h', String(Math.min(Math.max(1, Math.round(options.height)), 4096)));
|
|
250
|
+
if (options.fit) params.set('fit', options.fit);
|
|
251
|
+
if (options.format) params.set('fm', options.format);
|
|
252
|
+
if (options.quality != null) params.set('q', String(Math.min(Math.max(1, Math.round(options.quality)), 100)));
|
|
253
|
+
if (options.dpr != null) params.set('dpr', String(Math.min(Math.max(1, options.dpr), 3)));
|
|
254
|
+
|
|
255
|
+
const qs = params.toString();
|
|
256
|
+
return qs ? `${url}?${qs}` : url;
|
|
257
|
+
}, [baseUrl, key, options?.width, options?.height, options?.fit, options?.format, options?.quality, options?.dpr]);
|
|
258
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
import { blobs } from '../schema';
|
|
3
|
+
import { validateUpload } from '../validation';
|
|
4
|
+
import { defaultKeyGenerator } from '../key-generator';
|
|
5
|
+
import type { StorageHandlerOptions } from '../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute SHA-256 hash of a buffer. Returns hex string.
|
|
9
|
+
*/
|
|
10
|
+
async function computeSha256(data: ArrayBuffer | Buffer | Uint8Array): Promise<string> {
|
|
11
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(data as ArrayBuffer));
|
|
12
|
+
return Array.from(new Uint8Array(hashBuffer))
|
|
13
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
14
|
+
.join('');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createStorageHandler(options: StorageHandlerOptions): (request: Request) => Promise<Response> {
|
|
18
|
+
const basePath = options.basePath || '';
|
|
19
|
+
const { db, storage, auth, validation, publishJob } = options;
|
|
20
|
+
const keyGenerator = options.keyGenerator || defaultKeyGenerator;
|
|
21
|
+
const bucket = options.bucket || 'default';
|
|
22
|
+
|
|
23
|
+
function json(data: unknown, status: number = 200): Response {
|
|
24
|
+
return new Response(JSON.stringify(data), {
|
|
25
|
+
status,
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function verifyAuth(request: Request): Promise<Record<string, unknown> | null> {
|
|
31
|
+
if (!auth) return {};
|
|
32
|
+
const header = request.headers.get('authorization');
|
|
33
|
+
if (!header?.startsWith('Bearer ')) return null;
|
|
34
|
+
return auth.verifyToken(header.slice(7));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return async (request: Request): Promise<Response> => {
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
let pathname = url.pathname;
|
|
40
|
+
|
|
41
|
+
if (basePath && pathname.startsWith(basePath)) {
|
|
42
|
+
pathname = pathname.slice(basePath.length) || '/';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// POST /presign — generate presigned upload URL
|
|
46
|
+
if (request.method === 'POST' && pathname === '/presign') {
|
|
47
|
+
const user = await verifyAuth(request);
|
|
48
|
+
if (!user) return json({ error: 'Unauthorized' }, 401);
|
|
49
|
+
|
|
50
|
+
const body = (await request.json()) as { name: string; type: string; size: number };
|
|
51
|
+
if (!body.name || !body.type) {
|
|
52
|
+
return json({ error: 'Missing name or type' }, 400);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (validation) {
|
|
56
|
+
const result = validateUpload(body, validation);
|
|
57
|
+
if (!result.valid) return json({ error: result.error }, 400);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const key = keyGenerator({
|
|
61
|
+
name: body.name,
|
|
62
|
+
type: body.type,
|
|
63
|
+
userId: user.sub as string | undefined,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (storage.getPresignedUploadUrl) {
|
|
67
|
+
const uploadUrl = await storage.getPresignedUploadUrl(key, body.type);
|
|
68
|
+
return json({ url: uploadUrl, key });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fallback: return direct upload URL
|
|
72
|
+
const uploadUrl = `${basePath}/upload?key=${encodeURIComponent(key)}`;
|
|
73
|
+
return json({ url: uploadUrl, key });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// POST /upload — direct multipart upload
|
|
77
|
+
if (request.method === 'POST' && pathname === '/upload') {
|
|
78
|
+
const user = await verifyAuth(request);
|
|
79
|
+
if (!user) return json({ error: 'Unauthorized' }, 401);
|
|
80
|
+
|
|
81
|
+
let formData: FormData;
|
|
82
|
+
try {
|
|
83
|
+
formData = await request.formData();
|
|
84
|
+
} catch {
|
|
85
|
+
return json({ error: 'Invalid multipart form data' }, 400);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const file = formData.get('file') as File | null;
|
|
89
|
+
if (!file) return json({ error: 'Missing file field' }, 400);
|
|
90
|
+
|
|
91
|
+
if (validation) {
|
|
92
|
+
const result = validateUpload(
|
|
93
|
+
{ name: file.name, type: file.type, size: file.size },
|
|
94
|
+
validation
|
|
95
|
+
);
|
|
96
|
+
if (!result.valid) return json({ error: result.error }, 400);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const key = keyGenerator({
|
|
100
|
+
name: file.name,
|
|
101
|
+
type: file.type,
|
|
102
|
+
userId: user.sub as string | undefined,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
106
|
+
await storage.put(key, buffer, file.type);
|
|
107
|
+
|
|
108
|
+
// Compute content hash for dedup
|
|
109
|
+
const sha256 = await computeSha256(buffer);
|
|
110
|
+
|
|
111
|
+
// Write metadata to DB
|
|
112
|
+
const result = await db
|
|
113
|
+
.insert(blobs)
|
|
114
|
+
.values({
|
|
115
|
+
type: 'original',
|
|
116
|
+
sha256,
|
|
117
|
+
s3Key: key,
|
|
118
|
+
bucket,
|
|
119
|
+
sizeBytes: file.size,
|
|
120
|
+
contentType: file.type,
|
|
121
|
+
metadata: {
|
|
122
|
+
originalName: file.name,
|
|
123
|
+
userId: (user.sub as string) || null,
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
.returning({ id: blobs.id });
|
|
127
|
+
|
|
128
|
+
const uploadId = result[0].id;
|
|
129
|
+
|
|
130
|
+
// Publish image processing job if it's an image
|
|
131
|
+
if (publishJob && file.type.startsWith('image/')) {
|
|
132
|
+
await publishJob('process-image', { key, uploadId });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return json({ id: uploadId, key }, 201);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// GET /files/:key — serve file
|
|
139
|
+
const filesMatch = pathname.match(/^\/files\/(.+)$/);
|
|
140
|
+
if (request.method === 'GET' && filesMatch) {
|
|
141
|
+
const key = decodeURIComponent(filesMatch[1]);
|
|
142
|
+
|
|
143
|
+
// If adapter supports presigned download URLs, redirect
|
|
144
|
+
if (storage.getPresignedDownloadUrl) {
|
|
145
|
+
const downloadUrl = await storage.getPresignedDownloadUrl(key);
|
|
146
|
+
return Response.redirect(downloadUrl, 302);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const file = await storage.get(key);
|
|
150
|
+
if (!file) return json({ error: 'File not found' }, 404);
|
|
151
|
+
|
|
152
|
+
return new Response(new Uint8Array(file.data), {
|
|
153
|
+
headers: {
|
|
154
|
+
'content-type': file.contentType,
|
|
155
|
+
'cache-control': 'public, max-age=86400',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// GET /uploads — list upload metadata
|
|
161
|
+
if (request.method === 'GET' && pathname === '/uploads') {
|
|
162
|
+
const user = await verifyAuth(request);
|
|
163
|
+
if (!user) return json({ error: 'Unauthorized' }, 401);
|
|
164
|
+
|
|
165
|
+
const limit = url.searchParams.has('limit') ? parseInt(url.searchParams.get('limit')!, 10) : 50;
|
|
166
|
+
const offset = url.searchParams.has('offset') ? parseInt(url.searchParams.get('offset')!, 10) : 0;
|
|
167
|
+
|
|
168
|
+
let query = db.select().from(blobs);
|
|
169
|
+
|
|
170
|
+
if (user.sub) {
|
|
171
|
+
query = query.where(sql`${blobs.metadata}->>'userId' = ${user.sub}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
query = query.orderBy(sql`${blobs.createdAt} DESC`).limit(limit).offset(offset);
|
|
175
|
+
|
|
176
|
+
const result = await query;
|
|
177
|
+
return json(result);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// GET /uploads/:id — get upload metadata
|
|
181
|
+
const uploadMatch = pathname.match(/^\/uploads\/([^/]+)$/);
|
|
182
|
+
if (request.method === 'GET' && uploadMatch) {
|
|
183
|
+
const result = await db
|
|
184
|
+
.select()
|
|
185
|
+
.from(blobs)
|
|
186
|
+
.where(sql`${blobs.id} = ${uploadMatch[1]}`);
|
|
187
|
+
|
|
188
|
+
if (!result[0]) return json({ error: 'Upload not found' }, 404);
|
|
189
|
+
return json(result[0]);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// DELETE /uploads/:id — delete upload + file
|
|
193
|
+
const deleteMatch = pathname.match(/^\/uploads\/([^/]+)$/);
|
|
194
|
+
if (request.method === 'DELETE' && deleteMatch) {
|
|
195
|
+
const user = await verifyAuth(request);
|
|
196
|
+
if (!user) return json({ error: 'Unauthorized' }, 401);
|
|
197
|
+
|
|
198
|
+
const result = await db
|
|
199
|
+
.select()
|
|
200
|
+
.from(blobs)
|
|
201
|
+
.where(sql`${blobs.id} = ${deleteMatch[1]}`);
|
|
202
|
+
|
|
203
|
+
if (!result[0]) return json({ error: 'Upload not found' }, 404);
|
|
204
|
+
|
|
205
|
+
await storage.delete(result[0].s3Key!);
|
|
206
|
+
await db.delete(blobs).where(sql`${blobs.id} = ${deleteMatch[1]}`);
|
|
207
|
+
|
|
208
|
+
return json({ ok: true });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return json({ error: 'Not found' }, 404);
|
|
212
|
+
};
|
|
213
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { createStorageHandler } from './handler/index';
|
|
2
|
+
export { createObjectStorage } from './adapters/index';
|
|
3
|
+
export { FilesystemObjectStorage } from './adapters/filesystem';
|
|
4
|
+
export { S3ObjectStorage } from './adapters/s3';
|
|
5
|
+
export { validateUpload } from './validation';
|
|
6
|
+
export { defaultKeyGenerator } from './key-generator';
|
|
7
|
+
export type {
|
|
8
|
+
ObjectStorageAdapter,
|
|
9
|
+
ObjectStorageOptions,
|
|
10
|
+
StorageHandlerOptions,
|
|
11
|
+
UploadMetadata,
|
|
12
|
+
UploadValidation,
|
|
13
|
+
StorageType,
|
|
14
|
+
} from './types';
|
|
15
|
+
export type { ValidationResult } from './validation';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function defaultKeyGenerator(file: { name: string; type: string; userId?: string }): string {
|
|
2
|
+
const now = new Date();
|
|
3
|
+
const year = now.getFullYear();
|
|
4
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
5
|
+
const uuid = crypto.randomUUID();
|
|
6
|
+
const parts = file.name.split('.');
|
|
7
|
+
const ext = parts.length > 1 ? parts.pop()!.toLowerCase() : 'bin';
|
|
8
|
+
|
|
9
|
+
return `uploads/${year}/${month}/${uuid}.${ext}`;
|
|
10
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage schema — everyschema dist.blobs
|
|
3
|
+
*
|
|
4
|
+
* Content-addressed storage — in-DB (bytea) or S3-backed.
|
|
5
|
+
* Replaces the old `uploads` table.
|
|
6
|
+
*
|
|
7
|
+
* Type values for everystack:
|
|
8
|
+
* 'original' — user-uploaded content (S3-backed)
|
|
9
|
+
* 'thumbnail' — derived image variant (parentId → original)
|
|
10
|
+
* 'preview' — derived preview image
|
|
11
|
+
* 'webp' — derived WebP variant
|
|
12
|
+
*
|
|
13
|
+
* @see everyschema — canonical schema definitions
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
pgSchema,
|
|
18
|
+
text,
|
|
19
|
+
timestamp,
|
|
20
|
+
uuid,
|
|
21
|
+
bigint,
|
|
22
|
+
index,
|
|
23
|
+
uniqueIndex,
|
|
24
|
+
jsonb,
|
|
25
|
+
customType,
|
|
26
|
+
} from 'drizzle-orm/pg-core';
|
|
27
|
+
|
|
28
|
+
const distSchema = pgSchema('dist');
|
|
29
|
+
|
|
30
|
+
const bytea = customType<{ data: Buffer; driverValue: Buffer }>({
|
|
31
|
+
dataType() {
|
|
32
|
+
return 'bytea';
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// BLOBS - Raw binary storage, deduped by sha256
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Content-addressed storage — in-DB or S3-backed.
|
|
42
|
+
*
|
|
43
|
+
* Storage modes:
|
|
44
|
+
* In-DB: data (bytea) populated, s3Key/bucket NULL
|
|
45
|
+
* S3: s3Key + bucket populated, data NULL
|
|
46
|
+
*
|
|
47
|
+
* Derived content uses parentId:
|
|
48
|
+
* original → thumbnail, preview, webp, etc.
|
|
49
|
+
*
|
|
50
|
+
* metadata stores intrinsic content properties:
|
|
51
|
+
* Images: { width, height, format, originalName, userId }
|
|
52
|
+
* Video/audio: { codec, duration, bitrate }
|
|
53
|
+
*/
|
|
54
|
+
export const blobs = distSchema.table(
|
|
55
|
+
'blobs',
|
|
56
|
+
{
|
|
57
|
+
id: uuid('id').primaryKey().defaultRandom(),
|
|
58
|
+
repositoryId: uuid('repository_id'),
|
|
59
|
+
type: text('type').notNull(), // 'original' | 'thumbnail' | 'preview' | 'webp' | etc.
|
|
60
|
+
sha256: text('sha256').notNull().unique(),
|
|
61
|
+
|
|
62
|
+
// Storage: in-DB (bytea) or S3-backed (s3Key + bucket)
|
|
63
|
+
data: bytea('data'),
|
|
64
|
+
s3Key: text('s3_key'),
|
|
65
|
+
bucket: text('bucket'),
|
|
66
|
+
|
|
67
|
+
sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
|
|
68
|
+
contentType: text('content_type'),
|
|
69
|
+
|
|
70
|
+
// Derived content (thumbnail → original, webp → original, etc.)
|
|
71
|
+
parentId: uuid('parent_id'),
|
|
72
|
+
|
|
73
|
+
// Intrinsic content metadata
|
|
74
|
+
metadata: jsonb('metadata'),
|
|
75
|
+
|
|
76
|
+
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
77
|
+
},
|
|
78
|
+
(table) => ({
|
|
79
|
+
typeIdx: index('blobs_type_idx').on(table.type),
|
|
80
|
+
sha256Idx: uniqueIndex('blobs_sha256_idx').on(table.sha256),
|
|
81
|
+
contentTypeIdx: index('blobs_content_type_idx').on(table.contentType),
|
|
82
|
+
createdAtIdx: index('blobs_created_at_idx').on(table.createdAt),
|
|
83
|
+
repoIdx: index('blobs_repo_idx').on(table.repositoryId),
|
|
84
|
+
parentIdx: index('blobs_parent_idx').on(table.parentId),
|
|
85
|
+
s3KeyIdx: index('blobs_s3_key_idx').on(table.s3Key),
|
|
86
|
+
})
|
|
87
|
+
);
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export interface ObjectStorageAdapter {
|
|
2
|
+
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
|
3
|
+
get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
|
|
4
|
+
exists(key: string): Promise<boolean>;
|
|
5
|
+
list(prefix: string): Promise<string[]>;
|
|
6
|
+
delete(key: string): Promise<void>;
|
|
7
|
+
/** Batch delete up to 1000 keys per call. Falls back to sequential delete if not implemented. */
|
|
8
|
+
deleteMany?(keys: string[]): Promise<void>;
|
|
9
|
+
getPresignedUploadUrl?(key: string, contentType: string, expiresIn?: number): Promise<string>;
|
|
10
|
+
getPresignedDownloadUrl?(key: string, expiresIn?: number): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UploadMetadata {
|
|
14
|
+
id: string;
|
|
15
|
+
key: string;
|
|
16
|
+
bucket: string;
|
|
17
|
+
contentType: string;
|
|
18
|
+
size: number;
|
|
19
|
+
originalName: string | null;
|
|
20
|
+
metadata: Record<string, unknown> | null;
|
|
21
|
+
userId: string | null;
|
|
22
|
+
createdAt: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UploadValidation {
|
|
26
|
+
maxSize?: number;
|
|
27
|
+
allowedTypes?: string[];
|
|
28
|
+
allowedExtensions?: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type StorageType = 'filesystem' | 's3';
|
|
32
|
+
|
|
33
|
+
export type ObjectStorageOptions =
|
|
34
|
+
| { type: 'filesystem'; directory: string; baseUrl?: string }
|
|
35
|
+
| { type: 's3'; bucket: string; region?: string; endpoint?: string; cdnUrl?: string };
|
|
36
|
+
|
|
37
|
+
export interface StorageHandlerOptions {
|
|
38
|
+
db: any;
|
|
39
|
+
storage: ObjectStorageAdapter;
|
|
40
|
+
basePath?: string;
|
|
41
|
+
bucket?: string;
|
|
42
|
+
auth?: {
|
|
43
|
+
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
44
|
+
};
|
|
45
|
+
validation?: UploadValidation;
|
|
46
|
+
keyGenerator?: (file: { name: string; type: string; userId?: string }) => string;
|
|
47
|
+
publishJob?: (type: string, payload: unknown) => Promise<string>;
|
|
48
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { UploadValidation } from './types';
|
|
2
|
+
|
|
3
|
+
export interface ValidationResult {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
error?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function validateUpload(
|
|
9
|
+
file: { name: string; type: string; size: number },
|
|
10
|
+
rules: UploadValidation
|
|
11
|
+
): ValidationResult {
|
|
12
|
+
if (rules.maxSize && file.size > rules.maxSize) {
|
|
13
|
+
return {
|
|
14
|
+
valid: false,
|
|
15
|
+
error: `File size ${file.size} exceeds maximum ${rules.maxSize} bytes`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (rules.allowedTypes && rules.allowedTypes.length > 0) {
|
|
20
|
+
if (!rules.allowedTypes.includes(file.type)) {
|
|
21
|
+
return {
|
|
22
|
+
valid: false,
|
|
23
|
+
error: `File type ${file.type} is not allowed. Allowed: ${rules.allowedTypes.join(', ')}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (rules.allowedExtensions && rules.allowedExtensions.length > 0) {
|
|
29
|
+
const ext = file.name.split('.').pop()?.toLowerCase();
|
|
30
|
+
if (!ext || !rules.allowedExtensions.includes(ext)) {
|
|
31
|
+
return {
|
|
32
|
+
valid: false,
|
|
33
|
+
error: `File extension .${ext || ''} is not allowed. Allowed: ${rules.allowedExtensions.map((e) => `.${e}`).join(', ')}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { valid: true };
|
|
39
|
+
}
|