@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 CHANGED
@@ -5,12 +5,14 @@
5
5
  "title": "Asset Service",
6
6
  "description": "通用对象资产:sha256 仓库、/assets 路由、上传。",
7
7
  "packages": {
8
- "dsh-asset-service": {
8
+ "@capyclaw/asset-service": {
9
9
  "role": "provider",
10
- "version": "^0.1.0",
10
+ "version": "^0.1.1",
11
11
  "path": ".",
12
12
  "provides": {
13
- "services": ["assets"],
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": "dsh-asset-service"
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.0",
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
+ }