@dickpy/dsh-imagegen 1.5.3 → 1.5.5

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/routes.ts CHANGED
@@ -6,7 +6,11 @@
6
6
  */
7
7
 
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
+ import { spawn } from 'node:child_process'
10
+ import { mkdir as fsMkdir } from 'node:fs/promises'
9
11
  import { randomUUID } from 'node:crypto'
12
+ import { homedir } from 'node:os'
13
+ import path from 'node:path'
10
14
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
15
  import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
12
16
  import { SettingsConflictError, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
@@ -16,11 +20,13 @@ import { normalizeImageModels } from './image-models.ts'
16
20
  import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
17
21
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
18
22
  import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
23
+ import { canvasStore, CanvasConflictError, type CanvasImageInput, type CanvasStore } from './canvas-store.ts'
19
24
  import { listTemplates, readTemplateImage, refreshTemplates, sampleTemplates } from './templates-store.ts'
20
25
  import { addTemplateFavorite, listTemplateFavorites, removeTemplateFavorite } from './template-favorites.ts'
26
+ import { testStorage, type StorageSyncConfig } from './storage-sync.ts'
21
27
  import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
22
28
  import { IMAGE_PRESETS } from './presets.ts'
23
- import { AGENT_IMAGE_API, CONVERSATION_IMAGE_API, DEFAULT_TEMPLATE_SOURCE_ID, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATE_FAVORITES_API, TEMPLATES_API, UPDATE_API, USAGE_API, isTemplateSourceId, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateFavorite, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
29
+ import { AGENT_IMAGE_API, CANVAS_API, CONVERSATION_IMAGE_API, DATA_FOLDER_API, DEFAULT_TEMPLATE_SOURCE_ID, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, STORAGE_API, TASK_API, TEMPLATE_FAVORITES_API, TEMPLATES_API, UPDATE_API, USAGE_API, isTemplateSourceId, type CanvasDocument, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateFavorite, type TemplateListResult, type TemplateRefreshResult, type TemplateSample } from './protocol.ts'
24
30
 
25
31
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
26
32
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -73,6 +79,8 @@ export interface ImageGenRoutesDeps {
73
79
  updateTags?: (id: string, tags: string[]) => Promise<HistoryEntry[]>
74
80
  readImage: (file: string) => Promise<{ data: Buffer; mime: string } | undefined>
75
81
  }
82
+ /** Overrideable canvas backend, primarily for host integration tests. */
83
+ canvas?: CanvasBackend
76
84
  /** Overrideable template-library backend, primarily for host integration tests. */
77
85
  templates?: {
78
86
  list: (sourceId: string) => Promise<TemplateListResult>
@@ -86,10 +94,23 @@ export interface ImageGenRoutesDeps {
86
94
  add: (sourceId: string, item: TemplateFavorite['case']) => Promise<TemplateFavorite[]>
87
95
  remove: (key: string) => Promise<TemplateFavorite[]>
88
96
  }
97
+ /** Resolve the object-storage sync settings (with the real secret). */
98
+ resolveStorage?: () => StorageSyncConfig
89
99
  /** Shared host queue, used by Agent tools and browser task endpoints. */
90
100
  runtime?: ImageGenerationRuntime
91
101
  }
92
102
 
103
+ /** Minimal canvas store contract so hosts can inject an isolated test backend. */
104
+ export interface CanvasBackend {
105
+ list: () => Promise<Awaited<ReturnType<CanvasStore['list']>>>
106
+ create: (title?: string) => Promise<Awaited<ReturnType<CanvasStore['create']>>>
107
+ read: (id: string) => Promise<Awaited<ReturnType<CanvasStore['read']>>>
108
+ save: (document: CanvasDocument, expectedRevision?: number) => Promise<Awaited<ReturnType<CanvasStore['save']>>>
109
+ remove: (id: string) => Promise<Awaited<ReturnType<CanvasStore['remove']>>>
110
+ putImage: (input: CanvasImageInput) => Promise<Awaited<ReturnType<CanvasStore['putImage']>>>
111
+ readAsset: (file: string) => Promise<Awaited<ReturnType<CanvasStore['readAsset']>>>
112
+ }
113
+
93
114
  /** Loopback literal check plus browser same-origin markers (mirrors dsh-ssh). */
94
115
  function isLoopbackRequest(request: IncomingMessage): boolean {
95
116
  const address = request.socket.remoteAddress
@@ -156,6 +177,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
156
177
  const comparisonModels = Array.isArray(body.comparisonModels)
157
178
  ? [...new Set(body.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
158
179
  : []
180
+ const canvas = parseCanvasMeta(body.canvas)
159
181
  return {
160
182
  mode: body.mode === 'edit' ? 'edit' : 'text',
161
183
  model: typeof body.model === 'string' ? body.model : '',
@@ -169,6 +191,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
169
191
  ...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
170
192
  ...typeof body.comparisonId === 'string' && body.comparisonId !== '' ? { comparisonId: body.comparisonId } : {},
171
193
  ...comparisonModels.length > 1 ? { comparisonModels } : {},
194
+ ...canvas === undefined ? {} : { canvas },
172
195
  ...body.workflow === 'ecommerce' ? { workflow: 'ecommerce' as const } : {},
173
196
  ...typeof body.projectId === 'string' && body.projectId !== '' ? { projectId: body.projectId } : {},
174
197
  ...typeof body.projectName === 'string' && body.projectName !== '' ? { projectName: body.projectName } : {},
@@ -177,6 +200,19 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
177
200
  }
178
201
  }
179
202
 
203
+ function parseCanvasMeta(value: unknown): GenerateRequest['canvas'] {
204
+ if (value === null || typeof value !== 'object') return undefined
205
+ const raw = value as Record<string, unknown>
206
+ if (typeof raw.canvasId !== 'string' || raw.canvasId.trim() === '') return undefined
207
+ return {
208
+ canvasId: raw.canvasId.trim(),
209
+ ...typeof raw.sourceNodeId === 'string' && raw.sourceNodeId.trim() !== '' ? { sourceNodeId: raw.sourceNodeId.trim() } : {},
210
+ ...typeof raw.annotationNodeId === 'string' && raw.annotationNodeId.trim() !== '' ? { annotationNodeId: raw.annotationNodeId.trim() } : {},
211
+ ...typeof raw.parentNodeId === 'string' && raw.parentNodeId.trim() !== '' ? { parentNodeId: raw.parentNodeId.trim() } : {},
212
+ ...raw.placement === 'right' || raw.placement === 'below' ? { placement: raw.placement } : {},
213
+ }
214
+ }
215
+
180
216
  /** Validate a submitted history entry (images carry base64). */
181
217
  function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInput | undefined {
182
218
  const raw = body.entry
@@ -202,6 +238,7 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
202
238
  const comparisonModels = Array.isArray(entry.comparisonModels)
203
239
  ? [...new Set(entry.comparisonModels.filter((model): model is string => typeof model === 'string').map(model => model.trim()).filter(Boolean))]
204
240
  : []
241
+ const canvas = parseCanvasMeta(entry.canvas)
205
242
  return {
206
243
  id: entry.id,
207
244
  createdAt: entry.createdAt,
@@ -218,6 +255,7 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
218
255
  ...typeof entry.channel === 'string' ? { channel: entry.channel } : {},
219
256
  ...typeof entry.comparisonId === 'string' ? { comparisonId: entry.comparisonId } : {},
220
257
  ...comparisonModels.length > 1 ? { comparisonModels } : {},
258
+ ...canvas === undefined ? {} : { canvas },
221
259
  ...entry.workflow === 'ecommerce' ? { workflow: 'ecommerce' as const } : {},
222
260
  ...typeof entry.projectId === 'string' ? { projectId: entry.projectId } : {},
223
261
  ...typeof entry.projectName === 'string' ? { projectName: entry.projectName } : {},
@@ -323,6 +361,7 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
323
361
  updateTags: updateGalleryTags,
324
362
  readImage: readGalleryImage,
325
363
  }
364
+ const canvas = deps.canvas ?? canvasStore
326
365
  const templates = deps.templates ?? {
327
366
  list: listTemplates,
328
367
  refresh: refreshTemplates,
@@ -923,6 +962,148 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
923
962
  res.end(found.data)
924
963
  },
925
964
  },
965
+ // ------------------------------------------------------ canvas list
966
+ {
967
+ kind: 'exact',
968
+ path: CANVAS_API.list,
969
+ handler: async (req, res) => {
970
+ if (!guard(req, res, 'POST')) return
971
+ try { writeJson(res, 200, { ok: true, projects: await canvas.list() }) }
972
+ catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-failed', message: messageOf(error) }) }
973
+ },
974
+ },
975
+ // ---------------------------------------------------- canvas create
976
+ {
977
+ kind: 'exact',
978
+ path: CANVAS_API.create,
979
+ handler: async (req, res) => {
980
+ if (!guard(req, res, 'POST')) return
981
+ const body = await readJsonBody(req)
982
+ const title = typeof body?.title === 'string' ? body.title : '未命名画布'
983
+ try { writeJson(res, 200, { ok: true, document: await canvas.create(title) }) }
984
+ catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-failed', message: messageOf(error) }) }
985
+ },
986
+ },
987
+ // ------------------------------------------------------ canvas read
988
+ {
989
+ kind: 'exact',
990
+ path: CANVAS_API.read,
991
+ handler: async (req, res) => {
992
+ if (!guard(req, res, 'POST')) return
993
+ const body = await readJsonBody(req)
994
+ const id = typeof body?.id === 'string' ? body.id : ''
995
+ const document = id === '' ? undefined : await canvas.read(id)
996
+ if (document === undefined) writeJson(res, 200, { ok: false, code: 'not-found', message: '画布不存在' })
997
+ else writeJson(res, 200, { ok: true, document })
998
+ },
999
+ },
1000
+ // ------------------------------------------------------ canvas save
1001
+ {
1002
+ kind: 'exact',
1003
+ path: CANVAS_API.save,
1004
+ handler: async (req, res) => {
1005
+ if (!guard(req, res, 'POST')) return
1006
+ const body = await readJsonBody(req)
1007
+ const document = body?.document as CanvasDocument | undefined
1008
+ const expectedRevision = typeof body?.expectedRevision === 'number' ? body.expectedRevision : undefined
1009
+ if (document === undefined || typeof document !== 'object') {
1010
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'canvas document is required' })
1011
+ return
1012
+ }
1013
+ try { writeJson(res, 200, { ok: true, document: await canvas.save(document, expectedRevision) }) }
1014
+ catch (error) {
1015
+ const code = error instanceof CanvasConflictError ? error.code : 'canvas-failed'
1016
+ writeJson(res, 200, { ok: false, code, message: messageOf(error) })
1017
+ }
1018
+ },
1019
+ },
1020
+ // ---------------------------------------------------- canvas remove
1021
+ {
1022
+ kind: 'exact',
1023
+ path: CANVAS_API.remove,
1024
+ handler: async (req, res) => {
1025
+ if (!guard(req, res, 'POST')) return
1026
+ const body = await readJsonBody(req)
1027
+ const id = typeof body?.id === 'string' ? body.id : ''
1028
+ if (id === '') { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'canvas id is required' }); return }
1029
+ try { writeJson(res, 200, { ok: true, projects: await canvas.remove(id) }) }
1030
+ catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-failed', message: messageOf(error) }) }
1031
+ },
1032
+ },
1033
+ // ------------------------------------------------ canvas asset upload
1034
+ {
1035
+ kind: 'exact',
1036
+ path: CANVAS_API.assetUpload,
1037
+ handler: async (req, res) => {
1038
+ if (!guard(req, res, 'POST')) return
1039
+ const body = await readJsonBody(req, MAX_HISTORY_BODY_BYTES)
1040
+ const parsed = typeof body?.dataUrl === 'string' ? imageDataUrl(body.dataUrl) : undefined
1041
+ const width = Number(body?.width)
1042
+ const height = Number(body?.height)
1043
+ if (parsed === undefined || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
1044
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'image data and dimensions are required' })
1045
+ return
1046
+ }
1047
+ try {
1048
+ const asset = await canvas.putImage({
1049
+ data: parsed.data,
1050
+ mime: parsed.mediaType,
1051
+ width,
1052
+ height,
1053
+ origin: body?.origin === 'history' || body?.origin === 'gallery' || body?.origin === 'generated' ? body.origin : 'upload',
1054
+ ...typeof body?.originId === 'string' ? { originId: body.originId } : {},
1055
+ ...typeof body?.entryId === 'string' ? { entryId: body.entryId } : {},
1056
+ ...Number.isSafeInteger(Number(body?.imageIndex)) ? { imageIndex: Number(body?.imageIndex) } : {},
1057
+ })
1058
+ writeJson(res, 200, { ok: true, asset })
1059
+ } catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-asset-failed', message: messageOf(error) }) }
1060
+ },
1061
+ },
1062
+ // ----------------------------------------------- canvas asset import
1063
+ {
1064
+ kind: 'exact',
1065
+ path: CANVAS_API.assetImport,
1066
+ handler: async (req, res) => {
1067
+ if (!guard(req, res, 'POST')) return
1068
+ const body = await readJsonBody(req)
1069
+ const source = body?.source === 'history' || body?.source === 'gallery' ? body.source : undefined
1070
+ const entryId = typeof body?.entryId === 'string' ? body.entryId : ''
1071
+ const imageIndex = Number(body?.imageIndex)
1072
+ const width = Number(body?.width)
1073
+ const height = Number(body?.height)
1074
+ if (source === undefined || entryId === '' || !Number.isSafeInteger(imageIndex) || imageIndex < 0
1075
+ || !Number.isSafeInteger(width) || width < 1 || !Number.isSafeInteger(height) || height < 1) {
1076
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'source, entryId, imageIndex and dimensions are required' })
1077
+ return
1078
+ }
1079
+ try {
1080
+ const backend = source === 'history' ? history : gallery
1081
+ const entry = (await backend.list()).find(item => item.id === entryId)
1082
+ const image = entry?.images[imageIndex]
1083
+ if (image === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'image not found' }); return }
1084
+ const base = source === 'history' ? HISTORY_API.image : GALLERY_API.image
1085
+ const file = imageFileFrom(image.url, base)
1086
+ const found = file === undefined ? undefined : await backend.readImage(file)
1087
+ if (found === undefined) { writeJson(res, 200, { ok: false, code: 'not-found', message: 'image not found' }); return }
1088
+ const asset = await canvas.putImage({ data: found.data, mime: found.mime, width, height, origin: source, originId: entryId, entryId, imageIndex })
1089
+ writeJson(res, 200, { ok: true, asset })
1090
+ } catch (error) { writeJson(res, 200, { ok: false, code: 'canvas-asset-failed', message: messageOf(error) }) }
1091
+ },
1092
+ },
1093
+ // ------------------------------------------- canvas asset (prefix)
1094
+ {
1095
+ kind: 'prefix',
1096
+ path: CANVAS_API.asset,
1097
+ handler: async (req, res) => {
1098
+ if (!isLoopbackRequest(req)) { writeJson(res, 403, { error: 'forbidden: loopback-only' }); return }
1099
+ if (req.method !== 'GET') { writeJson(res, 405, { error: `method not allowed: ${req.method}` }); return }
1100
+ const file = imageFileFrom(req.url, CANVAS_API.asset)
1101
+ const found = file === undefined ? undefined : await canvas.readAsset(file)
1102
+ if (found === undefined) { writeJson(res, 404, { error: 'not found' }); return }
1103
+ res.writeHead(200, { 'content-type': found.mime, 'content-length': found.data.length, 'cache-control': 'private, max-age=3600' })
1104
+ res.end(found.data)
1105
+ },
1106
+ },
926
1107
  // --------------------------------------------------- templates list
927
1108
  {
928
1109
  kind: 'exact',
@@ -1076,5 +1257,54 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
1076
1257
  }
1077
1258
  },
1078
1259
  },
1260
+ // ------------------------------------------- data folder: reveal in OS
1261
+ {
1262
+ kind: 'exact',
1263
+ path: DATA_FOLDER_API,
1264
+ handler: async (req, res) => {
1265
+ if (!guard(req, res, 'POST')) return
1266
+ const body = await readJsonBody(req)
1267
+ const dir = path.join(homedir(), '.dsh', 'dsh-imagegen')
1268
+ try {
1269
+ await fsMkdir(dir, { recursive: true })
1270
+ } catch { /* reveal still works when the directory already exists */ }
1271
+ if (body?.open === false) {
1272
+ // Wiring probe for tests: resolve the path without spawning a shell.
1273
+ writeJson(res, 200, { ok: true, path: dir })
1274
+ return
1275
+ }
1276
+ try {
1277
+ const command = process.platform === 'win32'
1278
+ ? 'explorer.exe'
1279
+ : process.platform === 'darwin'
1280
+ ? 'open'
1281
+ : 'xdg-open'
1282
+ const child = spawn(command, [dir], { detached: true, stdio: 'ignore' })
1283
+ child.unref()
1284
+ writeJson(res, 200, { ok: true, path: dir })
1285
+ } catch (error) {
1286
+ writeJson(res, 200, { ok: false, code: 'data-folder-failed', message: messageOf(error) })
1287
+ }
1288
+ },
1289
+ },
1290
+ // ------------------------------------------------- storage: probe upload
1291
+ {
1292
+ kind: 'exact',
1293
+ path: STORAGE_API.test,
1294
+ handler: async (req, res) => {
1295
+ if (!guard(req, res, 'POST')) return
1296
+ const storage = deps.resolveStorage?.()
1297
+ if (storage === undefined) {
1298
+ writeJson(res, 200, { ok: false, code: 'storage-unavailable', message: '存储配置不可用' })
1299
+ return
1300
+ }
1301
+ try {
1302
+ const result = await testStorage(storage)
1303
+ writeJson(res, 200, { ok: true, ms: result.ms, key: result.key })
1304
+ } catch (error) {
1305
+ writeJson(res, 200, { ok: false, code: 'storage-test-failed', message: messageOf(error) })
1306
+ }
1307
+ },
1308
+ },
1079
1309
  ]
1080
1310
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Object-storage sync for saved images: one S3-compatible uploader (SigV4,
3
+ * zero dependencies) that covers Tencent COS / Alibaba OSS / Qiniu S3 /
4
+ * MinIO / R2 style endpoints, plus a fire-and-forget hook the image stores
5
+ * call after a file lands on disk. The handler is registered by the plugin
6
+ * root (it owns the live settings), so framework-free stores stay decoupled
7
+ * from the settings seam.
8
+ *
9
+ * Object keys: `${prefix}/gallery/<file>` and `${prefix}/images/<file>` —
10
+ * content-addressed file names dedupe re-uploads naturally.
11
+ */
12
+
13
+ import { createHash, createHmac } from 'node:crypto'
14
+
15
+ /** The storage section of the plugin settings document. */
16
+ export interface StorageSyncConfig {
17
+ endpoint: string
18
+ region: string
19
+ accessKey: string
20
+ secretKey: string
21
+ prefix: string
22
+ }
23
+
24
+ /** Registered by index.ts: uploads `filePath` when the settings allow it. */
25
+ export type StorageUploadHandler = (kind: 'gallery' | 'history', filePath: string) => void
26
+
27
+ let uploadHandler: StorageUploadHandler | undefined
28
+
29
+ /** Register the live uploader (index.ts apply). Pass undefined to clear. */
30
+ export function setStorageSyncHandler(handler: StorageUploadHandler | undefined): void {
31
+ uploadHandler = handler
32
+ }
33
+
34
+ /** Fire-and-forget notification from the image stores after a file write. */
35
+ export function notifyImageSaved(kind: 'gallery' | 'history', filePath: string): void {
36
+ try {
37
+ uploadHandler?.(kind, filePath)
38
+ } catch {
39
+ // A sync failure must never break the save path.
40
+ }
41
+ }
42
+
43
+ /** URL-encode per RFC 3986 (AWS SigV4 canonical forms). */
44
+ function uriEncode(value: string, encodeSlash = true): string {
45
+ return value.replace(/[^A-Za-z0-9-_.~]/g, char => {
46
+ const hex = char.charCodeAt(0).toString(16).toUpperCase()
47
+ return `%${hex.padStart(2, '0')}`
48
+ }).replace(/%2F/g, encodeSlash ? '%2F' : '/')
49
+ }
50
+
51
+ /** HMAC-SHA256 helper. */
52
+ function hmac(key: Buffer | string, data: string): Buffer {
53
+ return createHmac('sha256', key).update(data, 'utf8').digest()
54
+ }
55
+
56
+ /**
57
+ * PUT one object to an S3-compatible endpoint (SigV4, virtual-hosted or
58
+ * path-style — the endpoint URL already includes the bucket). Returns the
59
+ * elapsed milliseconds so the settings card can show a latency reading.
60
+ */
61
+ export async function putObject(config: StorageSyncConfig, key: string, data: Buffer, contentType = 'application/octet-stream'): Promise<{ ms: number }> {
62
+ const endpoint = config.endpoint.trim().replace(/\/+$/, '')
63
+ if (endpoint === '' || config.accessKey.trim() === '' || config.secretKey.trim() === '') {
64
+ throw new Error('对象存储配置不完整:请填写接口地址与密钥')
65
+ }
66
+ const url = new URL(`${endpoint}/${key.split('/').map(part => uriEncode(part)).join('/')}`)
67
+ const payloadHash = createHash('sha256').update(data).digest('hex')
68
+ const now = new Date()
69
+ const amzDate = `${now.toISOString().replace(/[:-]|\.\d{3}/g, '')}` // YYYYMMDDTHHMMSSZ
70
+ const dateStamp = amzDate.slice(0, 8)
71
+ const host = url.host
72
+ const canonicalUri = url.pathname
73
+ const canonicalHeaders = `content-type:${contentType}\nhost:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
74
+ const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'
75
+ const canonicalRequest = `PUT\n${canonicalUri}\n\n${canonicalHeaders}\n${signedHeaders}\n${payloadHash}`
76
+ const scope = `${dateStamp}/${config.region.trim() || 'us-east-1'}/s3/aws4_request`
77
+ const stringToSign = `AWS4-HMAC-SHA256\n${amzDate}\n${scope}\n${createHash('sha256').update(canonicalRequest, 'utf8').digest('hex')}`
78
+ const signingKey = hmac(hmac(hmac(hmac(`AWS4${config.secretKey.trim()}`, dateStamp), config.region.trim() || 'us-east-1'), 's3'), 'aws4_request')
79
+ const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex')
80
+ const authorization = `AWS4-HMAC-SHA256 Credential=${config.accessKey.trim()}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
81
+
82
+ const started = Date.now()
83
+ const response = await fetch(url, {
84
+ method: 'PUT',
85
+ headers: {
86
+ 'content-type': contentType,
87
+ 'x-amz-content-sha256': payloadHash,
88
+ 'x-amz-date': amzDate,
89
+ authorization,
90
+ },
91
+ body: new Uint8Array(data),
92
+ })
93
+ if (!response.ok) {
94
+ const text = await response.text().catch(() => '')
95
+ throw new Error(`对象存储拒绝上传(HTTP ${response.status})${text !== '' ? `:${text.slice(0, 200)}` : ''}`)
96
+ }
97
+ return { ms: Date.now() - started }
98
+ }
99
+
100
+ /** Upload a small probe object; used by the settings card's test button. */
101
+ export async function testStorage(config: StorageSyncConfig): Promise<{ ms: number; key: string }> {
102
+ const key = `${config.prefix.trim() || 'dsh-imagegen'}/ping.txt`
103
+ const { ms } = await putObject(config, key, Buffer.from('dsh-imagegen storage ok', 'utf8'), 'text/plain')
104
+ return { ms, key }
105
+ }