fix(storage): parse S3 responses as XML
- Replace regex-based list and error parsing with xmldom - Decode entities through the parser and support namespaced pagination responses - Treat malformed listing documents as empty pages
This commit is contained in:
parent
42d3122323
commit
858e6451de
1
bun.lock
1
bun.lock
|
|
@ -35,6 +35,7 @@
|
|||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"ai": "^6.0.174",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"ai": "^6.0.174",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import { AwsClient } from 'aws4fetch'
|
|||
import { storageFetch } from '@/app/integrations/storage/s3/fetch'
|
||||
import { inferS3Region } from '@/app/integrations/storage/s3/region'
|
||||
import type { S3CompatibleConfig } from '@/app/integrations/storage/s3/types'
|
||||
import {
|
||||
parseListObjectsV2Page,
|
||||
parseS3ErrorXml,
|
||||
type ListedObject
|
||||
} from '@/app/integrations/storage/s3/xml'
|
||||
|
||||
export function resolveS3Region(config: S3CompatibleConfig): string {
|
||||
const explicit = config.region?.trim()
|
||||
|
|
@ -50,13 +55,7 @@ export function createAwsClient(config: S3CompatibleConfig): AwsClient {
|
|||
|
||||
async function readErrorBody(res: Response): Promise<{ message: string; code: string | null }> {
|
||||
const text = await res.text().catch(() => '')
|
||||
const codeMatch = text.match(/<Code>([^<]+)<\/Code>/i)
|
||||
const messageMatch = text.match(/<Message>([^<]+)<\/Message>/i)
|
||||
const code = codeMatch?.[1] ?? null
|
||||
const message =
|
||||
messageMatch?.[1] ??
|
||||
(text.trim() ? text.trim().slice(0, 200) : `S3 request failed with status ${res.status}`)
|
||||
return { message, code }
|
||||
return parseS3ErrorXml(text, res.status)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,59 +236,6 @@ export async function deleteObject(config: S3CompatibleConfig, key: string): Pro
|
|||
}
|
||||
}
|
||||
|
||||
export type ListedObject = {
|
||||
key: string
|
||||
lastModified: string | null
|
||||
size: number | null
|
||||
}
|
||||
|
||||
export type ListObjectsPage = {
|
||||
objects: ListedObject[]
|
||||
isTruncated: boolean
|
||||
nextContinuationToken: string | null
|
||||
}
|
||||
|
||||
/** Parse ListObjectsV2 XML into key entries. Pure for unit tests. */
|
||||
export function parseListObjectsV2Xml(xml: string): ListedObject[] {
|
||||
return parseListObjectsV2Page(xml).objects
|
||||
}
|
||||
|
||||
/** Parse ListObjectsV2 XML including pagination fields. */
|
||||
export function parseListObjectsV2Page(xml: string): ListObjectsPage {
|
||||
const contents = [...xml.matchAll(/<Contents>([\s\S]*?)<\/Contents>/gi)]
|
||||
const items: ListedObject[] = []
|
||||
for (const match of contents) {
|
||||
const block = match[1] ?? ''
|
||||
const key = block.match(/<Key>([^<]*)<\/Key>/i)?.[1]
|
||||
if (!key) continue
|
||||
const lastModified = block.match(/<LastModified>([^<]*)<\/LastModified>/i)?.[1] ?? null
|
||||
const sizeRaw = block.match(/<Size>([^<]*)<\/Size>/i)?.[1]
|
||||
const size = sizeRaw != null && sizeRaw !== '' ? Number(sizeRaw) : null
|
||||
items.push({
|
||||
key: decodeXmlEntities(key),
|
||||
lastModified,
|
||||
size: Number.isFinite(size) ? size : null
|
||||
})
|
||||
}
|
||||
const truncatedRaw = xml.match(/<IsTruncated>([^<]*)<\/IsTruncated>/i)?.[1]
|
||||
const isTruncated = truncatedRaw?.trim().toLowerCase() === 'true'
|
||||
const tokenRaw = xml.match(/<NextContinuationToken>([^<]*)<\/NextContinuationToken>/i)?.[1]
|
||||
return {
|
||||
objects: items,
|
||||
isTruncated,
|
||||
nextContinuationToken: tokenRaw ? decodeXmlEntities(tokenRaw) : null
|
||||
}
|
||||
}
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
export async function listObjects(
|
||||
config: S3CompatibleConfig,
|
||||
prefix: string
|
||||
|
|
|
|||
76
src/app/integrations/storage/s3/xml.ts
Normal file
76
src/app/integrations/storage/s3/xml.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { DOMParser, type Document, type Element } from '@xmldom/xmldom'
|
||||
|
||||
export type ListedObject = {
|
||||
key: string
|
||||
lastModified: string | null
|
||||
size: number | null
|
||||
}
|
||||
|
||||
export type ListObjectsPage = {
|
||||
objects: ListedObject[]
|
||||
isTruncated: boolean
|
||||
nextContinuationToken: string | null
|
||||
}
|
||||
|
||||
function parseXML(source: string): Document | null {
|
||||
try {
|
||||
return new DOMParser({
|
||||
onError: (level, message) => {
|
||||
if (level !== 'warning') throw new Error(message)
|
||||
}
|
||||
}).parseFromString(source, 'application/xml')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function elementsByName(root: Document | Element, name: string): Element[] {
|
||||
return Array.from(root.getElementsByTagNameNS('*', name))
|
||||
}
|
||||
|
||||
function firstText(root: Document | Element, name: string): string | null {
|
||||
return elementsByName(root, name)[0]?.textContent ?? null
|
||||
}
|
||||
|
||||
export function parseS3ErrorXml(
|
||||
source: string,
|
||||
status: number
|
||||
): { message: string; code: string | null } {
|
||||
const xmlDocument = parseXML(source)
|
||||
const code = xmlDocument ? firstText(xmlDocument, 'Code') : null
|
||||
const xmlMessage = xmlDocument ? firstText(xmlDocument, 'Message') : null
|
||||
const message =
|
||||
xmlMessage ??
|
||||
(source.trim() ? source.trim().slice(0, 200) : `S3 request failed with status ${status}`)
|
||||
return { message, code }
|
||||
}
|
||||
|
||||
export function parseListObjectsV2Xml(source: string): ListedObject[] {
|
||||
return parseListObjectsV2Page(source).objects
|
||||
}
|
||||
|
||||
export function parseListObjectsV2Page(source: string): ListObjectsPage {
|
||||
const xmlDocument = parseXML(source)
|
||||
if (!xmlDocument) {
|
||||
return { objects: [], isTruncated: false, nextContinuationToken: null }
|
||||
}
|
||||
|
||||
const objects = elementsByName(xmlDocument, 'Contents').flatMap((content) => {
|
||||
const key = firstText(content, 'Key')
|
||||
if (!key) return []
|
||||
const sizeText = firstText(content, 'Size')
|
||||
const size = sizeText ? Number(sizeText) : null
|
||||
return [
|
||||
{
|
||||
key,
|
||||
lastModified: firstText(content, 'LastModified'),
|
||||
size: Number.isFinite(size) ? size : null
|
||||
}
|
||||
]
|
||||
})
|
||||
return {
|
||||
objects,
|
||||
isTruncated: firstText(xmlDocument, 'IsTruncated')?.trim().toLowerCase() === 'true',
|
||||
nextContinuationToken: firstText(xmlDocument, 'NextContinuationToken')
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import { documentIdFromFigKey } from '@/app/integrations/storage/namespace'
|
||||
import { parseListObjectsV2Xml } from '@/app/integrations/storage/s3/client'
|
||||
import {
|
||||
parseListObjectsV2Page,
|
||||
parseListObjectsV2Xml,
|
||||
parseS3ErrorXml
|
||||
} from '@/app/integrations/storage/s3/xml'
|
||||
|
||||
describe('parseListObjectsV2Xml', () => {
|
||||
test('extracts keys and ignores objects outside canvas fig pattern when filtered', () => {
|
||||
|
|
@ -36,9 +40,28 @@ describe('parseListObjectsV2Xml', () => {
|
|||
expect(canvasIds).toEqual(['a1'])
|
||||
})
|
||||
|
||||
test('decodes basic XML entities in keys', () => {
|
||||
const xml = `<ListBucketResult><Contents><Key>open_pencil_storage/canvases/a&b.fig</Key><Size>1</Size></Contents></ListBucketResult>`
|
||||
const listed = parseListObjectsV2Xml(xml)
|
||||
expect(listed[0]?.key).toBe('open_pencil_storage/canvases/a&b.fig')
|
||||
test('decodes entities and reads namespaced pagination fields', () => {
|
||||
const xml = `<s3:ListBucketResult xmlns:s3="urn:s3"><s3:Contents><s3:Key>open_pencil_storage/canvases/a&b.fig</s3:Key><s3:Size>1</s3:Size></s3:Contents><s3:IsTruncated>true</s3:IsTruncated><s3:NextContinuationToken>a&b</s3:NextContinuationToken></s3:ListBucketResult>`
|
||||
const page = parseListObjectsV2Page(xml)
|
||||
expect(page.objects[0]?.key).toBe('open_pencil_storage/canvases/a&b.fig')
|
||||
expect(page.isTruncated).toBe(true)
|
||||
expect(page.nextContinuationToken).toBe('a&b')
|
||||
})
|
||||
|
||||
test('parses S3 error XML without interpreting markup-like text', () => {
|
||||
expect(
|
||||
parseS3ErrorXml(
|
||||
`<Error><Code>AccessDenied</Code><Message>Key contains <Code>fake</Code></Message></Error>`,
|
||||
403
|
||||
)
|
||||
).toEqual({ code: 'AccessDenied', message: 'Key contains <Code>fake</Code>' })
|
||||
})
|
||||
|
||||
test('returns an empty page for malformed XML', () => {
|
||||
expect(parseListObjectsV2Page('<ListBucketResult><Contents>')).toEqual({
|
||||
objects: [],
|
||||
isTruncated: false,
|
||||
nextContinuationToken: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue