Add web multimodal image attachments
This commit is contained in:
121
packages/attachment/attachment-local/src/store.ts
Normal file
121
packages/attachment/attachment-local/src/store.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/** Content-addressed, owner-private local attachment storage. */
|
||||
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { constants } from 'node:fs'
|
||||
import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises'
|
||||
import { basename, join } from 'node:path'
|
||||
import {
|
||||
AttachmentError,
|
||||
AttachmentId,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { detectImage } from './image.ts'
|
||||
|
||||
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
|
||||
|
||||
function digest(data: Uint8Array): string {
|
||||
return createHash('sha256').update(data).digest('hex')
|
||||
}
|
||||
|
||||
function displayName(value: string | undefined): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const clean = basename(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255)
|
||||
return clean === '' ? undefined : clean
|
||||
}
|
||||
|
||||
function objectPath(root: string, sha256: string): string {
|
||||
return join(root, 'objects', sha256.slice(0, 2), sha256)
|
||||
}
|
||||
|
||||
function ensureReference(ref: ImageAttachmentRef): string {
|
||||
const match = ID_PATTERN.exec(String(ref.attachmentId))
|
||||
if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF')
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function validateMetadata(data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits): Omit<ImageAttachmentRef, 'attachmentId' | 'name'> {
|
||||
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
|
||||
if (data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
const detected = detectImage(data)
|
||||
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
|
||||
if (detected.width * detected.height > limits.maxImagePixels) throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS')
|
||||
return { ...detected, bytes: data.byteLength }
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and verify immutable image bytes below a versioned attachment root.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param input - encoded bytes and declared metadata.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns durable content-addressed reference.
|
||||
*/
|
||||
export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<ImageAttachmentRef> {
|
||||
const metadata = validateMetadata(input.data, input.mediaType, limits)
|
||||
const sha256 = digest(input.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
await mkdir(bucket, { recursive: true, mode: 0o700 })
|
||||
await mkdir(staging, { recursive: true, mode: 0o700 })
|
||||
await chmod(bucket, 0o700)
|
||||
await chmod(staging, 0o700)
|
||||
const temporary = join(staging, randomUUID())
|
||||
const target = objectPath(root, sha256)
|
||||
let handle
|
||||
try {
|
||||
handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
|
||||
await handle.writeFile(input.data)
|
||||
await handle.sync()
|
||||
await handle.close()
|
||||
handle = undefined
|
||||
try {
|
||||
await link(temporary, target)
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
|
||||
const existing = new Uint8Array(await readFile(target))
|
||||
if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
await unlink(temporary)
|
||||
} catch (error) {
|
||||
if (handle !== undefined) await handle.close().catch(() => { /* close failure is superseded by the storage failure */ })
|
||||
await unlink(temporary).catch((cleanupError: unknown) => {
|
||||
if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError
|
||||
})
|
||||
if (error instanceof AttachmentError) throw error
|
||||
throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error })
|
||||
}
|
||||
const name = displayName(input.name)
|
||||
return {
|
||||
attachmentId: AttachmentId(`sha256:${sha256}`),
|
||||
...metadata,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and verify one content-addressed image.
|
||||
* @param root - absolute `DSH_HOME/attachments/v1` root.
|
||||
* @param ref - reference recorded in the session log.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns verified bytes and reference.
|
||||
*/
|
||||
export async function readImageFile(root: string, ref: ImageAttachmentRef, limits: ImageAttachmentLimits): Promise<StoredImageAttachment> {
|
||||
const sha256 = ensureReference(ref)
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = new Uint8Array(await readFile(objectPath(root, sha256)))
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND')
|
||||
throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error })
|
||||
}
|
||||
if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT')
|
||||
const metadata = validateMetadata(data, ref.mediaType, limits)
|
||||
if (metadata.bytes !== ref.bytes || metadata.width !== ref.width || metadata.height !== ref.height) {
|
||||
throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT')
|
||||
}
|
||||
return { ref, data }
|
||||
}
|
||||
Reference in New Issue
Block a user