@capyclaw/asset-service 0.1.0 → 0.1.1
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/dsh-suite.json +6 -4
- package/package.json +15 -2
- package/src/index.mjs +217 -217
package/dsh-suite.json
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
"title": "Asset Service",
|
|
6
6
|
"description": "通用对象资产:sha256 仓库、/assets 路由、上传。",
|
|
7
7
|
"packages": {
|
|
8
|
-
"
|
|
8
|
+
"@capyclaw/asset-service": {
|
|
9
9
|
"role": "provider",
|
|
10
|
-
"version": "^0.1.
|
|
10
|
+
"version": "^0.1.1",
|
|
11
11
|
"path": ".",
|
|
12
12
|
"provides": {
|
|
13
|
-
"services": [
|
|
13
|
+
"services": [
|
|
14
|
+
"assets"
|
|
15
|
+
],
|
|
14
16
|
"capabilities": {
|
|
15
17
|
"assets.store": "1.0.0"
|
|
16
18
|
}
|
|
@@ -20,7 +22,7 @@
|
|
|
20
22
|
"rows": [
|
|
21
23
|
{
|
|
22
24
|
"id": "asset-service",
|
|
23
|
-
"name": "
|
|
25
|
+
"name": "@capyclaw/asset-service"
|
|
24
26
|
}
|
|
25
27
|
],
|
|
26
28
|
"checks": []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capyclaw/asset-service",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "DSH 公共资产插件:sha256 对象仓库、/assets 路由、base64 上传与 content-type,向插件提供 ctx.assets。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/host.mjs",
|
|
@@ -9,12 +9,25 @@
|
|
|
9
9
|
"./src": "./src/index.mjs",
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18",
|
|
14
|
+
"dsh": "^0.2.0-rc.2"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
12
19
|
"dsh": {
|
|
13
20
|
"bundle": {
|
|
14
21
|
"patch": "./cordis.patch.yml"
|
|
15
22
|
},
|
|
16
23
|
"suite": "./dsh-suite.json",
|
|
17
|
-
"apiVersion": "1.0.0"
|
|
24
|
+
"apiVersion": "1.0.0",
|
|
25
|
+
"category": "service",
|
|
26
|
+
"platform": "both"
|
|
27
|
+
},
|
|
28
|
+
"meta": {
|
|
29
|
+
"title": "公共资产服务",
|
|
30
|
+
"description": "sha256 对象仓库、/assets 路由、base64 上传与 content-type,向插件提供 ctx.assets"
|
|
18
31
|
},
|
|
19
32
|
"files": [
|
|
20
33
|
"lib/",
|
package/src/index.mjs
CHANGED
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-asset-service —— 通用对象资产仓库。
|
|
3
|
-
*
|
|
4
|
-
* 功能插件不再各自实现 `/assets/<sha256>`、base64 上传、content-type 与缓存头。
|
|
5
|
-
* 资产按 sha256 去重,url 为 `/assets/<id>`。
|
|
6
|
-
*
|
|
7
|
-
* 两种后端:
|
|
8
|
-
* - workspace:storeClient(ctx.workspace 的 scoped client),资产归当前 work;
|
|
9
|
-
* - file:本地目录,适合无 workspace 的独立运行/测试。
|
|
10
|
-
*/
|
|
11
|
-
import crypto from 'node:crypto'
|
|
12
|
-
import fs from 'node:fs'
|
|
13
|
-
import path from 'node:path'
|
|
14
|
-
import { send, readBody } from '@capyclaw/plugin-kit/service/http'
|
|
15
|
-
import { createServiceApi } from '@capyclaw/plugin-kit/service/api'
|
|
16
|
-
|
|
17
|
-
function sha256(buffer) {
|
|
18
|
-
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const ASSET_ID_RE = /^[a-f0-9]{64}$/
|
|
22
|
-
|
|
23
|
-
function validId(id) {
|
|
24
|
-
return ASSET_ID_RE.test(String(id || ''))
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function normalizeBuffer(value) {
|
|
28
|
-
if (Buffer.isBuffer(value)) return value
|
|
29
|
-
if (value instanceof Uint8Array) return Buffer.from(value)
|
|
30
|
-
throw new Error('asset: 数据必须是 Buffer/Uint8Array')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** 基于 ctx.workspace 的 scoped WorkspaceClient。 */
|
|
34
|
-
export function createWorkspaceAssetStore({ storeClient, workId = 'main' } = {}) {
|
|
35
|
-
if (!storeClient || typeof storeClient.put !== 'function') throw new Error('createWorkspaceAssetStore 需要 storeClient')
|
|
36
|
-
return {
|
|
37
|
-
kind: 'workspace',
|
|
38
|
-
async put(value, contentType = 'application/octet-stream') {
|
|
39
|
-
const buffer = normalizeBuffer(value)
|
|
40
|
-
const id = sha256(buffer)
|
|
41
|
-
const key = `assets/${id}`
|
|
42
|
-
storeClient.put(workId, key, buffer, { contentType })
|
|
43
|
-
return { id, key, url: `/assets/${id}`, size: buffer.length, contentType }
|
|
44
|
-
},
|
|
45
|
-
get(id) {
|
|
46
|
-
if (!validId(id)) return null
|
|
47
|
-
const key = `assets/${id}`
|
|
48
|
-
try {
|
|
49
|
-
const data = storeClient.get(workId, key)
|
|
50
|
-
if (!data) return null
|
|
51
|
-
const meta = storeClient.list(workId).find((item) => item.key === key)
|
|
52
|
-
return { data, contentType: meta?.contentType || 'application/octet-stream' }
|
|
53
|
-
} catch {
|
|
54
|
-
/* 缺失/权限错误按 404 处理,不能把 500 泄漏给资产路由。 */
|
|
55
|
-
return null
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
has(id) {
|
|
59
|
-
if (!validId(id)) return false
|
|
60
|
-
try { return !!storeClient.get(workId, `assets/${id}`) } catch { return false }
|
|
61
|
-
},
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** 本地目录后端:<dir>/<sha256> + <dir>/<sha256>.meta.json。 */
|
|
66
|
-
export function createFileAssetStore({ dir } = {}) {
|
|
67
|
-
if (!dir) throw new Error('createFileAssetStore 需要 dir')
|
|
68
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
69
|
-
return {
|
|
70
|
-
kind: 'file',
|
|
71
|
-
async put(value, contentType = 'application/octet-stream') {
|
|
72
|
-
const buffer = normalizeBuffer(value)
|
|
73
|
-
const id = sha256(buffer)
|
|
74
|
-
const file = path.join(dir, id)
|
|
75
|
-
const metaFile = file + '.meta.json'
|
|
76
|
-
const existingOk = (() => {
|
|
77
|
-
try { return fs.statSync(file).size === buffer.length } catch { return false }
|
|
78
|
-
})()
|
|
79
|
-
if (!existingOk) {
|
|
80
|
-
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
81
|
-
fs.writeFileSync(tmp, buffer)
|
|
82
|
-
fs.renameSync(tmp, file)
|
|
83
|
-
}
|
|
84
|
-
const metaTmp = `${metaFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
85
|
-
fs.writeFileSync(metaTmp, JSON.stringify({ contentType, size: buffer.length }))
|
|
86
|
-
fs.renameSync(metaTmp, metaFile)
|
|
87
|
-
return { id, key: id, url: `/assets/${id}`, size: buffer.length, contentType }
|
|
88
|
-
},
|
|
89
|
-
get(id) {
|
|
90
|
-
if (!validId(id)) return null
|
|
91
|
-
const file = path.join(dir, path.basename(String(id)))
|
|
92
|
-
try {
|
|
93
|
-
const data = fs.readFileSync(file)
|
|
94
|
-
/* 内容寻址仓库必须复核 hash:截断/篡改的对象不能静默返回。 */
|
|
95
|
-
if (sha256(data) !== id) {
|
|
96
|
-
console.error(`[asset-service] 对象校验失败,拒绝返回: ${id}`)
|
|
97
|
-
return null
|
|
98
|
-
}
|
|
99
|
-
let contentType = 'application/octet-stream'
|
|
100
|
-
try { contentType = JSON.parse(fs.readFileSync(file + '.meta.json', 'utf8')).contentType || contentType } catch { /* 默认 */ }
|
|
101
|
-
return { data, contentType }
|
|
102
|
-
} catch {
|
|
103
|
-
return null
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
has(id) {
|
|
107
|
-
if (!validId(id)) return false
|
|
108
|
-
try { return fs.existsSync(path.join(dir, path.basename(String(id)))) } catch { return false }
|
|
109
|
-
},
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** 防止 text/html / svg 在 loopback 同源执行脚本;图片仍按原 MIME 内联。 */
|
|
114
|
-
function safeContentType(type) {
|
|
115
|
-
const base = String(type || '').split(';')[0].trim().toLowerCase() || 'application/octet-stream'
|
|
116
|
-
if (base === 'text/html' || base === 'application/xhtml+xml' || base === 'image/svg+xml') {
|
|
117
|
-
return 'text/plain; charset=utf-8'
|
|
118
|
-
}
|
|
119
|
-
return base
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* service-host 兼容的资产路由。
|
|
124
|
-
* GET/HEAD `/assets/<id>`(同时兼容 `<basePath>/assets/<id>`);
|
|
125
|
-
* POST `/assets` 或 `<basePath>/assets`(JSON: { data: base64, contentType })。
|
|
126
|
-
*/
|
|
127
|
-
export function createAssetRoutes({ assetStore, basePath = '', maxUploadBytes = 10 * 1024 * 1024 } = {}) {
|
|
128
|
-
const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
|
|
129
|
-
const getPrefixes = ['/assets/', prefix ? `${prefix}/assets/` : null].filter(Boolean)
|
|
130
|
-
const postPaths = new Set(['/assets', prefix ? `${prefix}/assets` : null].filter(Boolean))
|
|
131
|
-
return async function assetRoutes(req, res, u) {
|
|
132
|
-
const p = u.pathname
|
|
133
|
-
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
134
|
-
const getPrefix = getPrefixes.find((candidate) => p.startsWith(candidate))
|
|
135
|
-
if (getPrefix) {
|
|
136
|
-
if (!assetStore) { send(req, res, 404, { error: 'asset store unavailable' }); return true }
|
|
137
|
-
const id = decodeURIComponent(p.slice(getPrefix.length))
|
|
138
|
-
if (!validId(id)) { send(req, res, 404, { error: 'asset not found' }); return true }
|
|
139
|
-
const asset = assetStore.get(id)
|
|
140
|
-
if (!asset) { send(req, res, 404, { error: 'asset not found' }); return true }
|
|
141
|
-
res.writeHead(200, {
|
|
142
|
-
'content-type': safeContentType(asset.contentType),
|
|
143
|
-
'content-length': asset.data.length,
|
|
144
|
-
'cache-control': 'public, max-age=31536000, immutable',
|
|
145
|
-
'x-content-type-options': 'nosniff',
|
|
146
|
-
'referrer-policy': 'no-referrer',
|
|
147
|
-
})
|
|
148
|
-
res.end(req.method === 'HEAD' ? undefined : asset.data)
|
|
149
|
-
return true
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (postPaths.has(p) && req.method === 'POST') {
|
|
153
|
-
if (!assetStore) { send(req, res, 503, { error: 'asset store unavailable' }); return true }
|
|
154
|
-
let body
|
|
155
|
-
try { body = await readBody(req, maxUploadBytes) } catch (error) {
|
|
156
|
-
const status = error?.code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
157
|
-
send(req, res, status, { error: String(error.message || error) })
|
|
158
|
-
return true
|
|
159
|
-
}
|
|
160
|
-
if (!body || typeof body.data !== 'string' || !body.data) {
|
|
161
|
-
send(req, res, 400, { error: '缺少图片数据' })
|
|
162
|
-
return true
|
|
163
|
-
}
|
|
164
|
-
const buffer = Buffer.from(body.data, 'base64')
|
|
165
|
-
if (!buffer.length) { send(req, res, 400, { error: '图片数据为空' }); return true }
|
|
166
|
-
const asset = await assetStore.put(buffer, body.contentType || 'application/octet-stream')
|
|
167
|
-
send(req, res, 200, { ok: true, ...asset })
|
|
168
|
-
return true
|
|
169
|
-
}
|
|
170
|
-
return false
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function createAssetHost(config = {}) {
|
|
175
|
-
const defaultStore = config.storeClient
|
|
176
|
-
? createWorkspaceAssetStore({ storeClient: config.storeClient, workId: config.workId })
|
|
177
|
-
: config.dir
|
|
178
|
-
? createFileAssetStore({ dir: config.dir })
|
|
179
|
-
: null
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
api: createServiceApi('assets', '1.0.0', ['workspace-store', 'file-store', 'routes', 'sha256', 'mount']),
|
|
183
|
-
version: '0.1.0',
|
|
184
|
-
createStore: createWorkspaceAssetStore,
|
|
185
|
-
createFileStore: createFileAssetStore,
|
|
186
|
-
routes: createAssetRoutes,
|
|
187
|
-
store: defaultStore,
|
|
188
|
-
/**
|
|
189
|
-
* 每个 service-host app 只挂一次资产路由,避免多个插件重复争抢 /assets。
|
|
190
|
-
* @param {object} hostApp createServiceApp() 返回值
|
|
191
|
-
* @param {{ store?: object, basePath?: string }} options
|
|
192
|
-
*/
|
|
193
|
-
mount(hostApp, { store = defaultStore, basePath = '' } = {}) {
|
|
194
|
-
if (!hostApp || typeof hostApp.use !== 'function') throw new Error('assets.mount 需要 service-host app')
|
|
195
|
-
if (!store) throw new Error('assets.mount: 没有可用 store')
|
|
196
|
-
if (hostApp.__dshAssetsMounted) return () => {}
|
|
197
|
-
hostApp.__dshAssetsMounted = true
|
|
198
|
-
const unuse = hostApp.use(createAssetRoutes({ assetStore: store, basePath }))
|
|
199
|
-
let disposed = false
|
|
200
|
-
return () => {
|
|
201
|
-
if (disposed) return
|
|
202
|
-
disposed = true
|
|
203
|
-
/* 真正卸载路由;否则热重载后旧 store 仍会抢答 /assets。 */
|
|
204
|
-
if (typeof unuse === 'function') unuse()
|
|
205
|
-
hostApp.__dshAssetsMounted = false
|
|
206
|
-
}
|
|
207
|
-
},
|
|
208
|
-
async put(value, contentType) {
|
|
209
|
-
if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
|
|
210
|
-
return defaultStore.put(value, contentType)
|
|
211
|
-
},
|
|
212
|
-
get(id) {
|
|
213
|
-
if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
|
|
214
|
-
return defaultStore.get(id)
|
|
215
|
-
},
|
|
216
|
-
}
|
|
217
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-asset-service —— 通用对象资产仓库。
|
|
3
|
+
*
|
|
4
|
+
* 功能插件不再各自实现 `/assets/<sha256>`、base64 上传、content-type 与缓存头。
|
|
5
|
+
* 资产按 sha256 去重,url 为 `/assets/<id>`。
|
|
6
|
+
*
|
|
7
|
+
* 两种后端:
|
|
8
|
+
* - workspace:storeClient(ctx.workspace 的 scoped client),资产归当前 work;
|
|
9
|
+
* - file:本地目录,适合无 workspace 的独立运行/测试。
|
|
10
|
+
*/
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { send, readBody } from '@capyclaw/plugin-kit/service/http'
|
|
15
|
+
import { createServiceApi } from '@capyclaw/plugin-kit/service/api'
|
|
16
|
+
|
|
17
|
+
function sha256(buffer) {
|
|
18
|
+
return crypto.createHash('sha256').update(buffer).digest('hex')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ASSET_ID_RE = /^[a-f0-9]{64}$/
|
|
22
|
+
|
|
23
|
+
function validId(id) {
|
|
24
|
+
return ASSET_ID_RE.test(String(id || ''))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function normalizeBuffer(value) {
|
|
28
|
+
if (Buffer.isBuffer(value)) return value
|
|
29
|
+
if (value instanceof Uint8Array) return Buffer.from(value)
|
|
30
|
+
throw new Error('asset: 数据必须是 Buffer/Uint8Array')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 基于 ctx.workspace 的 scoped WorkspaceClient。 */
|
|
34
|
+
export function createWorkspaceAssetStore({ storeClient, workId = 'main' } = {}) {
|
|
35
|
+
if (!storeClient || typeof storeClient.put !== 'function') throw new Error('createWorkspaceAssetStore 需要 storeClient')
|
|
36
|
+
return {
|
|
37
|
+
kind: 'workspace',
|
|
38
|
+
async put(value, contentType = 'application/octet-stream') {
|
|
39
|
+
const buffer = normalizeBuffer(value)
|
|
40
|
+
const id = sha256(buffer)
|
|
41
|
+
const key = `assets/${id}`
|
|
42
|
+
storeClient.put(workId, key, buffer, { contentType })
|
|
43
|
+
return { id, key, url: `/assets/${id}`, size: buffer.length, contentType }
|
|
44
|
+
},
|
|
45
|
+
get(id) {
|
|
46
|
+
if (!validId(id)) return null
|
|
47
|
+
const key = `assets/${id}`
|
|
48
|
+
try {
|
|
49
|
+
const data = storeClient.get(workId, key)
|
|
50
|
+
if (!data) return null
|
|
51
|
+
const meta = storeClient.list(workId).find((item) => item.key === key)
|
|
52
|
+
return { data, contentType: meta?.contentType || 'application/octet-stream' }
|
|
53
|
+
} catch {
|
|
54
|
+
/* 缺失/权限错误按 404 处理,不能把 500 泄漏给资产路由。 */
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
has(id) {
|
|
59
|
+
if (!validId(id)) return false
|
|
60
|
+
try { return !!storeClient.get(workId, `assets/${id}`) } catch { return false }
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 本地目录后端:<dir>/<sha256> + <dir>/<sha256>.meta.json。 */
|
|
66
|
+
export function createFileAssetStore({ dir } = {}) {
|
|
67
|
+
if (!dir) throw new Error('createFileAssetStore 需要 dir')
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
69
|
+
return {
|
|
70
|
+
kind: 'file',
|
|
71
|
+
async put(value, contentType = 'application/octet-stream') {
|
|
72
|
+
const buffer = normalizeBuffer(value)
|
|
73
|
+
const id = sha256(buffer)
|
|
74
|
+
const file = path.join(dir, id)
|
|
75
|
+
const metaFile = file + '.meta.json'
|
|
76
|
+
const existingOk = (() => {
|
|
77
|
+
try { return fs.statSync(file).size === buffer.length } catch { return false }
|
|
78
|
+
})()
|
|
79
|
+
if (!existingOk) {
|
|
80
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
81
|
+
fs.writeFileSync(tmp, buffer)
|
|
82
|
+
fs.renameSync(tmp, file)
|
|
83
|
+
}
|
|
84
|
+
const metaTmp = `${metaFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
85
|
+
fs.writeFileSync(metaTmp, JSON.stringify({ contentType, size: buffer.length }))
|
|
86
|
+
fs.renameSync(metaTmp, metaFile)
|
|
87
|
+
return { id, key: id, url: `/assets/${id}`, size: buffer.length, contentType }
|
|
88
|
+
},
|
|
89
|
+
get(id) {
|
|
90
|
+
if (!validId(id)) return null
|
|
91
|
+
const file = path.join(dir, path.basename(String(id)))
|
|
92
|
+
try {
|
|
93
|
+
const data = fs.readFileSync(file)
|
|
94
|
+
/* 内容寻址仓库必须复核 hash:截断/篡改的对象不能静默返回。 */
|
|
95
|
+
if (sha256(data) !== id) {
|
|
96
|
+
console.error(`[asset-service] 对象校验失败,拒绝返回: ${id}`)
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
let contentType = 'application/octet-stream'
|
|
100
|
+
try { contentType = JSON.parse(fs.readFileSync(file + '.meta.json', 'utf8')).contentType || contentType } catch { /* 默认 */ }
|
|
101
|
+
return { data, contentType }
|
|
102
|
+
} catch {
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
has(id) {
|
|
107
|
+
if (!validId(id)) return false
|
|
108
|
+
try { return fs.existsSync(path.join(dir, path.basename(String(id)))) } catch { return false }
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 防止 text/html / svg 在 loopback 同源执行脚本;图片仍按原 MIME 内联。 */
|
|
114
|
+
function safeContentType(type) {
|
|
115
|
+
const base = String(type || '').split(';')[0].trim().toLowerCase() || 'application/octet-stream'
|
|
116
|
+
if (base === 'text/html' || base === 'application/xhtml+xml' || base === 'image/svg+xml') {
|
|
117
|
+
return 'text/plain; charset=utf-8'
|
|
118
|
+
}
|
|
119
|
+
return base
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* service-host 兼容的资产路由。
|
|
124
|
+
* GET/HEAD `/assets/<id>`(同时兼容 `<basePath>/assets/<id>`);
|
|
125
|
+
* POST `/assets` 或 `<basePath>/assets`(JSON: { data: base64, contentType })。
|
|
126
|
+
*/
|
|
127
|
+
export function createAssetRoutes({ assetStore, basePath = '', maxUploadBytes = 10 * 1024 * 1024 } = {}) {
|
|
128
|
+
const prefix = basePath === '/' ? '' : String(basePath || '').replace(/\/+$/, '')
|
|
129
|
+
const getPrefixes = ['/assets/', prefix ? `${prefix}/assets/` : null].filter(Boolean)
|
|
130
|
+
const postPaths = new Set(['/assets', prefix ? `${prefix}/assets` : null].filter(Boolean))
|
|
131
|
+
return async function assetRoutes(req, res, u) {
|
|
132
|
+
const p = u.pathname
|
|
133
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
134
|
+
const getPrefix = getPrefixes.find((candidate) => p.startsWith(candidate))
|
|
135
|
+
if (getPrefix) {
|
|
136
|
+
if (!assetStore) { send(req, res, 404, { error: 'asset store unavailable' }); return true }
|
|
137
|
+
const id = decodeURIComponent(p.slice(getPrefix.length))
|
|
138
|
+
if (!validId(id)) { send(req, res, 404, { error: 'asset not found' }); return true }
|
|
139
|
+
const asset = assetStore.get(id)
|
|
140
|
+
if (!asset) { send(req, res, 404, { error: 'asset not found' }); return true }
|
|
141
|
+
res.writeHead(200, {
|
|
142
|
+
'content-type': safeContentType(asset.contentType),
|
|
143
|
+
'content-length': asset.data.length,
|
|
144
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
145
|
+
'x-content-type-options': 'nosniff',
|
|
146
|
+
'referrer-policy': 'no-referrer',
|
|
147
|
+
})
|
|
148
|
+
res.end(req.method === 'HEAD' ? undefined : asset.data)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (postPaths.has(p) && req.method === 'POST') {
|
|
153
|
+
if (!assetStore) { send(req, res, 503, { error: 'asset store unavailable' }); return true }
|
|
154
|
+
let body
|
|
155
|
+
try { body = await readBody(req, maxUploadBytes) } catch (error) {
|
|
156
|
+
const status = error?.code === 'BODY_TOO_LARGE' ? 413 : 400
|
|
157
|
+
send(req, res, status, { error: String(error.message || error) })
|
|
158
|
+
return true
|
|
159
|
+
}
|
|
160
|
+
if (!body || typeof body.data !== 'string' || !body.data) {
|
|
161
|
+
send(req, res, 400, { error: '缺少图片数据' })
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
const buffer = Buffer.from(body.data, 'base64')
|
|
165
|
+
if (!buffer.length) { send(req, res, 400, { error: '图片数据为空' }); return true }
|
|
166
|
+
const asset = await assetStore.put(buffer, body.contentType || 'application/octet-stream')
|
|
167
|
+
send(req, res, 200, { ok: true, ...asset })
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function createAssetHost(config = {}) {
|
|
175
|
+
const defaultStore = config.storeClient
|
|
176
|
+
? createWorkspaceAssetStore({ storeClient: config.storeClient, workId: config.workId })
|
|
177
|
+
: config.dir
|
|
178
|
+
? createFileAssetStore({ dir: config.dir })
|
|
179
|
+
: null
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
api: createServiceApi('assets', '1.0.0', ['workspace-store', 'file-store', 'routes', 'sha256', 'mount']),
|
|
183
|
+
version: '0.1.0',
|
|
184
|
+
createStore: createWorkspaceAssetStore,
|
|
185
|
+
createFileStore: createFileAssetStore,
|
|
186
|
+
routes: createAssetRoutes,
|
|
187
|
+
store: defaultStore,
|
|
188
|
+
/**
|
|
189
|
+
* 每个 service-host app 只挂一次资产路由,避免多个插件重复争抢 /assets。
|
|
190
|
+
* @param {object} hostApp createServiceApp() 返回值
|
|
191
|
+
* @param {{ store?: object, basePath?: string }} options
|
|
192
|
+
*/
|
|
193
|
+
mount(hostApp, { store = defaultStore, basePath = '' } = {}) {
|
|
194
|
+
if (!hostApp || typeof hostApp.use !== 'function') throw new Error('assets.mount 需要 service-host app')
|
|
195
|
+
if (!store) throw new Error('assets.mount: 没有可用 store')
|
|
196
|
+
if (hostApp.__dshAssetsMounted) return () => {}
|
|
197
|
+
hostApp.__dshAssetsMounted = true
|
|
198
|
+
const unuse = hostApp.use(createAssetRoutes({ assetStore: store, basePath }))
|
|
199
|
+
let disposed = false
|
|
200
|
+
return () => {
|
|
201
|
+
if (disposed) return
|
|
202
|
+
disposed = true
|
|
203
|
+
/* 真正卸载路由;否则热重载后旧 store 仍会抢答 /assets。 */
|
|
204
|
+
if (typeof unuse === 'function') unuse()
|
|
205
|
+
hostApp.__dshAssetsMounted = false
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async put(value, contentType) {
|
|
209
|
+
if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
|
|
210
|
+
return defaultStore.put(value, contentType)
|
|
211
|
+
},
|
|
212
|
+
get(id) {
|
|
213
|
+
if (!defaultStore) throw new Error('asset-service: 未配置 storeClient/dir')
|
|
214
|
+
return defaultStore.get(id)
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
}
|