vasuzex 2.3.12 → 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 +117 -0
- package/README.md +505 -514
- 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 +157 -22
- 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/jsconfig.json +1 -0
- package/package.json +1 -1
|
@@ -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';
|