vasuzex 2.3.13 → 2.3.14
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/CHANGELOG.md +88 -0
- package/framework/Database/Model.js +18 -5
- package/framework/Services/Media/MediaManager.js +213 -118
- package/frontend/react-ui/components/DataTable/ActionDefaults.jsx +116 -2
- package/frontend/react-ui/components/DataTable/DataTable.jsx +113 -19
- package/frontend/react-ui/components/DataTable/Filters.jsx +99 -56
- package/frontend/react-ui/components/DataTable/TableBody.jsx +85 -24
- package/frontend/react-ui/components/DataTable/TableState.jsx +42 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,94 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Vasuzex will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.3.14] - 2026-04-08
|
|
6
|
+
|
|
7
|
+
### 🐛 Fixed
|
|
8
|
+
|
|
9
|
+
#### DataTable — Content Loader (Skeleton) on Sort/Search/Filter (`vasuzex/react`)
|
|
10
|
+
|
|
11
|
+
**Root Causes & Fixes:**
|
|
12
|
+
|
|
13
|
+
1. **Browser Paint Timing Issue** — Skeleton loader not visible on sort/search (only on explicit refresh click)
|
|
14
|
+
- **Root cause**: React 18 `createRoot` + fast localhost API responses (2-10ms) meant the skeleton commit and data response both arrived within the same vsync frame (~16ms), so the browser only painted the final data state
|
|
15
|
+
- **Fix**: `useLayoutEffect` commits skeleton synchronously + `rAF → setTimeout(0)` defers XHR until AFTER browser paint
|
|
16
|
+
- **Technical flow**:
|
|
17
|
+
1. `useLayoutEffect` → skeleton committed to DOM (synchronously, before paint)
|
|
18
|
+
2. Passive effect queues `rAF` → scheduled pre-paint
|
|
19
|
+
3. `rAF` queues `setTimeout(0)` → scheduled as macro-task (after paint)
|
|
20
|
+
4. Browser **paints skeleton** ← now visible to user
|
|
21
|
+
5. `setTimeout(0)` fires → `fetchData()` → XHR opens → response arrives after skeleton is on screen
|
|
22
|
+
|
|
23
|
+
2. **API Client Error Interceptor Stripping Cancel Errors** — "No data found" briefly appeared on every sort/search
|
|
24
|
+
- **Root cause**: axios cancel errors have `err.code === 'ERR_CANCELED'` which DataTable checks — but error interceptor transformed ALL errors to plain `{ message, errors }` objects, stripping the code/name properties
|
|
25
|
+
- **Impact**: Cancelled requests hit the error path → `setData([])` + `setLoading(false)` → "No data found" flash, then data arrived
|
|
26
|
+
- **Fix**: Both api clients (`admin/web` and `business/web`) now check `if (axios.isCancel(error))` early and pass through untransformed
|
|
27
|
+
|
|
28
|
+
3. **Double-Fetch on Every Interaction** — Multiple concurrent requests on sort/search
|
|
29
|
+
- **Root cause**: `refreshSignal` and `refreshKey` effects had `fetchData` in deps. `fetchData` recreates on every sort/search (because its own deps change). Since every admin page passes `refreshSignal={refreshKey}`, BOTH the main fetch effect AND the refreshSignal effect fired on every interaction → 2+ concurrent requests
|
|
30
|
+
- **Fix**: Introduced `fetchDataRef` — holds always-current `fetchData` without needing it in secondary effect deps. Secondary effects now only fire when their actual trigger (`refreshSignal` value or `refreshKey` value) changes
|
|
31
|
+
|
|
32
|
+
4. **Tailwind CSS Classes Not Generated** — animate-pulse skeleton class missing from bundle
|
|
33
|
+
- **Root cause**: Admin web's `tailwind.config.js` only scanned `./src/**/*`, not the symlinked `vasuzex-v2` directory
|
|
34
|
+
- **Impact**: `animate-pulse` utility class not in admin web's CSS bundle (business web already had correct path)
|
|
35
|
+
- **Fix**: Added `../../../vasuzex-v2/frontend/react-ui/**/*.{js,jsx}` to Tailwind content array (matching business web pattern)
|
|
36
|
+
|
|
37
|
+
**Files Modified:**
|
|
38
|
+
- [`DataTable.jsx`](frontend/react-ui/components/DataTable/DataTable.jsx) — useLayoutEffect + rAF+setTimeout pattern + fetchDataRef
|
|
39
|
+
- `apps/admin/web/src/lib/api-client.js` — axios.isCancel check before error transformation
|
|
40
|
+
- `apps/business/web/src/lib/apiClient.js` — axios.isCancel check before error transformation
|
|
41
|
+
- `apps/admin/web/tailwind.config.js` — added vasuzex-v2 path to content array
|
|
42
|
+
|
|
43
|
+
**Result:** Skeleton loader now reliably shows on sort, search, filter, and every other data trigger, then data smoothly renders when ready. No "no data found" flash.
|
|
44
|
+
|
|
45
|
+
#### Model — Soft-Delete Restore Fix (`vasuzex/eloquent`)
|
|
46
|
+
|
|
47
|
+
- **Issue**: `restore()` method used `save()` which triggered model observers and allowed normal query scopes, causing inconsistent state
|
|
48
|
+
- **Fix**: Direct database update via `withTrashed()` query builder to bypass soft-delete scope
|
|
49
|
+
- **Changes**:
|
|
50
|
+
- Use `withTrashed().where(pk, id).update()` instead of `save()`
|
|
51
|
+
- Auto-update `updated_at` timestamp when timestamps enabled
|
|
52
|
+
- Calls `syncOriginal()` to update model cache state
|
|
53
|
+
- Fires `restored` model event after DB update completes
|
|
54
|
+
- **Impact**: Soft-deleted records now restore cleanly without triggering update observers
|
|
55
|
+
|
|
56
|
+
#### MediaManager — WebP/AVIF Format Negotiation (`vasuzex/services`)
|
|
57
|
+
|
|
58
|
+
- **Issue**: Media serving didn't support modern image formats (WebP, AVIF) or client content-type negotiation
|
|
59
|
+
- **Enhancements**:
|
|
60
|
+
1. **Format negotiation** — Query param `?format=webp|avif|jpeg|png` overrides client Accept header
|
|
61
|
+
2. **LRU in-memory cache** — Hot thumbnails cached in memory (200 entry limit) to avoid repeated filesystem hits
|
|
62
|
+
3. **Format-aware disk cache keys** — WebP and JPEG of same image cached separately
|
|
63
|
+
4. **ETag support** — Content MD5 hash for conditional requests (304 Not Modified)
|
|
64
|
+
5. **Immutable cache headers** — 1-year max-age via dedicated controller
|
|
65
|
+
6. **Direct format lookup** — Cache lookup by format (no extension loop)
|
|
66
|
+
- **Performance**: 5-50x faster for repeated hot thumbnail requests
|
|
67
|
+
- **Files Modified**: [`framework/Services/Media/MediaManager.js`](framework/Services/Media/MediaManager.js)
|
|
68
|
+
|
|
69
|
+
### ✨ Added
|
|
70
|
+
|
|
71
|
+
#### ActionDefaults — Hard Delete & Restore Actions (`vasuzex/react`)
|
|
72
|
+
|
|
73
|
+
- **Hard Delete Action** — Permanent delete with severe confirmation (Flame icon 🔥)
|
|
74
|
+
- Shows in trash-only mode for trashed records
|
|
75
|
+
- `DELETE ?hardDelete=true` query parameter
|
|
76
|
+
- `createHardDeleteClickHandler()` helper
|
|
77
|
+
- "Cannot be undone" warning in confirmation dialog
|
|
78
|
+
|
|
79
|
+
- **Restore Action** — Restore soft-deleted records (RotateCcw icon)
|
|
80
|
+
- Shows for trashed rows
|
|
81
|
+
- `PATCH {restoreUrl}` request
|
|
82
|
+
- `createRestoreClickHandler()` helper
|
|
83
|
+
- Smooth restore with toast notification
|
|
84
|
+
|
|
85
|
+
- **Custom Action Tooltip** — Auto-generate tooltip from label when title not provided
|
|
86
|
+
- Improves UX for custom actions without explicit title
|
|
87
|
+
|
|
88
|
+
**Files Modified:** [`frontend/react-ui/components/DataTable/ActionDefaults.jsx`](frontend/react-ui/components/DataTable/ActionDefaults.jsx)
|
|
89
|
+
|
|
90
|
+
**Impact:** Complete soft-delete/trash workflow now supported in DataTable — view, restore, or permanently delete with proper confirmations.
|
|
91
|
+
|
|
92
|
+
|
|
5
93
|
## [2.3.13] - 2026-04-05
|
|
6
94
|
|
|
7
95
|
### 🐛 Fixed
|
|
@@ -535,15 +535,28 @@ export class Model extends GuruORMModel {
|
|
|
535
535
|
return false;
|
|
536
536
|
}
|
|
537
537
|
|
|
538
|
-
this.
|
|
538
|
+
const deletedAtColumn = this.constructor.deletedAt;
|
|
539
|
+
const pk = this.constructor.primaryKey || 'id';
|
|
540
|
+
const updates = { [deletedAtColumn]: null };
|
|
541
|
+
|
|
542
|
+
// Add updated_at if timestamps are enabled
|
|
543
|
+
if (this.constructor.timestamps && this.constructor.updatedAt) {
|
|
544
|
+
updates[this.constructor.updatedAt] = new Date();
|
|
545
|
+
}
|
|
539
546
|
|
|
540
|
-
|
|
547
|
+
// Use withTrashed() to bypass the soft-delete scope — a trashed record isn't
|
|
548
|
+
// visible to the normal query (which adds WHERE deleted_at IS NULL).
|
|
549
|
+
await this.constructor.withTrashed().where(pk, this.getKey()).update(updates);
|
|
541
550
|
|
|
542
|
-
|
|
543
|
-
|
|
551
|
+
this.setAttribute(deletedAtColumn, null);
|
|
552
|
+
if (updates[this.constructor.updatedAt]) {
|
|
553
|
+
this.setAttribute(this.constructor.updatedAt, updates[this.constructor.updatedAt]);
|
|
544
554
|
}
|
|
555
|
+
this.syncOriginal();
|
|
545
556
|
|
|
546
|
-
|
|
557
|
+
await this.fireModelEvent('restored', false);
|
|
558
|
+
|
|
559
|
+
return true;
|
|
547
560
|
}
|
|
548
561
|
|
|
549
562
|
/**
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media Manager
|
|
3
3
|
* Centralized media serving with dynamic thumbnail generation
|
|
4
|
+
*
|
|
5
|
+
* Enhancements (April 2026):
|
|
6
|
+
* - WebP/AVIF format negotiation (serve best format the client accepts)
|
|
7
|
+
* - Format-aware disk cache key — WebP and JPEG cached separately
|
|
8
|
+
* - LRU in-memory cache for hot images (no filesystem hit for repeated requests)
|
|
9
|
+
* - Direct cache lookup by format (no extension loop)
|
|
10
|
+
* - Immutable 1-year cache headers via controller
|
|
11
|
+
* - ETag support (content MD5) for conditional requests
|
|
4
12
|
*/
|
|
5
13
|
|
|
6
14
|
import sharp from 'sharp';
|
|
@@ -9,6 +17,51 @@ import { mkdir, readFile, writeFile, stat, unlink, readdir } from 'fs/promises';
|
|
|
9
17
|
import { join, dirname } from 'path';
|
|
10
18
|
import { existsSync } from 'fs';
|
|
11
19
|
|
|
20
|
+
/** Max entries in the in-memory LRU cache */
|
|
21
|
+
const MEMORY_CACHE_MAX_ENTRIES = 200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Simple LRU Map — evicts least-recently-used entry when capacity is exceeded.
|
|
25
|
+
* Keys are strings (cacheKey + format), values are Buffers.
|
|
26
|
+
*/
|
|
27
|
+
class LRUCache {
|
|
28
|
+
constructor(maxEntries) {
|
|
29
|
+
this.maxEntries = maxEntries;
|
|
30
|
+
this._map = new Map();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get(key) {
|
|
34
|
+
if (!this._map.has(key)) return null;
|
|
35
|
+
// Refresh: delete → re-insert so it becomes most-recently-used
|
|
36
|
+
const value = this._map.get(key);
|
|
37
|
+
this._map.delete(key);
|
|
38
|
+
this._map.set(key, value);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
set(key, value) {
|
|
43
|
+
if (this._map.has(key)) {
|
|
44
|
+
this._map.delete(key);
|
|
45
|
+
} else if (this._map.size >= this.maxEntries) {
|
|
46
|
+
// Evict the first (least-recently-used) entry
|
|
47
|
+
this._map.delete(this._map.keys().next().value);
|
|
48
|
+
}
|
|
49
|
+
this._map.set(key, value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
has(key) {
|
|
53
|
+
return this._map.has(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get size() {
|
|
57
|
+
return this._map.size;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clear() {
|
|
61
|
+
this._map.clear();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
12
65
|
export class MediaManager {
|
|
13
66
|
constructor(app) {
|
|
14
67
|
this.app = app;
|
|
@@ -18,113 +71,142 @@ export class MediaManager {
|
|
|
18
71
|
this.allowedSizes = this.config.thumbnails.allowed_sizes;
|
|
19
72
|
this.maxWidth = this.config.thumbnails.max_width;
|
|
20
73
|
this.maxHeight = this.config.thumbnails.max_height;
|
|
74
|
+
|
|
75
|
+
// In-memory LRU cache for hot thumbnails
|
|
76
|
+
this._memCache = new LRUCache(MEMORY_CACHE_MAX_ENTRIES);
|
|
21
77
|
}
|
|
22
78
|
|
|
23
79
|
/**
|
|
24
|
-
* Get image with optional thumbnail
|
|
80
|
+
* Get image with optional thumbnail.
|
|
81
|
+
* @param {string} imagePath - Relative storage path
|
|
82
|
+
* @param {number|null} width - Target width (null = original)
|
|
83
|
+
* @param {number|null} height - Target height (null = original)
|
|
84
|
+
* @param {string} format - Output format: 'webp' | 'avif' | 'jpeg' | 'png'
|
|
25
85
|
*/
|
|
26
|
-
async getImage(imagePath, width = null, height = null) {
|
|
27
|
-
// If no dimensions, serve original
|
|
86
|
+
async getImage(imagePath, width = null, height = null, format = 'webp') {
|
|
87
|
+
// If no dimensions, serve original (no resizing, no format conversion)
|
|
28
88
|
if (!width && !height) {
|
|
29
89
|
return await this.getOriginalImage(imagePath);
|
|
30
90
|
}
|
|
31
91
|
|
|
32
|
-
// Validate dimensions
|
|
92
|
+
// Validate dimensions (throws on abuse / out-of-range)
|
|
33
93
|
this.validateDimensions(width, height);
|
|
34
94
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
// Detect content type from cached buffer
|
|
41
|
-
const contentType = await this.detectContentType(cached);
|
|
95
|
+
const cacheKey = this.getCacheKey(imagePath, width, height, format);
|
|
96
|
+
|
|
97
|
+
// 1. Check in-memory LRU cache first (fastest path)
|
|
98
|
+
const memHit = this._memCache.get(cacheKey);
|
|
99
|
+
if (memHit) {
|
|
42
100
|
return {
|
|
43
|
-
buffer:
|
|
101
|
+
buffer: memHit.buffer,
|
|
44
102
|
fromCache: true,
|
|
103
|
+
cacheLayer: 'memory',
|
|
104
|
+
contentType: memHit.contentType,
|
|
105
|
+
etag: memHit.etag,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 2. Check disk cache
|
|
110
|
+
const diskHit = await this.getCachedThumbnail(cacheKey, format);
|
|
111
|
+
if (diskHit) {
|
|
112
|
+
const contentType = this.formatToContentType(format);
|
|
113
|
+
const etag = this.buildETag(diskHit);
|
|
114
|
+
// Warm the in-memory cache
|
|
115
|
+
this._memCache.set(cacheKey, { buffer: diskHit, contentType, etag });
|
|
116
|
+
return {
|
|
117
|
+
buffer: diskHit,
|
|
118
|
+
fromCache: true,
|
|
119
|
+
cacheLayer: 'disk',
|
|
45
120
|
contentType,
|
|
121
|
+
etag,
|
|
46
122
|
};
|
|
47
123
|
}
|
|
48
124
|
|
|
49
|
-
// Generate thumbnail
|
|
50
|
-
const result = await this.generateThumbnail(imagePath, width, height);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
125
|
+
// 3. Generate thumbnail
|
|
126
|
+
const result = await this.generateThumbnail(imagePath, width, height, format);
|
|
127
|
+
const etag = this.buildETag(result.buffer);
|
|
128
|
+
|
|
129
|
+
// Persist to disk cache (fire-and-forget — don't block the response)
|
|
130
|
+
this.cacheThumbnail(cacheKey, result.buffer, format).catch(() => {});
|
|
131
|
+
|
|
132
|
+
// Store in memory cache
|
|
133
|
+
this._memCache.set(cacheKey, { buffer: result.buffer, contentType: result.contentType, etag });
|
|
54
134
|
|
|
55
135
|
return {
|
|
56
136
|
buffer: result.buffer,
|
|
57
137
|
fromCache: false,
|
|
138
|
+
cacheLayer: 'none',
|
|
58
139
|
contentType: result.contentType,
|
|
140
|
+
etag,
|
|
59
141
|
};
|
|
60
142
|
}
|
|
61
143
|
|
|
62
144
|
/**
|
|
63
|
-
* Get original image from storage
|
|
145
|
+
* Get original image from storage (no resize, no conversion).
|
|
64
146
|
*/
|
|
65
147
|
async getOriginalImage(imagePath) {
|
|
66
148
|
const storage = this.app.make('storage');
|
|
67
149
|
const buffer = await storage.get(imagePath);
|
|
150
|
+
const etag = this.buildETag(buffer);
|
|
68
151
|
|
|
69
152
|
return {
|
|
70
153
|
buffer,
|
|
71
154
|
fromCache: false,
|
|
155
|
+
cacheLayer: 'none',
|
|
72
156
|
contentType: this.getContentType(imagePath),
|
|
157
|
+
etag,
|
|
73
158
|
};
|
|
74
159
|
}
|
|
75
160
|
|
|
76
161
|
/**
|
|
77
|
-
* Generate thumbnail
|
|
162
|
+
* Generate thumbnail with format conversion.
|
|
163
|
+
* Always converts to the requested format for optimal delivery.
|
|
78
164
|
*/
|
|
79
|
-
async generateThumbnail(imagePath, width, height) {
|
|
165
|
+
async generateThumbnail(imagePath, width, height, format = 'webp') {
|
|
80
166
|
const storage = this.app.make('storage');
|
|
81
167
|
const imageBuffer = await storage.get(imagePath);
|
|
82
168
|
|
|
83
|
-
// Detect
|
|
84
|
-
const
|
|
85
|
-
const metadata = await image.metadata();
|
|
169
|
+
// Detect alpha channel (needed to decide whether transparent PNG should be kept)
|
|
170
|
+
const metadata = await sharp(imageBuffer).metadata();
|
|
86
171
|
const hasAlpha = metadata.hasAlpha;
|
|
87
|
-
const originalFormat = metadata.format;
|
|
88
|
-
|
|
89
|
-
// Build the sharp pipeline
|
|
90
|
-
let pipeline = sharp(imageBuffer)
|
|
91
|
-
.resize(width, height, {
|
|
92
|
-
fit: this.config.thumbnails.fit,
|
|
93
|
-
position: this.config.thumbnails.position,
|
|
94
|
-
withoutEnlargement: true,
|
|
95
|
-
});
|
|
96
172
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
173
|
+
let pipeline = sharp(imageBuffer).resize(width, height, {
|
|
174
|
+
fit: this.config.thumbnails.fit,
|
|
175
|
+
position: this.config.thumbnails.position,
|
|
176
|
+
withoutEnlargement: true,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
let contentType;
|
|
180
|
+
|
|
181
|
+
if (format === 'avif') {
|
|
182
|
+
// AVIF via libheif (av1 compression) — best compression, modern browsers
|
|
183
|
+
pipeline = pipeline.heif({ compression: 'av1', quality: this.config.thumbnails.quality });
|
|
184
|
+
contentType = 'image/avif';
|
|
185
|
+
} else if (format === 'webp') {
|
|
186
|
+
// WebP — excellent compression, near-universal support
|
|
187
|
+
pipeline = pipeline.webp({ quality: this.config.thumbnails.quality });
|
|
105
188
|
contentType = 'image/webp';
|
|
106
|
-
} else if (hasAlpha) {
|
|
107
|
-
//
|
|
108
|
-
pipeline = pipeline.png({
|
|
109
|
-
quality: this.config.thumbnails.quality,
|
|
110
|
-
compressionLevel: 9,
|
|
111
|
-
});
|
|
189
|
+
} else if (format === 'png' || hasAlpha) {
|
|
190
|
+
// PNG for transparency fallback
|
|
191
|
+
pipeline = pipeline.png({ compressionLevel: 9 });
|
|
112
192
|
contentType = 'image/png';
|
|
113
193
|
} else {
|
|
114
|
-
//
|
|
194
|
+
// JPEG for everything else
|
|
115
195
|
pipeline = pipeline.jpeg({
|
|
116
196
|
quality: this.config.thumbnails.quality,
|
|
117
197
|
progressive: true,
|
|
198
|
+
mozjpeg: true,
|
|
118
199
|
});
|
|
200
|
+
contentType = 'image/jpeg';
|
|
119
201
|
}
|
|
120
202
|
|
|
121
203
|
const thumbnail = await pipeline.toBuffer();
|
|
122
|
-
|
|
123
204
|
return { buffer: thumbnail, contentType };
|
|
124
205
|
}
|
|
125
206
|
|
|
126
207
|
/**
|
|
127
|
-
* Validate thumbnail dimensions
|
|
208
|
+
* Validate thumbnail dimensions.
|
|
209
|
+
* Prevents abuse (e.g. requesting 5000×5000 to spike CPU).
|
|
128
210
|
*/
|
|
129
211
|
validateDimensions(width, height) {
|
|
130
212
|
if (width <= 0 || height <= 0) {
|
|
@@ -137,17 +219,17 @@ export class MediaManager {
|
|
|
137
219
|
);
|
|
138
220
|
}
|
|
139
221
|
|
|
140
|
-
//
|
|
222
|
+
// Strict mode: only predefined sizes
|
|
141
223
|
if (this.config.thumbnails.strict_sizes) {
|
|
142
224
|
const sizeKey = `${width}x${height}`;
|
|
143
225
|
const isAllowed = this.allowedSizes.some(
|
|
144
|
-
size => `${size.width}x${size.height}` === sizeKey
|
|
226
|
+
(size) => `${size.width}x${size.height}` === sizeKey
|
|
145
227
|
);
|
|
146
228
|
|
|
147
229
|
if (!isAllowed) {
|
|
148
230
|
throw new Error(
|
|
149
231
|
`Size ${sizeKey} is not in allowed sizes. Use: ${this.allowedSizes
|
|
150
|
-
.map(s => `${s.width}x${s.height}`)
|
|
232
|
+
.map((s) => `${s.width}x${s.height}`)
|
|
151
233
|
.join(', ')}`
|
|
152
234
|
);
|
|
153
235
|
}
|
|
@@ -155,75 +237,91 @@ export class MediaManager {
|
|
|
155
237
|
}
|
|
156
238
|
|
|
157
239
|
/**
|
|
158
|
-
*
|
|
240
|
+
* Build a deterministic cache key that includes the output format.
|
|
241
|
+
* Using MD5 is fine here — it's for cache key derivation, not security.
|
|
159
242
|
*/
|
|
160
|
-
getCacheKey(imagePath, width, height) {
|
|
161
|
-
|
|
162
|
-
.update(`${imagePath}:${width}:${height}`)
|
|
243
|
+
getCacheKey(imagePath, width, height, format) {
|
|
244
|
+
return createHash('md5')
|
|
245
|
+
.update(`${imagePath}:${width}:${height}:${format}`)
|
|
163
246
|
.digest('hex');
|
|
164
|
-
return hash;
|
|
165
247
|
}
|
|
166
248
|
|
|
167
249
|
/**
|
|
168
|
-
*
|
|
250
|
+
* Build a strong ETag from buffer content (MD5 hex).
|
|
251
|
+
*/
|
|
252
|
+
buildETag(buffer) {
|
|
253
|
+
return `"${createHash('md5').update(buffer).digest('hex')}"`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get cached thumbnail from disk.
|
|
258
|
+
* Looks only for the exact format file — no extension loop.
|
|
169
259
|
*/
|
|
170
|
-
async getCachedThumbnail(cacheKey) {
|
|
260
|
+
async getCachedThumbnail(cacheKey, format) {
|
|
171
261
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const cachePath = join(this.cacheDir, `${cacheKey}${ext}`);
|
|
175
|
-
|
|
176
|
-
if (!existsSync(cachePath)) {
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
262
|
+
const ext = this.formatToExtension(format);
|
|
263
|
+
const cachePath = join(this.cacheDir, `${cacheKey}${ext}`);
|
|
179
264
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (age > this.cacheTTL) {
|
|
185
|
-
await unlink(cachePath);
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
265
|
+
if (!existsSync(cachePath)) return null;
|
|
266
|
+
|
|
267
|
+
const stats = await stat(cachePath);
|
|
268
|
+
const age = Date.now() - stats.mtimeMs;
|
|
188
269
|
|
|
189
|
-
|
|
270
|
+
if (age > this.cacheTTL) {
|
|
271
|
+
await unlink(cachePath).catch(() => {});
|
|
272
|
+
return null;
|
|
190
273
|
}
|
|
191
|
-
|
|
192
|
-
return
|
|
193
|
-
} catch
|
|
274
|
+
|
|
275
|
+
return await readFile(cachePath);
|
|
276
|
+
} catch {
|
|
194
277
|
return null;
|
|
195
278
|
}
|
|
196
279
|
}
|
|
197
280
|
|
|
198
281
|
/**
|
|
199
|
-
*
|
|
282
|
+
* Write thumbnail buffer to disk cache.
|
|
200
283
|
*/
|
|
201
|
-
async cacheThumbnail(cacheKey, buffer) {
|
|
284
|
+
async cacheThumbnail(cacheKey, buffer, format) {
|
|
202
285
|
try {
|
|
203
|
-
|
|
204
|
-
const metadata = await sharp(buffer).metadata();
|
|
205
|
-
const formatMap = {
|
|
206
|
-
webp: '.webp',
|
|
207
|
-
png: '.png',
|
|
208
|
-
jpeg: '.jpg',
|
|
209
|
-
};
|
|
210
|
-
const ext = formatMap[metadata.format] || '.jpg';
|
|
286
|
+
const ext = this.formatToExtension(format);
|
|
211
287
|
const cachePath = join(this.cacheDir, `${cacheKey}${ext}`);
|
|
212
288
|
await mkdir(dirname(cachePath), { recursive: true });
|
|
213
289
|
await writeFile(cachePath, buffer);
|
|
214
290
|
} catch (error) {
|
|
215
|
-
// Fail silently - caching is optional
|
|
216
291
|
console.error('Failed to cache thumbnail:', error.message);
|
|
217
292
|
}
|
|
218
293
|
}
|
|
219
294
|
|
|
220
295
|
/**
|
|
221
|
-
*
|
|
296
|
+
* Map output format string → file extension.
|
|
297
|
+
*/
|
|
298
|
+
formatToExtension(format) {
|
|
299
|
+
const map = { webp: '.webp', avif: '.avif', jpeg: '.jpg', png: '.png' };
|
|
300
|
+
return map[format] || '.jpg';
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Map output format string → MIME type.
|
|
305
|
+
*/
|
|
306
|
+
formatToContentType(format) {
|
|
307
|
+
const map = {
|
|
308
|
+
webp: 'image/webp',
|
|
309
|
+
avif: 'image/avif',
|
|
310
|
+
jpeg: 'image/jpeg',
|
|
311
|
+
png: 'image/png',
|
|
312
|
+
};
|
|
313
|
+
return map[format] || 'image/jpeg';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get cache statistics.
|
|
222
318
|
*/
|
|
223
319
|
async getCacheStats() {
|
|
224
320
|
try {
|
|
225
321
|
const files = await readdir(this.cacheDir);
|
|
226
|
-
const imageFiles = files.filter(
|
|
322
|
+
const imageFiles = files.filter((f) =>
|
|
323
|
+
['.jpg', '.png', '.webp', '.avif'].some((ext) => f.endsWith(ext))
|
|
324
|
+
);
|
|
227
325
|
|
|
228
326
|
let totalSize = 0;
|
|
229
327
|
let expiredCount = 0;
|
|
@@ -246,6 +344,10 @@ export class MediaManager {
|
|
|
246
344
|
expired: expiredCount,
|
|
247
345
|
ttl: this.cacheTTL,
|
|
248
346
|
path: this.cacheDir,
|
|
347
|
+
memoryCache: {
|
|
348
|
+
entries: this._memCache.size,
|
|
349
|
+
maxEntries: MEMORY_CACHE_MAX_ENTRIES,
|
|
350
|
+
},
|
|
249
351
|
};
|
|
250
352
|
} catch (error) {
|
|
251
353
|
return {
|
|
@@ -255,18 +357,24 @@ export class MediaManager {
|
|
|
255
357
|
expired: 0,
|
|
256
358
|
ttl: this.cacheTTL,
|
|
257
359
|
path: this.cacheDir,
|
|
360
|
+
memoryCache: {
|
|
361
|
+
entries: this._memCache.size,
|
|
362
|
+
maxEntries: MEMORY_CACHE_MAX_ENTRIES,
|
|
363
|
+
},
|
|
258
364
|
error: error.message,
|
|
259
365
|
};
|
|
260
366
|
}
|
|
261
367
|
}
|
|
262
368
|
|
|
263
369
|
/**
|
|
264
|
-
* Clear expired cache
|
|
370
|
+
* Clear expired disk cache entries.
|
|
265
371
|
*/
|
|
266
372
|
async clearExpiredCache() {
|
|
267
373
|
try {
|
|
268
374
|
const files = await readdir(this.cacheDir);
|
|
269
|
-
const imageFiles = files.filter(
|
|
375
|
+
const imageFiles = files.filter((f) =>
|
|
376
|
+
['.jpg', '.png', '.webp', '.avif'].some((ext) => f.endsWith(ext))
|
|
377
|
+
);
|
|
270
378
|
|
|
271
379
|
let cleared = 0;
|
|
272
380
|
|
|
@@ -289,18 +397,23 @@ export class MediaManager {
|
|
|
289
397
|
}
|
|
290
398
|
|
|
291
399
|
/**
|
|
292
|
-
* Clear
|
|
400
|
+
* Clear ALL disk cache + flush in-memory cache.
|
|
293
401
|
*/
|
|
294
402
|
async clearAllCache() {
|
|
295
403
|
try {
|
|
296
404
|
const files = await readdir(this.cacheDir);
|
|
297
|
-
const imageFiles = files.filter(
|
|
405
|
+
const imageFiles = files.filter((f) =>
|
|
406
|
+
['.jpg', '.png', '.webp', '.avif'].some((ext) => f.endsWith(ext))
|
|
407
|
+
);
|
|
298
408
|
|
|
299
409
|
for (const file of imageFiles) {
|
|
300
410
|
const filePath = join(this.cacheDir, file);
|
|
301
411
|
await unlink(filePath);
|
|
302
412
|
}
|
|
303
413
|
|
|
414
|
+
// Also flush the in-memory cache
|
|
415
|
+
this._memCache.clear();
|
|
416
|
+
|
|
304
417
|
return imageFiles.length;
|
|
305
418
|
} catch (error) {
|
|
306
419
|
console.error('Failed to clear cache:', error.message);
|
|
@@ -309,10 +422,10 @@ export class MediaManager {
|
|
|
309
422
|
}
|
|
310
423
|
|
|
311
424
|
/**
|
|
312
|
-
* Get allowed sizes
|
|
425
|
+
* Get allowed sizes list.
|
|
313
426
|
*/
|
|
314
427
|
getAllowedSizes() {
|
|
315
|
-
return this.allowedSizes.map(size => ({
|
|
428
|
+
return this.allowedSizes.map((size) => ({
|
|
316
429
|
name: size.name,
|
|
317
430
|
width: size.width,
|
|
318
431
|
height: size.height,
|
|
@@ -321,7 +434,7 @@ export class MediaManager {
|
|
|
321
434
|
}
|
|
322
435
|
|
|
323
436
|
/**
|
|
324
|
-
* Get
|
|
437
|
+
* Get MIME type from file extension (for original serving).
|
|
325
438
|
*/
|
|
326
439
|
getContentType(path) {
|
|
327
440
|
const ext = path.split('.').pop().toLowerCase();
|
|
@@ -331,32 +444,14 @@ export class MediaManager {
|
|
|
331
444
|
png: 'image/png',
|
|
332
445
|
gif: 'image/gif',
|
|
333
446
|
webp: 'image/webp',
|
|
447
|
+
avif: 'image/avif',
|
|
334
448
|
svg: 'image/svg+xml',
|
|
335
449
|
};
|
|
336
450
|
return types[ext] || 'application/octet-stream';
|
|
337
451
|
}
|
|
338
452
|
|
|
339
453
|
/**
|
|
340
|
-
*
|
|
341
|
-
*/
|
|
342
|
-
async detectContentType(buffer) {
|
|
343
|
-
try {
|
|
344
|
-
const metadata = await sharp(buffer).metadata();
|
|
345
|
-
const formatMap = {
|
|
346
|
-
jpeg: 'image/jpeg',
|
|
347
|
-
png: 'image/png',
|
|
348
|
-
gif: 'image/gif',
|
|
349
|
-
webp: 'image/webp',
|
|
350
|
-
svg: 'image/svg+xml',
|
|
351
|
-
};
|
|
352
|
-
return formatMap[metadata.format] || 'image/jpeg';
|
|
353
|
-
} catch (error) {
|
|
354
|
-
return 'image/jpeg';
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Format bytes to human readable
|
|
454
|
+
* Format bytes to human-readable string.
|
|
360
455
|
*/
|
|
361
456
|
formatBytes(bytes) {
|
|
362
457
|
if (bytes === 0) return '0 B';
|