odac 1.4.3 → 1.4.4
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/.agent/rules/coding.md +2 -2
- package/.github/workflows/release.yml +2 -0
- package/.husky/pre-push +0 -1
- package/.kiro/steering/coding.md +27 -0
- package/.kiro/steering/memory.md +56 -0
- package/.kiro/steering/project.md +30 -0
- package/.kiro/steering/workflow.md +16 -0
- package/CHANGELOG.md +70 -0
- package/docs/ai/skills/backend/authentication.md +7 -5
- package/docs/ai/skills/backend/controllers.md +24 -3
- package/docs/ai/skills/backend/forms.md +8 -6
- package/docs/ai/skills/backend/image-processing.md +93 -0
- package/docs/ai/skills/backend/request_response.md +2 -2
- package/docs/ai/skills/backend/routing.md +11 -0
- package/docs/ai/skills/backend/structure.md +1 -1
- package/docs/ai/skills/frontend/realtime.md +18 -2
- package/docs/backend/05-controllers/02-your-trusty-odac-assistant.md +24 -0
- package/docs/backend/07-views/03-template-syntax.md +18 -2
- package/docs/backend/07-views/11-image-optimization.md +197 -0
- package/package.json +5 -2
- package/src/Auth.js +8 -4
- package/src/Config.js +5 -0
- package/src/Database/ConnectionFactory.js +16 -0
- package/src/Ipc.js +3 -2
- package/src/Lang.js +17 -10
- package/src/Odac.js +1 -0
- package/src/Request.js +20 -20
- package/src/Route.js +39 -3
- package/src/Validator.js +5 -5
- package/src/View/Image.js +495 -0
- package/src/View.js +4 -0
- package/test/Auth/verifyMagicLink.test.js +281 -0
- package/test/Lang/get.test.js +37 -11
- package/test/Odac/image.test.js +61 -0
- package/test/Route/set.test.js +102 -0
- package/test/View/Image/buildFilename.test.js +62 -0
- package/test/View/Image/hash.test.js +59 -0
- package/test/View/Image/isAvailable.test.js +15 -0
- package/test/View/Image/parse.test.js +83 -0
- package/test/View/Image/process.test.js +38 -0
- package/test/View/Image/render.test.js +117 -0
- package/test/View/Image/serve.test.js +56 -0
- package/test/View/Image/url.test.js +53 -0
- package/test/View/constructor.test.js +10 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
const nodeCrypto = require('crypto')
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const fsPromises = fs.promises
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const IMG_CACHE_DIR = './storage/.cache/img'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Handles on-demand image processing (resize + format conversion) for the
|
|
10
|
+
* ODAC template engine's `<odac:img>` tag. Uses sharp as an optional dependency
|
|
11
|
+
* to keep the framework lightweight — when sharp is unavailable, the tag
|
|
12
|
+
* gracefully degrades to a standard `<img>` element with no processing.
|
|
13
|
+
*
|
|
14
|
+
* Processed images are cached to disk so that only the first request incurs
|
|
15
|
+
* the transformation cost; subsequent requests are served at near-zero latency.
|
|
16
|
+
*/
|
|
17
|
+
class Image {
|
|
18
|
+
/** @type {Map<string, {path: string, type: string, cacheKey: string}>} In-memory index of processed images */
|
|
19
|
+
static #cache = new Map()
|
|
20
|
+
|
|
21
|
+
/** @type {Map<string, Promise>} In-flight processing promises to prevent duplicate work */
|
|
22
|
+
static #inflight = new Map()
|
|
23
|
+
|
|
24
|
+
/** @type {Map<string, number>} Source file mtime cache — eliminates per-render stat() in production */
|
|
25
|
+
static #mtimeCache = new Map()
|
|
26
|
+
|
|
27
|
+
/** @type {number} Maximum entries in the in-memory cache to prevent unbounded growth */
|
|
28
|
+
static #MAX_CACHE_SIZE = 1000
|
|
29
|
+
|
|
30
|
+
/** @type {boolean|null} Lazy-evaluated sharp availability flag */
|
|
31
|
+
static #sharpAvailable = null
|
|
32
|
+
|
|
33
|
+
/** @type {Set<string>} Supported output formats for format conversion */
|
|
34
|
+
static SUPPORTED_FORMATS = new Set(['webp', 'avif', 'png', 'jpeg', 'jpg', 'tiff'])
|
|
35
|
+
|
|
36
|
+
/** @type {Set<string>} Supported source extensions that sharp can process */
|
|
37
|
+
static SUPPORTED_INPUTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'avif', 'svg'])
|
|
38
|
+
|
|
39
|
+
/** @type {number} Default JPEG/WebP quality when not specified by the user */
|
|
40
|
+
static DEFAULT_QUALITY = 80
|
|
41
|
+
|
|
42
|
+
/** @type {number} Maximum allowed dimension to prevent abuse */
|
|
43
|
+
static MAX_DIMENSION = 4096
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checks whether sharp is installed and usable. The result is memoized
|
|
47
|
+
* so the require() probe runs at most once per process lifetime.
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
static isAvailable() {
|
|
51
|
+
if (this.#sharpAvailable !== null) return this.#sharpAvailable
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
require('sharp')
|
|
55
|
+
this.#sharpAvailable = true
|
|
56
|
+
} catch {
|
|
57
|
+
this.#sharpAvailable = false
|
|
58
|
+
console.warn('[ODAC] <odac:img> image processing is disabled. Run: npm install sharp')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return this.#sharpAvailable
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generates a deterministic hash from the image transformation parameters.
|
|
66
|
+
* Identical source + options + mtime always produce the same hash, enabling
|
|
67
|
+
* cache deduplication across templates and requests while ensuring cache
|
|
68
|
+
* invalidation when the source file changes.
|
|
69
|
+
* @param {string} src - Source image path (relative to public/)
|
|
70
|
+
* @param {object} options - Transformation options (width, height, format, quality)
|
|
71
|
+
* @param {number} [mtime=0] - Source file modification time (ms) for cache busting
|
|
72
|
+
* @returns {string} 8-character hex hash
|
|
73
|
+
*/
|
|
74
|
+
static hash(src, options = {}, mtime = 0) {
|
|
75
|
+
const payload = JSON.stringify({
|
|
76
|
+
src,
|
|
77
|
+
w: options.width || null,
|
|
78
|
+
h: options.height || null,
|
|
79
|
+
f: options.format || null,
|
|
80
|
+
q: options.quality || null,
|
|
81
|
+
m: mtime
|
|
82
|
+
})
|
|
83
|
+
return nodeCrypto.createHash('md5').update(payload).digest('hex').substring(0, 8)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Builds a human-readable cache filename from the source path and options.
|
|
88
|
+
* Pattern: {name}-{dimension}-{hash8}.{ext}
|
|
89
|
+
* Examples: logo-250-a1b2c3d4.webp, hero-o-f9e8d7c6.avif
|
|
90
|
+
*
|
|
91
|
+
* The dimension segment uses width if specified, otherwise 'o' (original).
|
|
92
|
+
* The hash suffix guarantees uniqueness across different paths, quality
|
|
93
|
+
* settings, height values, and source file versions (via mtime).
|
|
94
|
+
*
|
|
95
|
+
* @param {string} src - Source image path (e.g. '/images/logo.jpg')
|
|
96
|
+
* @param {object} options - {width, height, format, quality}
|
|
97
|
+
* @param {number} [mtime=0] - Source file modification time for cache busting
|
|
98
|
+
* @returns {string} Cache filename (e.g. 'logo-250-a1b2c3d4.webp')
|
|
99
|
+
*/
|
|
100
|
+
static buildFilename(src, options = {}, mtime = 0) {
|
|
101
|
+
const imgHash = this.hash(src, options, mtime)
|
|
102
|
+
const format = this.#resolveFormat(src, options.format)
|
|
103
|
+
const basename = path.basename(src, path.extname(src)).replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
104
|
+
const dimension = options.width ? String(parseInt(options.width, 10)) : 'o'
|
|
105
|
+
return `${basename}-${dimension}-${imgHash}.${format}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolves the output format: uses the requested format if valid,
|
|
110
|
+
* otherwise falls back to the source file's extension.
|
|
111
|
+
* @param {string} src - Source file path
|
|
112
|
+
* @param {string|null} requestedFormat - User-requested output format
|
|
113
|
+
* @returns {string} Normalized format string (e.g. 'webp', 'jpeg')
|
|
114
|
+
*/
|
|
115
|
+
static #resolveFormat(src, requestedFormat) {
|
|
116
|
+
if (requestedFormat) {
|
|
117
|
+
const normalized = requestedFormat.toLowerCase()
|
|
118
|
+
if (normalized === 'jpg') return 'jpeg'
|
|
119
|
+
if (this.SUPPORTED_FORMATS.has(normalized)) return normalized
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ext = path.extname(src).slice(1).toLowerCase()
|
|
123
|
+
if (ext === 'jpg') return 'jpeg'
|
|
124
|
+
if (this.SUPPORTED_FORMATS.has(ext)) return ext
|
|
125
|
+
|
|
126
|
+
return 'jpeg'
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Clamps a dimension value to safe bounds, preventing resource exhaustion
|
|
131
|
+
* from absurdly large resize requests.
|
|
132
|
+
* @param {string|number|null} value - Raw dimension value from template attribute
|
|
133
|
+
* @returns {number|null} Clamped integer or null if not specified
|
|
134
|
+
*/
|
|
135
|
+
static #parseDimension(value) {
|
|
136
|
+
if (!value) return null
|
|
137
|
+
const num = parseInt(value, 10)
|
|
138
|
+
if (isNaN(num) || num <= 0) return null
|
|
139
|
+
return Math.min(num, this.MAX_DIMENSION)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Processes a source image: resizes and/or converts format, then writes
|
|
144
|
+
* the result to the cache directory. Returns the cached file path.
|
|
145
|
+
*
|
|
146
|
+
* Uses sharp's pipeline API for single-pass processing (no intermediate
|
|
147
|
+
* buffers), keeping memory pressure minimal even for large images.
|
|
148
|
+
*
|
|
149
|
+
* Concurrent requests for the same variant are coalesced via an in-flight
|
|
150
|
+
* promise map, preventing duplicate sharp pipelines and file write races.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} src - Source path relative to public/ (e.g. '/images/hero.jpg')
|
|
153
|
+
* @param {object} options - {width, height, format, quality}
|
|
154
|
+
* @returns {Promise<{path: string, type: string}|null>} Cached file info or null on failure
|
|
155
|
+
*/
|
|
156
|
+
static async process(src, options = {}, mtime = 0) {
|
|
157
|
+
if (!this.isAvailable()) return null
|
|
158
|
+
|
|
159
|
+
const cacheKey = this.buildFilename(src, options, mtime)
|
|
160
|
+
|
|
161
|
+
// O(1) in-memory cache hit
|
|
162
|
+
if (this.#cache.has(cacheKey)) return this.#cache.get(cacheKey)
|
|
163
|
+
|
|
164
|
+
// Coalesce concurrent requests for the same variant
|
|
165
|
+
if (this.#inflight.has(cacheKey)) return this.#inflight.get(cacheKey)
|
|
166
|
+
|
|
167
|
+
const format = this.#resolveFormat(src, options.format)
|
|
168
|
+
const promise = this.#processInternal(src, cacheKey, format, options, mtime > 0)
|
|
169
|
+
this.#inflight.set(cacheKey, promise)
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
return await promise
|
|
173
|
+
} finally {
|
|
174
|
+
this.#inflight.delete(cacheKey)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Internal processing pipeline — separated from process() to keep the
|
|
180
|
+
* in-flight coalescing logic clean and the actual I/O isolated.
|
|
181
|
+
* @param {string} src - Source path relative to public/
|
|
182
|
+
* @param {string} cacheKey - Pre-computed cache key (hash.format)
|
|
183
|
+
* @param {string} format - Resolved output format
|
|
184
|
+
* @param {object} options - Processing options
|
|
185
|
+
* @param {boolean} sourceVerified - When true, skips the redundant access check (caller already stat'd the file)
|
|
186
|
+
* @returns {Promise<{path: string, type: string}|null>}
|
|
187
|
+
*/
|
|
188
|
+
static async #processInternal(src, cacheKey, format, options, sourceVerified = false) {
|
|
189
|
+
const cachePath = path.join(IMG_CACHE_DIR, cacheKey)
|
|
190
|
+
|
|
191
|
+
// Disk cache hit — populate in-memory index without reprocessing
|
|
192
|
+
try {
|
|
193
|
+
await fsPromises.access(cachePath)
|
|
194
|
+
const result = {path: cachePath, type: `image/${format}`, cacheKey}
|
|
195
|
+
this.#setCacheEntry(cacheKey, result)
|
|
196
|
+
return result
|
|
197
|
+
} catch {
|
|
198
|
+
// Cache miss — proceed to process
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Resolve source file from public directory
|
|
202
|
+
const baseDir = global.__dir || process.cwd()
|
|
203
|
+
const sourcePath = path.join(baseDir, 'public', src)
|
|
204
|
+
|
|
205
|
+
// Path traversal guard
|
|
206
|
+
const publicDir = path.resolve(baseDir, 'public')
|
|
207
|
+
const resolvedSource = path.resolve(sourcePath)
|
|
208
|
+
if (!resolvedSource.startsWith(publicDir + path.sep) && resolvedSource !== publicDir) {
|
|
209
|
+
console.error(`[ODAC Image] Path traversal blocked: ${src}`)
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate source extension
|
|
214
|
+
const sourceExt = path.extname(src).slice(1).toLowerCase()
|
|
215
|
+
if (!this.SUPPORTED_INPUTS.has(sourceExt)) return null
|
|
216
|
+
|
|
217
|
+
// Skip access check when render() already confirmed the file exists via stat
|
|
218
|
+
if (!sourceVerified) {
|
|
219
|
+
try {
|
|
220
|
+
await fsPromises.access(resolvedSource)
|
|
221
|
+
} catch {
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const width = this.#parseDimension(options.width)
|
|
227
|
+
const height = this.#parseDimension(options.height)
|
|
228
|
+
const quality = Math.min(Math.max(parseInt(options.quality, 10) || this.DEFAULT_QUALITY, 1), 100)
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const sharp = require('sharp')
|
|
232
|
+
let pipeline = sharp(resolvedSource)
|
|
233
|
+
|
|
234
|
+
// Resize only if dimensions are specified
|
|
235
|
+
if (width || height) {
|
|
236
|
+
pipeline = pipeline.resize(width, height, {
|
|
237
|
+
fit: 'inside',
|
|
238
|
+
withoutEnlargement: true
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Format conversion with quality setting
|
|
243
|
+
pipeline = pipeline.toFormat(format, {quality})
|
|
244
|
+
|
|
245
|
+
await fsPromises.mkdir(IMG_CACHE_DIR, {recursive: true})
|
|
246
|
+
await pipeline.toFile(cachePath)
|
|
247
|
+
|
|
248
|
+
const result = {path: cachePath, type: `image/${format}`, cacheKey}
|
|
249
|
+
this.#setCacheEntry(cacheKey, result)
|
|
250
|
+
return result
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error(`[ODAC Image] Processing failed for "${src}":`, e.message)
|
|
253
|
+
return null
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Adds an entry to the in-memory cache with FIFO eviction.
|
|
259
|
+
* When the cache exceeds MAX_CACHE_SIZE, the oldest entry (first inserted)
|
|
260
|
+
* is evicted to bound memory usage in high-variant deployments.
|
|
261
|
+
* @param {string} key - Cache key
|
|
262
|
+
* @param {{path: string, type: string}} value - Cached result
|
|
263
|
+
*/
|
|
264
|
+
static #setCacheEntry(key, value) {
|
|
265
|
+
if (this.#cache.size >= this.#MAX_CACHE_SIZE) {
|
|
266
|
+
const oldest = this.#cache.keys().next().value
|
|
267
|
+
this.#cache.delete(oldest)
|
|
268
|
+
}
|
|
269
|
+
this.#cache.set(key, value)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Serves a previously processed image by its cache hash. Called by the
|
|
274
|
+
* internal `/_odac/img/{hash}.{ext}` route handler.
|
|
275
|
+
*
|
|
276
|
+
* Returns a readable stream for zero-copy transfer to the HTTP response,
|
|
277
|
+
* avoiding full file buffering in memory.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} filename - Cache filename (e.g. 'abc123def.webp')
|
|
280
|
+
* @returns {Promise<{stream: ReadableStream, type: string, size: number}|null>}
|
|
281
|
+
*/
|
|
282
|
+
static async serve(filename) {
|
|
283
|
+
const cachePath = path.join(IMG_CACHE_DIR, filename)
|
|
284
|
+
|
|
285
|
+
// Prevent directory traversal in the filename
|
|
286
|
+
const resolvedCache = path.resolve(cachePath)
|
|
287
|
+
const cacheDir = path.resolve(IMG_CACHE_DIR)
|
|
288
|
+
if (!resolvedCache.startsWith(cacheDir + path.sep) && resolvedCache !== cacheDir) {
|
|
289
|
+
return null
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const stat = await fsPromises.stat(resolvedCache)
|
|
294
|
+
if (!stat.isFile()) return null
|
|
295
|
+
|
|
296
|
+
const ext = path.extname(filename).slice(1).toLowerCase()
|
|
297
|
+
const type = ext === 'jpg' ? 'image/jpeg' : `image/${ext}`
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
stream: fs.createReadStream(resolvedCache),
|
|
301
|
+
type,
|
|
302
|
+
size: stat.size
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Processes the image (if needed) and returns the complete `<img>` HTML tag.
|
|
311
|
+
* Called at template render time via the compiled `<odac:img>` tag output.
|
|
312
|
+
* When sharp is unavailable, gracefully degrades to a standard `<img>`.
|
|
313
|
+
*
|
|
314
|
+
* @param {object} attrs - Parsed attributes from the `<odac:img>` tag
|
|
315
|
+
* @returns {Promise<string>} Complete `<img>` HTML tag
|
|
316
|
+
*/
|
|
317
|
+
static async render(attrs) {
|
|
318
|
+
const src = attrs.src || ''
|
|
319
|
+
const format = attrs.format || global.Odac?.Config?.image?.format || null
|
|
320
|
+
const quality = attrs.quality || global.Odac?.Config?.image?.quality || null
|
|
321
|
+
|
|
322
|
+
// Attributes that control processing (not passed to HTML output)
|
|
323
|
+
const processingAttrs = new Set(['src', 'format', 'quality'])
|
|
324
|
+
|
|
325
|
+
if (!this.isAvailable() || !src) {
|
|
326
|
+
return this.#renderImgTag(src, attrs, processingAttrs)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const options = {
|
|
330
|
+
width: attrs.width || null,
|
|
331
|
+
height: attrs.height || null,
|
|
332
|
+
format,
|
|
333
|
+
quality
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Production mtime cache: eliminates a stat() syscall per render when the
|
|
337
|
+
// source file hasn't changed. Development always stats for hot-reload.
|
|
338
|
+
const isDebug = global.Odac?.Config?.debug !== false
|
|
339
|
+
const baseDir = global.__dir || process.cwd()
|
|
340
|
+
const sourcePath = path.join(baseDir, 'public', src)
|
|
341
|
+
let mtime = 0
|
|
342
|
+
|
|
343
|
+
if (!isDebug && this.#mtimeCache.has(src)) {
|
|
344
|
+
mtime = this.#mtimeCache.get(src)
|
|
345
|
+
} else {
|
|
346
|
+
try {
|
|
347
|
+
const stat = await fsPromises.stat(sourcePath)
|
|
348
|
+
mtime = stat.mtimeMs
|
|
349
|
+
if (!isDebug) this.#mtimeCache.set(src, mtime)
|
|
350
|
+
} catch {
|
|
351
|
+
// Source not found — process() will handle the error
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Trigger processing — returns immediately on cache hit
|
|
356
|
+
const result = await this.process(src, options, mtime)
|
|
357
|
+
if (!result) {
|
|
358
|
+
return this.#renderImgTag(src, attrs, processingAttrs)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Use cacheKey from process() result — avoids recomputing hash + buildFilename
|
|
362
|
+
const processedSrc = `/_odac/img/${result.cacheKey}`
|
|
363
|
+
|
|
364
|
+
return this.#renderImgTag(processedSrc, attrs, processingAttrs)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Returns the processed image URL without generating an HTML tag.
|
|
369
|
+
* Designed for use in controllers, cron jobs, mail templates, or anywhere
|
|
370
|
+
* a raw URL is needed (e.g. CSS background-image, JSON API responses).
|
|
371
|
+
*
|
|
372
|
+
* When sharp is unavailable or processing fails, returns the original
|
|
373
|
+
* source path so the caller always gets a usable URL.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} src - Source path relative to public/ (e.g. '/images/hero.jpg')
|
|
376
|
+
* @param {object} [options] - {width, height, format, quality}
|
|
377
|
+
* @returns {Promise<string>} Processed image URL or original src as fallback
|
|
378
|
+
*/
|
|
379
|
+
static async url(src, options = {}) {
|
|
380
|
+
if (!src) return ''
|
|
381
|
+
if (!this.isAvailable()) return src
|
|
382
|
+
|
|
383
|
+
const format = options.format || global.Odac?.Config?.image?.format || null
|
|
384
|
+
const quality = options.quality || global.Odac?.Config?.image?.quality || null
|
|
385
|
+
const opts = {width: options.width || null, height: options.height || null, format, quality}
|
|
386
|
+
|
|
387
|
+
const isDebug = global.Odac?.Config?.debug !== false
|
|
388
|
+
const baseDir = global.__dir || process.cwd()
|
|
389
|
+
const sourcePath = path.join(baseDir, 'public', src)
|
|
390
|
+
let mtime = 0
|
|
391
|
+
|
|
392
|
+
if (!isDebug && this.#mtimeCache.has(src)) {
|
|
393
|
+
mtime = this.#mtimeCache.get(src)
|
|
394
|
+
} else {
|
|
395
|
+
try {
|
|
396
|
+
const stat = await fsPromises.stat(sourcePath)
|
|
397
|
+
mtime = stat.mtimeMs
|
|
398
|
+
if (!isDebug) this.#mtimeCache.set(src, mtime)
|
|
399
|
+
} catch {
|
|
400
|
+
return src
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const result = await this.process(src, opts, mtime)
|
|
405
|
+
if (!result) return src
|
|
406
|
+
|
|
407
|
+
return `/_odac/img/${result.cacheKey}`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Compile-time parser that converts `<odac:img>` template tags into
|
|
412
|
+
* `<script:odac>` blocks containing runtime Image.render() calls.
|
|
413
|
+
*
|
|
414
|
+
* Runs in the View#render pipeline BEFORE jsBlocks extraction, so the
|
|
415
|
+
* generated `<script:odac>` blocks are properly protected from template
|
|
416
|
+
* literal escaping — identical to how Form.parse operates.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} content - Raw template HTML
|
|
419
|
+
* @returns {string} Template with `<odac:img>` tags replaced by `<script:odac>` blocks
|
|
420
|
+
*/
|
|
421
|
+
static parse(content) {
|
|
422
|
+
return content.replace(/<odac:img\s+([^>]*?)\/?>/g, (fullMatch, attributes) => {
|
|
423
|
+
const attrs = {}
|
|
424
|
+
const attrRegex = /(\w[\w-]*)(?:=(["'])((?:(?!\2).)*)\2|=([^\s>]+))?/g
|
|
425
|
+
let match
|
|
426
|
+
while ((match = attrRegex.exec(attributes))) {
|
|
427
|
+
const key = match[1]
|
|
428
|
+
const value = match[3] !== undefined ? match[3] : match[4] !== undefined ? match[4] : true
|
|
429
|
+
attrs[key] = value
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!attrs.src) return fullMatch
|
|
433
|
+
|
|
434
|
+
let attrsStr = JSON.stringify(attrs)
|
|
435
|
+
|
|
436
|
+
// Unquote dynamic template expressions so they become live JS at runtime
|
|
437
|
+
attrsStr = attrsStr.replace(/"\{\{([\s\S]*?)\}\}"/g, '(await Odac.Var(await $1).html())')
|
|
438
|
+
attrsStr = attrsStr.replace(/"\{!!([\s\S]*?)!!\}"/g, '(await $1)')
|
|
439
|
+
|
|
440
|
+
return `<script:odac>html += await Odac.View.Image.render(${attrsStr});</script:odac>`
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Escapes HTML special characters in attribute values to prevent XSS
|
|
446
|
+
* injection through dynamic template expressions or user-controlled input.
|
|
447
|
+
* @param {string} value - Raw attribute value
|
|
448
|
+
* @returns {string} Escaped value safe for HTML attribute context
|
|
449
|
+
*/
|
|
450
|
+
static #escapeAttr(value) {
|
|
451
|
+
if (typeof value !== 'string') return value
|
|
452
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Renders a standard HTML `<img>` tag from the given attributes,
|
|
457
|
+
* excluding processing-specific attributes (format, quality).
|
|
458
|
+
* All attribute values are HTML-escaped to prevent XSS injection.
|
|
459
|
+
* @param {string} src - The resolved src URL
|
|
460
|
+
* @param {object} attrs - All parsed attributes
|
|
461
|
+
* @param {Set<string>} exclude - Attribute names to exclude from HTML output
|
|
462
|
+
* @returns {string} HTML img tag
|
|
463
|
+
*/
|
|
464
|
+
static #renderImgTag(src, attrs, exclude) {
|
|
465
|
+
let tag = `<img src="${this.#escapeAttr(src)}"`
|
|
466
|
+
|
|
467
|
+
// Alphabetical order for deterministic output
|
|
468
|
+
const keys = Object.keys(attrs)
|
|
469
|
+
.filter(k => !exclude.has(k))
|
|
470
|
+
.sort()
|
|
471
|
+
for (const key of keys) {
|
|
472
|
+
const value = attrs[key]
|
|
473
|
+
if (value === true) {
|
|
474
|
+
tag += ` ${key}`
|
|
475
|
+
} else {
|
|
476
|
+
tag += ` ${key}="${this.#escapeAttr(value)}"`
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
tag += '>'
|
|
481
|
+
return tag
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Clears all in-memory caches (processed image index + source mtime).
|
|
486
|
+
* Useful during hot-reload in development mode to pick up re-processed
|
|
487
|
+
* images and detect source file changes immediately.
|
|
488
|
+
*/
|
|
489
|
+
static clearCache() {
|
|
490
|
+
this.#cache.clear()
|
|
491
|
+
this.#mtimeCache.clear()
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
module.exports = Image
|
package/src/View.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs')
|
|
|
3
3
|
const fsPromises = fs.promises
|
|
4
4
|
const Form = require('./View/Form')
|
|
5
5
|
const EarlyHints = require('./View/EarlyHints')
|
|
6
|
+
const Image = require('./View/Image')
|
|
6
7
|
|
|
7
8
|
const TITLE_REGEX = /<title[^>]*>([^<]*)<\/title>/i
|
|
8
9
|
|
|
@@ -119,7 +120,9 @@ class View {
|
|
|
119
120
|
this.#earlyHints = global.Odac.View.EarlyHints
|
|
120
121
|
}
|
|
121
122
|
global.Odac.View.Form = Form
|
|
123
|
+
global.Odac.View.Image = Image
|
|
122
124
|
this.Form = Form
|
|
125
|
+
this.Image = Image
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
all(name) {
|
|
@@ -404,6 +407,7 @@ class View {
|
|
|
404
407
|
|
|
405
408
|
if (content !== null) {
|
|
406
409
|
content = Form.parse(content, this.#odac)
|
|
410
|
+
content = Image.parse(content)
|
|
407
411
|
|
|
408
412
|
const jsBlocks = []
|
|
409
413
|
content = content.replace(/<script:odac([^>]*)>([\s\S]*?)<\/script:odac>/g, (match, attrs, jsContent) => {
|