Skip to content

Commit 57ab785

Browse files
fix(blossom): send the real content type on upload
every upload went out as application/octet-stream because no caller passes contentType, and a blossom server that sniffs the body rejects the mismatch: uploading a valid png returned 400 'Content-Type header does not match the file content, expected image/png'. detect from the leading bytes rather than the file name. the bytes are what the server checks, and an uploader should not be able to mislabel a blob by renaming it. png, jpeg, gif, webp, pdf and svg, falling back to octet-stream when nothing matches.
1 parent 7c93739 commit 57ab785

2 files changed

Lines changed: 55 additions & 1 deletion

File tree

src/social/blossom.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,36 @@ export interface BlobDescriptor {
1818
uploaded?: number
1919
}
2020

21+
// Blossom servers sniff the body and reject a mismatched Content-Type, so
22+
// `application/octet-stream` fails for any format they recognise. Detect from
23+
// the leading bytes rather than the file name: the bytes are what the server
24+
// checks, and an uploader should not be able to mislabel a blob by renaming it.
25+
const MAGIC: { type: string; test: (b: Uint8Array) => boolean }[] = [
26+
{ type: 'image/png', test: (b) => b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47 },
27+
{ type: 'image/jpeg', test: (b) => b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
28+
{ type: 'image/gif', test: (b) => b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 },
29+
{
30+
type: 'image/webp',
31+
test: (b) =>
32+
b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
33+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50,
34+
},
35+
{ type: 'application/pdf', test: (b) => b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 },
36+
{ type: 'image/svg+xml', test: (b) => Buffer.from(b.subarray(0, 256)).toString('utf8').includes('<svg') },
37+
]
38+
39+
export function detectContentType(body: Uint8Array): string | undefined {
40+
if (body.byteLength < 4) return undefined
41+
for (const { type, test } of MAGIC) {
42+
try {
43+
if (test(body)) return type
44+
} catch {
45+
// a short or odd buffer simply does not match this signature
46+
}
47+
}
48+
return undefined
49+
}
50+
2151
/** Upload a file to a blossom media server */
2252
export async function handleBlossomUpload(
2353
ctx: SigningContext,
@@ -62,7 +92,7 @@ export async function handleBlossomUpload(
6292
method: 'PUT',
6393
headers: {
6494
Authorization: authHeader,
65-
'Content-Type': args.contentType ?? 'application/octet-stream',
95+
'Content-Type': args.contentType ?? detectContentType(body) ?? 'application/octet-stream',
6696
},
6797
body: Buffer.from(body),
6898
signal: AbortSignal.timeout(30_000),

test/social/blossom.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
handleBlossomUsage,
1515
handleBlossomServersGet,
1616
handleBlossomServersSet,
17+
detectContentType,
1718
} from '../../src/social/blossom.js'
1819

1920
const TEST_NSEC = 'nsec1cxymst7yntfnvt4vkztk54q9muks6n77dn7qyhjpcvlxtkc6hy2s0364r8'
@@ -737,3 +738,26 @@ describe('blossom handlers', () => {
737738
})
738739
})
739740
})
741+
742+
describe('detectContentType', () => {
743+
it('recognises the formats a blossom server sniffs for', () => {
744+
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
745+
const jpeg = new Uint8Array([0xff, 0xd8, 0xff, 0xe0])
746+
const gif = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
747+
const webp = new Uint8Array([
748+
0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50,
749+
])
750+
const pdf = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])
751+
expect(detectContentType(png)).toBe('image/png')
752+
expect(detectContentType(jpeg)).toBe('image/jpeg')
753+
expect(detectContentType(gif)).toBe('image/gif')
754+
expect(detectContentType(webp)).toBe('image/webp')
755+
expect(detectContentType(pdf)).toBe('application/pdf')
756+
})
757+
758+
it('falls back to undefined rather than guessing', () => {
759+
expect(detectContentType(new Uint8Array([1, 2, 3, 4, 5]))).toBeUndefined()
760+
expect(detectContentType(new Uint8Array([1, 2]))).toBeUndefined()
761+
expect(detectContentType(new Uint8Array())).toBeUndefined()
762+
})
763+
})

0 commit comments

Comments
 (0)