@xenterprises/fastify-ximagepipeline 1.0.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 ADDED
@@ -0,0 +1,488 @@
1
+ # xMedia Plugin for Fastify v5
2
+
3
+ Fastify plugin for handling image uploads with automatic EXIF stripping, content moderation, WebP variant generation, and Cloudflare R2 storage using a job queue pattern.
4
+
5
+ ## Features
6
+
7
+ โœจ **Core Capabilities**
8
+ - ๐Ÿ–ผ๏ธ **Image Upload** - Multipart file upload with validation
9
+ - ๐Ÿ” **EXIF Stripping** - Remove metadata while preserving orientation
10
+ - ๐ŸŽจ **Variant Generation** - Automatic WebP variants at multiple sizes
11
+ - ๐Ÿ“ฆ **R2 Storage** - Direct integration with Cloudflare R2 (S3-compatible)
12
+ - โณ **Job Queue** - Database-backed processing queue with retry logic
13
+ - ๐Ÿ”’ **Content Moderation** - Pluggable moderation API support
14
+ - ๐ŸŽฏ **Focal Points** - Smart cropping hints for UI
15
+ - ๐Ÿ“Š **Blurhash** - Loading placeholders for instant UI feedback
16
+ - ๐Ÿงน **Cleanup** - Automatic staging cleanup and stale job recovery
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @xenterprises/fastify-xmedia @fastify/multipart
22
+ ```
23
+
24
+ ## Setup
25
+
26
+ ### 1. Add Prisma Models
27
+
28
+ Add these models to your `schema.prisma`:
29
+
30
+ ```prisma
31
+ enum MediaStatus {
32
+ PENDING
33
+ PROCESSING
34
+ COMPLETE
35
+ REJECTED
36
+ FAILED
37
+ }
38
+
39
+ model MediaQueue {
40
+ id String @id @default(cuid())
41
+ status MediaStatus @default(PENDING)
42
+ sourceType String
43
+ sourceId String
44
+ stagingKey String
45
+ originalFilename String
46
+ mimeType String
47
+ fileSize Int
48
+ mediaId String?
49
+ media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
50
+ attempts Int @default(0)
51
+ maxAttempts Int @default(3)
52
+ errorMsg String?
53
+ moderationResult String?
54
+ moderationDetails Json?
55
+ lockedAt DateTime?
56
+ lockedBy String?
57
+ createdAt DateTime @default(now())
58
+ updatedAt DateTime @updatedAt
59
+
60
+ @@index([status, createdAt])
61
+ @@index([sourceType, sourceId])
62
+ }
63
+
64
+ model Media {
65
+ id String @id @default(cuid())
66
+ urls Json @default("{}")
67
+ originalUrl String
68
+ width Int
69
+ height Int
70
+ format String
71
+ aspectRatio String
72
+ blurhash String
73
+ focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}")
74
+ sourceType String
75
+ sourceId String
76
+ originalFilename String
77
+ mimeType String
78
+ fileSize Int
79
+ exifStripped Boolean @default(true)
80
+ createdAt DateTime @default(now())
81
+ updatedAt DateTime @updatedAt
82
+ queue MediaQueue[]
83
+
84
+ @@index([sourceType, sourceId])
85
+ }
86
+ ```
87
+
88
+ ### 2. Register Plugin
89
+
90
+ ```javascript
91
+ import Fastify from 'fastify';
92
+ import xMedia from '@xenterprises/fastify-xmedia';
93
+ import multipart from '@fastify/multipart';
94
+ import { PrismaClient } from '@prisma/client';
95
+
96
+ const fastify = Fastify();
97
+ const prisma = new PrismaClient();
98
+
99
+ await fastify.register(multipart);
100
+
101
+ await fastify.register(xMedia, {
102
+ // R2 Configuration
103
+ r2: {
104
+ endpoint: process.env.R2_ENDPOINT,
105
+ region: 'auto',
106
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
107
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
108
+ bucket: process.env.R2_BUCKET,
109
+ },
110
+
111
+ // Database connection
112
+ db: prisma,
113
+
114
+ // Optional: Content moderation
115
+ moderation: {
116
+ provider: 'rekognition', // or 'vision', 'sightengine', etc.
117
+ apiKey: process.env.MODERATION_API_KEY,
118
+ // provider-specific options...
119
+ },
120
+
121
+ // Optional: Customize variant specs
122
+ variants: {
123
+ xs: { width: 80, height: 80, fit: 'cover' },
124
+ sm: { width: 200, height: 200, fit: 'cover' },
125
+ md: { width: 600, height: null, fit: 'inside' },
126
+ lg: { width: 1200, height: null, fit: 'inside' },
127
+ xl: { width: 1920, height: null, fit: 'inside' },
128
+ '2xl': { width: 2560, height: null, fit: 'inside' },
129
+ },
130
+
131
+ // Optional: Worker configuration
132
+ worker: {
133
+ enabled: true,
134
+ pollInterval: 5000, // 5 seconds
135
+ maxAttempts: 3,
136
+ lockTimeout: 300000, // 5 minutes
137
+ failOnError: false,
138
+ },
139
+
140
+ // Optional: Storage paths
141
+ stagingPath: 'staging',
142
+ mediaPath: 'media',
143
+ originalsPath: 'originals',
144
+
145
+ // Optional: Limits
146
+ maxFileSize: 50 * 1024 * 1024, // 50MB
147
+ allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
148
+ });
149
+ ```
150
+
151
+ ## API Endpoints
152
+
153
+ ### Upload Image
154
+
155
+ ```http
156
+ POST /media/upload HTTP/1.1
157
+
158
+ Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
159
+
160
+ ------WebKitFormBoundary
161
+ Content-Disposition: form-data; name="file"; filename="photo.jpg"
162
+ Content-Type: image/jpeg
163
+
164
+ [binary image data]
165
+ ------WebKitFormBoundary
166
+ Content-Disposition: form-data; name="sourceType"
167
+
168
+ avatar
169
+ ------WebKitFormBoundary
170
+ Content-Disposition: form-data; name="sourceId"
171
+
172
+ user123
173
+ ------WebKitFormBoundary--
174
+ ```
175
+
176
+ **Response:** `202 Accepted`
177
+ ```json
178
+ {
179
+ "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
180
+ "message": "File uploaded. Processing started.",
181
+ "statusUrl": "/media/status/clh7k9w1j0000nv8zk9k9k9k9"
182
+ }
183
+ ```
184
+
185
+ ### Check Processing Status
186
+
187
+ ```http
188
+ GET /media/status/:jobId HTTP/1.1
189
+ ```
190
+
191
+ **Responses:**
192
+
193
+ While Processing (202):
194
+ ```json
195
+ {
196
+ "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
197
+ "status": "PROCESSING",
198
+ "sourceType": "avatar",
199
+ "sourceId": "user123",
200
+ "createdAt": "2024-01-15T10:30:00Z",
201
+ "updatedAt": "2024-01-15T10:30:02Z"
202
+ }
203
+ ```
204
+
205
+ When Complete (200):
206
+ ```json
207
+ {
208
+ "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
209
+ "status": "COMPLETE",
210
+ "sourceType": "avatar",
211
+ "sourceId": "user123",
212
+ "media": {
213
+ "id": "media-1705314600000-abc123",
214
+ "urls": {
215
+ "xs": "https://r2.example.com/media/avatar/user123/...-xs.webp",
216
+ "sm": "https://r2.example.com/media/avatar/user123/...-sm.webp",
217
+ "md": "https://r2.example.com/media/avatar/user123/...-md.webp"
218
+ },
219
+ "originalUrl": "https://r2.example.com/originals/avatar/user123/.../original.jpg",
220
+ "width": 2000,
221
+ "height": 2000,
222
+ "aspectRatio": "1:1",
223
+ "blurhash": "UeKUpMxua4t757oJodS3_3kCMd9F6p",
224
+ "focalPoint": { "x": 0.5, "y": 0.5 }
225
+ }
226
+ }
227
+ ```
228
+
229
+ When Rejected (400):
230
+ ```json
231
+ {
232
+ "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
233
+ "status": "REJECTED",
234
+ "reason": "REJECTED",
235
+ "moderationDetails": {
236
+ "flags": ["adult", "violence"],
237
+ "confidence": { "adult": 0.95 }
238
+ }
239
+ }
240
+ ```
241
+
242
+ When Failed (500):
243
+ ```json
244
+ {
245
+ "jobId": "clh7k9w1j0000nv8zk9k9k9k9",
246
+ "status": "FAILED",
247
+ "error": "Failed to download from R2",
248
+ "attempts": 3
249
+ }
250
+ ```
251
+
252
+ ## Source Types & Variant Presets
253
+
254
+ The plugin comes with predefined source types and their variant presets:
255
+
256
+ | Source Type | Variants | Use Case |
257
+ |---|---|---|
258
+ | `avatar` | xs, sm | User/band profile pictures |
259
+ | `member_photo` | xs, sm, md | Member directory images |
260
+ | `gallery` | md, lg, xl | Gallery display images |
261
+ | `hero` | lg, xl, 2xl | Hero/banner backgrounds |
262
+ | `content` | md, lg | Article/post images |
263
+
264
+ Each source type generates only the specified variants. For example, `avatar` uploads will create `xs.webp` and `sm.webp` files.
265
+
266
+ ## Frontend Usage
267
+
268
+ ### React Example
269
+
270
+ ```jsx
271
+ import { useEffect, useState } from 'react';
272
+
273
+ function AvatarUpload({ userId }) {
274
+ const [jobId, setJobId] = useState(null);
275
+ const [status, setStatus] = useState(null);
276
+ const [loading, setLoading] = useState(false);
277
+
278
+ const handleUpload = async (file) => {
279
+ const formData = new FormData();
280
+ formData.append('file', file);
281
+ formData.append('sourceType', 'avatar');
282
+ formData.append('sourceId', userId);
283
+
284
+ const response = await fetch('/media/upload', {
285
+ method: 'POST',
286
+ body: formData,
287
+ });
288
+
289
+ const data = await response.json();
290
+ setJobId(data.jobId);
291
+ };
292
+
293
+ useEffect(() => {
294
+ if (!jobId) return;
295
+
296
+ const checkStatus = async () => {
297
+ const response = await fetch(`/media/status/${jobId}`);
298
+ const data = await response.json();
299
+ setStatus(data);
300
+
301
+ if (data.status === 'COMPLETE') {
302
+ setLoading(false);
303
+ }
304
+ };
305
+
306
+ const interval = setInterval(checkStatus, 2000);
307
+ return () => clearInterval(interval);
308
+ }, [jobId]);
309
+
310
+ return (
311
+ <div>
312
+ <input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
313
+ {status?.media && (
314
+ <img src={status.media.urls.sm} alt="Avatar" />
315
+ )}
316
+ {status?.status === 'PROCESSING' && <p>Processing...</p>}
317
+ {status?.status === 'REJECTED' && <p>Image rejected: {status.reason}</p>}
318
+ </div>
319
+ );
320
+ }
321
+ ```
322
+
323
+ ### Nuxt Example
324
+
325
+ ```vue
326
+ <template>
327
+ <div>
328
+ <input type="file" @change="handleUpload" />
329
+
330
+ <div v-if="media">
331
+ <img :src="media.urls.sm" alt="Avatar" />
332
+ <div
333
+ v-if="media.blurhash"
334
+ :style="{ backgroundColor: blurhashColor }"
335
+ class="blur-placeholder"
336
+ />
337
+ </div>
338
+
339
+ <p v-if="status === 'PROCESSING'">Processing...</p>
340
+ <p v-if="status === 'REJECTED'">Image rejected</p>
341
+ </div>
342
+ </template>
343
+
344
+ <script setup>
345
+ import { ref } from 'vue';
346
+
347
+ const jobId = ref(null);
348
+ const media = ref(null);
349
+ const status = ref(null);
350
+
351
+ const handleUpload = async (event) => {
352
+ const file = event.target.files[0];
353
+ const formData = new FormData();
354
+ formData.append('file', file);
355
+ formData.append('sourceType', 'avatar');
356
+ formData.append('sourceId', 'user123');
357
+
358
+ const response = await fetch('/media/upload', {
359
+ method: 'POST',
360
+ body: formData,
361
+ });
362
+
363
+ const { jobId: id } = await response.json();
364
+ jobId.value = id;
365
+ pollStatus();
366
+ };
367
+
368
+ const pollStatus = async () => {
369
+ const response = await fetch(`/media/status/${jobId.value}`);
370
+ const data = await response.json();
371
+ status.value = data.status;
372
+
373
+ if (data.status === 'COMPLETE') {
374
+ media.value = data.media;
375
+ } else if (data.status !== 'PROCESSING') {
376
+ setTimeout(pollStatus, 2000);
377
+ }
378
+ };
379
+ </script>
380
+ ```
381
+
382
+ ## Processing Pipeline
383
+
384
+ 1. **Upload** - File received at `/media/upload`
385
+ 2. **Queue** - Job created in database with `PENDING` status
386
+ 3. **Worker Polling** - Worker finds next job and locks it
387
+ 4. **Download** - File downloaded from staging bucket
388
+ 5. **EXIF Strip** - Metadata removed while preserving orientation
389
+ 6. **Moderation** - Content checked (if enabled)
390
+ 7. **Variants** - WebP variants generated at specified sizes
391
+ 8. **Blurhash** - Loading placeholder generated
392
+ 9. **Upload** - Variants and original uploaded to media bucket
393
+ 10. **Store** - Media record created with URLs and metadata
394
+ 11. **Cleanup** - Staging file deleted
395
+ 12. **Complete** - Job status updated to `COMPLETE`
396
+
397
+ ## Configuration
398
+
399
+ ### R2 Setup
400
+
401
+ 1. Create R2 bucket
402
+ 2. Generate API token
403
+ 3. Set CORS policy on bucket:
404
+ ```json
405
+ {
406
+ "CORSRules": [
407
+ {
408
+ "AllowedOrigins": ["https://yoursite.com"],
409
+ "AllowedMethods": ["GET"],
410
+ "AllowedHeaders": ["*"],
411
+ "MaxAgeSeconds": 3600
412
+ }
413
+ ]
414
+ }
415
+ ```
416
+
417
+ 4. Set lifecycle policy to clean staging:
418
+ ```
419
+ Delete objects in /staging/ older than 1 day
420
+ ```
421
+
422
+ ### Environment Variables
423
+
424
+ ```bash
425
+ # R2
426
+ R2_ENDPOINT=https://[account-id].r2.cloudflarestorage.com
427
+ R2_ACCESS_KEY_ID=...
428
+ R2_SECRET_ACCESS_KEY=...
429
+ R2_BUCKET=my-media-bucket
430
+
431
+ # Database (Prisma)
432
+ DATABASE_URL=postgresql://...
433
+
434
+ # Moderation (optional)
435
+ MODERATION_PROVIDER=rekognition
436
+ MODERATION_API_KEY=...
437
+ ```
438
+
439
+ ## Testing
440
+
441
+ ```bash
442
+ npm test
443
+ ```
444
+
445
+ ## Performance Tips
446
+
447
+ - ๐Ÿš€ **Direct Upload**: Consider presigned URLs for large files to bypass server bandwidth
448
+ - ๐ŸŽฏ **CDN**: Put R2 behind Cloudflare CDN for caching
449
+ - โšก **Worker Pool**: Run multiple worker processes for faster processing
450
+ - ๐Ÿ“Š **Monitoring**: Monitor `MediaQueue` table for stuck jobs
451
+ - ๐Ÿงน **Cleanup**: Run cleanup tasks regularly to remove orphaned files
452
+
453
+ ## Troubleshooting
454
+
455
+ ### Jobs stuck in PROCESSING
456
+
457
+ Jobs are automatically recovered if locked > 5 minutes. To manually recover:
458
+
459
+ ```javascript
460
+ import { recoverStaleLocks } from '@xenterprises/fastify-xmedia/workers/processor';
461
+
462
+ await recoverStaleLocks(prisma, 5 * 60 * 1000);
463
+ ```
464
+
465
+ ### Missing variants
466
+
467
+ Check that sourceType is in `getVariantPresets()`. Custom source types require variant config.
468
+
469
+ ### R2 upload failures
470
+
471
+ - Verify credentials and bucket name
472
+ - Check CORS policy
473
+ - Ensure endpoint URL is correct
474
+
475
+ ## Future Enhancements
476
+
477
+ - [ ] Direct browser-to-R2 uploads (presigned URLs)
478
+ - [ ] Video support with thumbnails
479
+ - [ ] Audio waveform generation
480
+ - [ ] Batch uploads
481
+ - [ ] AVIF format support
482
+ - [ ] Face detection for smart cropping
483
+ - [ ] Custom image filters/transforms
484
+ - [ ] Analytics and usage tracking
485
+
486
+ ## License
487
+
488
+ ISC
package/SCHEMA.prisma ADDED
@@ -0,0 +1,113 @@
1
+ // This schema should be added to your main Prisma schema.prisma file
2
+ // It defines the models needed for the xMedia pipeline
3
+
4
+ enum MediaStatus {
5
+ PENDING
6
+ PROCESSING
7
+ COMPLETE
8
+ REJECTED
9
+ FAILED
10
+ }
11
+
12
+ enum ModerationResult {
13
+ APPROVED
14
+ REJECTED
15
+ FLAGGED
16
+ }
17
+
18
+ model MediaQueue {
19
+ id String @id @default(cuid())
20
+
21
+ // Job status
22
+ status MediaStatus @default(PENDING)
23
+
24
+ // Source information
25
+ sourceType String // avatar, gallery, hero, member_photo, content, etc.
26
+ sourceId String // userId, bandId, etc.
27
+
28
+ // File information
29
+ stagingKey String // Key in R2 staging bucket
30
+ originalFilename String
31
+ mimeType String
32
+ fileSize Int
33
+
34
+ // Processing results
35
+ mediaId String? // FK to Media after processing
36
+ media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
37
+
38
+ // Error tracking
39
+ attempts Int @default(0)
40
+ maxAttempts Int @default(3)
41
+ errorMsg String?
42
+
43
+ // Moderation results
44
+ moderationResult ModerationResult?
45
+ moderationDetails Json? // Full moderation API response
46
+
47
+ // Locking for worker process
48
+ lockedAt DateTime?
49
+ lockedBy String? // Worker ID that locked this job
50
+
51
+ // Timestamps
52
+ createdAt DateTime @default(now())
53
+ updatedAt DateTime @updatedAt
54
+
55
+ @@index([status, createdAt])
56
+ @@index([sourceType, sourceId])
57
+ @@index([lockedAt])
58
+ }
59
+
60
+ model Media {
61
+ id String @id @default(cuid())
62
+
63
+ // Variant URLs (object mapping variant name to URL)
64
+ // e.g., { "xs": "https://...", "sm": "https://...", "md": "https://..." }
65
+ urls Json @default("{}")
66
+ originalUrl String // URL to full-resolution original
67
+
68
+ // Image properties
69
+ width Int
70
+ height Int
71
+ format String // jpeg, png, webp, gif
72
+ aspectRatio String // e.g., "16:9", "4:3", "1:1"
73
+
74
+ // Loading placeholder
75
+ blurhash String // For instant UI placeholder
76
+
77
+ // For smart cropping
78
+ focalPoint Json @default("{\"x\": 0.5, \"y\": 0.5}") // { x: 0-1, y: 0-1 }
79
+
80
+ // Source information (denormalized for queries)
81
+ sourceType String
82
+ sourceId String
83
+
84
+ // File information
85
+ originalFilename String
86
+ mimeType String
87
+ fileSize Int
88
+
89
+ // Metadata
90
+ exifStripped Boolean @default(true)
91
+ createdAt DateTime @default(now())
92
+ updatedAt DateTime @updatedAt
93
+
94
+ // Relations (optional - add as needed)
95
+ // User.avatar => Media
96
+ // Band.avatar => Media
97
+ // Gallery items, etc.
98
+
99
+ queue MediaQueue[]
100
+
101
+ @@index([sourceType, sourceId])
102
+ @@index([createdAt])
103
+ }
104
+
105
+ // Example: Add to User model
106
+ // avatar Media? @relation(fields: [avatarId], references: [id], onDelete: SetNull)
107
+ // avatarId String?
108
+
109
+ // Example: Add to Band model
110
+ // avatar Media? @relation(fields: [avatarId], references: [id], onDelete: SetNull)
111
+ // avatarId String?
112
+ // hero Media? @relation(fields: [heroId], references: [id], onDelete: SetNull)
113
+ // heroId String?
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@xenterprises/fastify-ximagepipeline",
3
+ "type": "module",
4
+ "version": "1.0.0",
5
+ "description": "Fastify plugin for image uploads with EXIF stripping, moderation, variant generation, and R2 storage with job queue",
6
+ "main": "src/xImagePipeline.js",
7
+ "exports": {
8
+ ".": "./src/xImagePipeline.js",
9
+ "./image": "./src/utils/image.js",
10
+ "./s3": "./src/services/s3.js"
11
+ },
12
+ "scripts": {
13
+ "start": "fastify start -l info server/app.js",
14
+ "dev": "fastify start -w -l info -P server/app.js",
15
+ "test": "node --test test/xImagePipeline.test.js"
16
+ },
17
+ "keywords": [
18
+ "fastify",
19
+ "media",
20
+ "image",
21
+ "upload",
22
+ "s3",
23
+ "r2",
24
+ "cloudflare",
25
+ "exif",
26
+ "variants",
27
+ "moderation",
28
+ "sharp",
29
+ "webp"
30
+ ],
31
+ "author": "Tim Mushen",
32
+ "license": "ISC",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git@gitlab.com:x-enterprises/fastify-plugins/fastify-x-imagepipeline.git"
36
+ },
37
+ "bugs": {
38
+ "url": "https://gitlab.com/x-enterprises/fastify-plugins/fastify-x-imagepipeline/-/issues"
39
+ },
40
+ "dependencies": {
41
+ "@aws-sdk/client-s3": "^3.700.0",
42
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
43
+ "blurhash": "^2.0.5",
44
+ "fastify-plugin": "^5.0.0",
45
+ "sharp": "^0.33.5"
46
+ },
47
+ "devDependencies": {
48
+ "@fastify/multipart": "^9.0.0",
49
+ "@types/node": "^22.7.4",
50
+ "fastify": "^5.1.0"
51
+ },
52
+ "peerDependencies": {
53
+ "fastify": "^5.0.0"
54
+ }
55
+ }