dsh-intertidal 0.0.0-stage → 0.2.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/src/media.js ADDED
@@ -0,0 +1,423 @@
1
+ // 潮间 · 媒体素材(v2:用真实图片/视频当海的基底,程序化海降级为兜底)
2
+ //
3
+ // 两条来源:
4
+ // dir —— 用户指定的本地目录,随机挑一张(每次浮现换一张)
5
+ // url —— 用户给的链接;可能是直链图片,也可能是一个网页(从 og:image / 首图里挖)
6
+ //
7
+ // 三条约束:
8
+ // 1. **路径围栏**:只服务目录内的 basename,扩展名白名单之外一律不认,杜绝 `../` 穿越。
9
+ // 2. **不联网就不用网络**:`dir` 模式完全不发请求;`url` 模式只在缓存缺失时下载一次,
10
+ // 之后一直用本地缓存(离线也能继续看)。
11
+ // 3. **装载期零 IO**:本文件 import 时不读目录、不建目录、不解析 $DSH_HOME。
12
+
13
+ import { createHash } from 'node:crypto'
14
+ import { createReadStream } from 'node:fs'
15
+ import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises'
16
+ import { homedir } from 'node:os'
17
+ import { basename, extname, isAbsolute, join, resolve, sep } from 'node:path'
18
+
19
+ export const IMAGE_EXT = Object.freeze(['.jpg', '.jpeg', '.png', '.webp', '.avif', '.gif', '.bmp'])
20
+ export const VIDEO_EXT = Object.freeze(['.mp4', '.webm', '.mov', '.m4v'])
21
+
22
+ export const MIME = Object.freeze({
23
+ '.jpg': 'image/jpeg',
24
+ '.jpeg': 'image/jpeg',
25
+ '.png': 'image/png',
26
+ '.webp': 'image/webp',
27
+ '.avif': 'image/avif',
28
+ '.gif': 'image/gif',
29
+ '.bmp': 'image/bmp',
30
+ '.mp4': 'video/mp4',
31
+ '.webm': 'video/webm',
32
+ '.mov': 'video/quicktime',
33
+ '.m4v': 'video/x-m4v',
34
+ })
35
+
36
+ const MAX_DOWNLOAD = 40 * 1024 * 1024
37
+ const MAX_HTML = 2 * 1024 * 1024
38
+ const MAX_CACHE_BYTES = 200 * 1024 * 1024
39
+ const FETCH_TIMEOUT = 12_000
40
+ const CACHE_DIR_NAME = 'cache'
41
+
42
+ /** 扩展名 → 'image' | 'video' | null */
43
+ export function kindOf(name) {
44
+ const ext = extname(String(name ?? '')).toLowerCase()
45
+ if (IMAGE_EXT.includes(ext)) return 'image'
46
+ if (VIDEO_EXT.includes(ext)) return 'video'
47
+ return null
48
+ }
49
+
50
+ /** 合法的素材文件名:只有 basename,没有分隔符,扩展名在白名单内。 */
51
+ export function isMediaName(name) {
52
+ if (typeof name !== 'string' || !name) return false
53
+ if (name !== basename(name)) return false
54
+ if (name.includes('/') || name.includes('\\') || name.includes('..')) return false
55
+ if (name.startsWith('.')) return false
56
+ return kindOf(name) !== null
57
+ }
58
+
59
+ export function cacheDir(home) {
60
+ return join(home, 'intertidal', CACHE_DIR_NAME)
61
+ }
62
+
63
+ /** 目录里有哪些素材(只认白名单扩展名,跳过隐藏文件)。 */
64
+ export async function scanDir(dir) {
65
+ if (typeof dir !== 'string' || !isAbsolute(dir)) {
66
+ return { dir, ok: false, count: 0, images: 0, videos: 0, names: [], error: 'not-absolute' }
67
+ }
68
+ let entries
69
+ try {
70
+ entries = await readdir(dir, { withFileTypes: true })
71
+ } catch (error) {
72
+ return { dir, ok: false, count: 0, images: 0, videos: 0, names: [], error: String(error?.code ?? error) }
73
+ }
74
+ const names = []
75
+ for (const entry of entries) {
76
+ if (!entry.isFile()) continue
77
+ if (!isMediaName(entry.name)) continue
78
+ names.push(entry.name)
79
+ }
80
+ names.sort()
81
+ const images = names.filter((name) => kindOf(name) === 'image').length
82
+ return {
83
+ dir,
84
+ ok: true,
85
+ count: names.length,
86
+ images,
87
+ videos: names.length - images,
88
+ names: names.slice(0, 50),
89
+ }
90
+ }
91
+
92
+ /** 上一个挑到的文件(避免"随机"连着两次同一张)。 */
93
+ const lastPicked = new Map()
94
+
95
+ /** 从目录里挑一个素材;空目录返回 null。 */
96
+ export async function pickFromDir(dir, { shuffle = true } = {}) {
97
+ const scan = await scanDir(dir)
98
+ if (!scan.ok || scan.count === 0) return null
99
+ const previous = lastPicked.get(dir)
100
+ const pool = !shuffle && Array.isArray(scan.names) && scan.names.length ? scan.names : scan.names
101
+ let candidates = pool
102
+ if (pool.length > 1 && previous) candidates = pool.filter((name) => name !== previous)
103
+ const name = candidates[Math.floor(Math.random() * candidates.length)] ?? pool[0]
104
+ lastPicked.set(dir, name)
105
+ return { kind: kindOf(name), name, url: `/intertidal/media/file?name=${encodeURIComponent(name)}`, source: 'dir' }
106
+ }
107
+
108
+ /** 目录内一个文件的绝对路径;不合法或不存在返回 null(围栏的第二道:路径必须真的在目录下)。 */
109
+ export async function resolveDirFile(dir, name) {
110
+ if (typeof dir !== 'string' || !isAbsolute(dir)) return null
111
+ if (!isMediaName(name)) return null
112
+ const target = resolve(dir, name)
113
+ if (!target.startsWith(resolve(dir) + sep)) return null
114
+ try {
115
+ const info = await stat(target)
116
+ if (!info.isFile()) return null
117
+ return { path: target, size: info.size, mime: MIME[extname(name).toLowerCase()] ?? 'application/octet-stream' }
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // url 模式:下载一次、本地缓存、之后离线可用
125
+
126
+ function cacheKey(url) {
127
+ return createHash('sha1').update(url).digest('hex')
128
+ }
129
+
130
+ /** 只允许公网 http/https:拒绝 loopback 与私有网段(防 SSRF)。 */
131
+ export function isFetchableUrl(text) {
132
+ let parsed
133
+ try {
134
+ parsed = new URL(String(text))
135
+ } catch {
136
+ return false
137
+ }
138
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
139
+ const host = parsed.hostname.toLowerCase()
140
+ if (!host) return false
141
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return false
142
+ if (host === '::1' || host === '[::1]') return false
143
+ if (/^127\./.test(host)) return false
144
+ if (/^10\./.test(host)) return false
145
+ if (/^192\.168\./.test(host)) return false
146
+ if (/^169\.254\./.test(host)) return false
147
+ const private172 = /^172\.(\d{1,3})\./.exec(host)
148
+ if (private172) {
149
+ const second = Number(private172[1])
150
+ if (second >= 16 && second <= 31) return false
151
+ }
152
+ if (host.startsWith('[')) return false // 其余 IPv6 一律不接
153
+ return true
154
+ }
155
+
156
+ async function fetchWithLimit(url, { limit, accept }) {
157
+ const controller = new AbortController()
158
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT)
159
+ try {
160
+ const res = await fetch(url, {
161
+ signal: controller.signal,
162
+ redirect: 'follow',
163
+ headers: {
164
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) dsh-intertidal/0.2',
165
+ 'accept': accept,
166
+ },
167
+ })
168
+ if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status })
169
+ const length = Number(res.headers.get('content-length') ?? 0)
170
+ if (Number.isFinite(length) && length > limit) throw new Error('内容太大')
171
+ const buffer = Buffer.from(await res.arrayBuffer())
172
+ if (buffer.length > limit) throw new Error('内容太大')
173
+ return { buffer, contentType: String(res.headers.get('content-type') ?? '') }
174
+ } finally {
175
+ clearTimeout(timer)
176
+ }
177
+ }
178
+
179
+ /** 从一个网页 HTML 里挖出最像"首图"的地址。 */
180
+ export function extractImageUrl(html, baseUrl) {
181
+ const text = String(html ?? '')
182
+ const patterns = [
183
+ /<meta[^>]+property=["']og:image(?::secure_url|:url)?["'][^>]*content=["']([^"']+)["']/i,
184
+ /<meta[^>]+content=["']([^"']+)["'][^>]*property=["']og:image(?::secure_url|:url)?["']/i,
185
+ /<meta[^>]+name=["']twitter:image(?::src)?["'][^>]*content=["']([^"']+)["']/i,
186
+ /<meta[^>]+content=["']([^"']+)["'][^>]*name=["']twitter:image(?::src)?["']/i,
187
+ /<link[^>]+rel=["']image_src["'][^>]*href=["']([^"']+)["']/i,
188
+ /<img[^>]+src=["']([^"']+)["']/i,
189
+ ]
190
+ for (const pattern of patterns) {
191
+ const match = pattern.exec(text)
192
+ if (!match) continue
193
+ try {
194
+ const absolute = new URL(decodeURIComponent(match[1].replace(/&amp;/g, '&')), baseUrl)
195
+ if (absolute.protocol === 'http:' || absolute.protocol === 'https:') return absolute.href
196
+ } catch {
197
+ /* 试下一条 */
198
+ }
199
+ }
200
+ return null
201
+ }
202
+
203
+ function extFromContentType(contentType, fallbackUrl) {
204
+ const type = contentType.split(';')[0].trim().toLowerCase()
205
+ const byType = {
206
+ 'image/jpeg': '.jpg',
207
+ 'image/png': '.png',
208
+ 'image/webp': '.webp',
209
+ 'image/avif': '.avif',
210
+ 'image/gif': '.gif',
211
+ 'image/bmp': '.bmp',
212
+ 'video/mp4': '.mp4',
213
+ 'video/webm': '.webm',
214
+ 'video/quicktime': '.mov',
215
+ }[type]
216
+ if (byType) return byType
217
+ try {
218
+ const ext = extname(new URL(fallbackUrl).pathname).toLowerCase()
219
+ if (MIME[ext]) return ext
220
+ } catch {
221
+ /* ignore */
222
+ }
223
+ return null
224
+ }
225
+
226
+ /** 把总量压回上限(按修改时间从旧到新删)。 */
227
+ export async function pruneCache(dir, maxBytes = MAX_CACHE_BYTES) {
228
+ let entries
229
+ try {
230
+ entries = await readdir(dir, { withFileTypes: true })
231
+ } catch {
232
+ return { removed: 0 }
233
+ }
234
+ const files = []
235
+ for (const entry of entries) {
236
+ if (!entry.isFile()) continue
237
+ try {
238
+ const info = await stat(join(dir, entry.name))
239
+ files.push({ name: entry.name, size: info.size, mtime: info.mtimeMs })
240
+ } catch {
241
+ /* 读不到就跳过 */
242
+ }
243
+ }
244
+ files.sort((a, b) => a.mtime - b.mtime)
245
+ let total = files.reduce((sum, file) => sum + file.size, 0)
246
+ let removed = 0
247
+ for (const file of files) {
248
+ if (total <= maxBytes) break
249
+ try {
250
+ await unlink(join(dir, file.name))
251
+ total -= file.size
252
+ removed += 1
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ }
257
+ return { removed }
258
+ }
259
+
260
+ /**
261
+ * 解析一个链接 → 缓存中的素材。
262
+ * 直链图片/视频直接下载;网页则先挖 og:image 再下载。
263
+ * @returns {{kind,name,url,source}|null}
264
+ */
265
+ export async function resolveUrlToCache(url, cacheDirectory) {
266
+ if (!isFetchableUrl(url)) return null
267
+ const key = cacheKey(url)
268
+ const directory = cacheDirectory
269
+
270
+ // 已经缓存过就直接用(同一个链接不会重复下载)
271
+ try {
272
+ const names = await readdir(directory)
273
+ const hit = names.find((name) => name.startsWith(`${key}.`) && kindOf(name))
274
+ if (hit) {
275
+ const info = await stat(join(directory, hit))
276
+ if (info.size > 0) {
277
+ return { kind: kindOf(hit), name: hit, url: `/intertidal/media/file?name=${encodeURIComponent(hit)}`, source: 'url' }
278
+ }
279
+ }
280
+ } catch {
281
+ /* 目录还不存在,往下走 */
282
+ }
283
+
284
+ let fetched
285
+ try {
286
+ fetched = await fetchWithLimit(url, { limit: MAX_HTML, accept: 'image/*,video/*,text/html;q=0.8,*/*;q=0.5' })
287
+ } catch {
288
+ return null
289
+ }
290
+
291
+ const isHtml = fetched.contentType.startsWith('text/html')
292
+ let payload = fetched.buffer
293
+ let contentType = fetched.contentType
294
+ let sourceUrl = url
295
+
296
+ if (isHtml) {
297
+ const imageUrl = extractImageUrl(fetched.buffer.toString('utf8'), url)
298
+ if (!imageUrl || !isFetchableUrl(imageUrl)) return null
299
+ try {
300
+ const image = await fetchWithLimit(imageUrl, { limit: MAX_DOWNLOAD, accept: 'image/*,video/*,*/*;q=0.5' })
301
+ payload = image.buffer
302
+ contentType = image.contentType
303
+ sourceUrl = imageUrl
304
+ } catch {
305
+ return null
306
+ }
307
+ }
308
+
309
+ const ext = extFromContentType(contentType, sourceUrl)
310
+ if (!ext) return null
311
+
312
+ const name = `${key}${ext}`
313
+ try {
314
+ await mkdir(directory, { recursive: true, mode: 0o700 })
315
+ await writeFile(join(directory, name), payload, { mode: 0o600 })
316
+ } catch {
317
+ return null
318
+ }
319
+ void pruneCache(directory)
320
+ return { kind: kindOf(name), name, url: `/intertidal/media/file?name=${encodeURIComponent(name)}`, source: 'url' }
321
+ }
322
+
323
+ /** 缓存目录里挑一个(url 模式下的随机换图)。 */
324
+ export async function pickFromCache(directory) {
325
+ let names
326
+ try {
327
+ names = await readdir(directory)
328
+ } catch {
329
+ return null
330
+ }
331
+ const usable = names.filter(isMediaName)
332
+ if (!usable.length) return null
333
+ const name = usable[Math.floor(Math.random() * usable.length)]
334
+ return { kind: kindOf(name), name, url: `/intertidal/media/file?name=${encodeURIComponent(name)}`, source: 'url' }
335
+ }
336
+
337
+ /** 打开一个媒体文件流(支持 Range,视频才能拖/循环)。 */
338
+ export async function openMediaFile(directory, name) {
339
+ if (!isMediaName(name)) return null
340
+ const target = resolve(directory, name)
341
+ if (!target.startsWith(resolve(directory) + sep)) return null
342
+ try {
343
+ const info = await stat(target)
344
+ if (!info.isFile()) return null
345
+ return {
346
+ stream: (range) => createReadStream(target, range),
347
+ size: info.size,
348
+ mime: MIME[extname(name).toLowerCase()] ?? 'application/octet-stream',
349
+ path: target,
350
+ }
351
+ } catch {
352
+ return null
353
+ }
354
+ }
355
+
356
+ /** 读一点点字节做图片尺寸嗅探(只认 PNG/JPEG/GIF/WebP 四类头,认不出就返回 null)。 */
357
+ export async function sniffSize(path) {
358
+ let buffer
359
+ try {
360
+ const handle = await readFile(path)
361
+ buffer = handle.subarray(0, 64 * 1024)
362
+ } catch {
363
+ return null
364
+ }
365
+ try {
366
+ // PNG
367
+ if (buffer.length >= 24 && buffer.readUInt32BE(0) === 0x89504e47) {
368
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
369
+ }
370
+ // GIF
371
+ if (buffer.length >= 10 && buffer.toString('ascii', 0, 3) === 'GIF') {
372
+ return { width: buffer.readUInt16LE(6), height: buffer.readUInt16LE(8) }
373
+ }
374
+ // WebP
375
+ if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
376
+ const chunk = buffer.toString('ascii', 12, 16)
377
+ if (chunk === 'VP8X') {
378
+ const width = 1 + (buffer.readUIntLE(24, 3) & 0xffffff)
379
+ const height = 1 + (buffer.readUIntLE(27, 3) & 0xffffff)
380
+ return { width, height }
381
+ }
382
+ if (chunk === 'VP8 ') {
383
+ return { width: buffer.readUInt16LE(26) & 0x3fff, height: buffer.readUInt16LE(28) & 0x3fff }
384
+ }
385
+ if (chunk === 'VP8L') {
386
+ const bits = buffer.readUInt32LE(21)
387
+ return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) }
388
+ }
389
+ }
390
+ // JPEG:扫 SOFn
391
+ if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
392
+ let offset = 2
393
+ while (offset + 9 < buffer.length) {
394
+ if (buffer[offset] !== 0xff) {
395
+ offset += 1
396
+ continue
397
+ }
398
+ const marker = buffer[offset + 1]
399
+ const length = buffer.readUInt16BE(offset + 2)
400
+ const isSof = marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)
401
+ if (isSof) {
402
+ return { height: buffer.readUInt16BE(offset + 5), width: buffer.readUInt16BE(offset + 7) }
403
+ }
404
+ offset += 2 + length
405
+ }
406
+ }
407
+ } catch {
408
+ /* 认不出就当没有 */
409
+ }
410
+ return null
411
+ }
412
+
413
+ export const MEDIA_LIMITS = Object.freeze({ MAX_DOWNLOAD, MAX_HTML, MAX_CACHE_BYTES })
414
+
415
+ /** 给测试用:清掉"上次挑到哪个"的记忆。 */
416
+ export function forgetPicks() {
417
+ lastPicked.clear()
418
+ }
419
+
420
+ /** 默认主页(测试可改 $DSH_HOME 后再取)。 */
421
+ export function defaultDshHome() {
422
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh')
423
+ }