Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ export const projectNotFoundError = () =>
export const iconNotFoundErrror = () =>
new HttpError(404, 'ICON_NOT_FOUND', 'Icon not found')

export const attachmentUpstreamUnavailableError = () =>
new HttpError(
502,
'ATTACHMENT_UPSTREAM_UNAVAILABLE',
'Could not read attachment; it may not be fully synced to this server yet',
)

/** @param {never} value */
export const shouldBeImpossibleError = (value) =>
new Error(`${value} should be impossible`)
Expand Down
22 changes: 21 additions & 1 deletion src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ const SUPPORTED_ATTACHMENT_TYPES = new Set(
/** @type {const} */ (['photo', 'audio']),
)

// Headers that describe the upstream connection, not the attachment (RFC 9110 7.6.1)
const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
])

/**
* @typedef {object} RouteOptions
* @prop {string} serverBearerToken
Expand Down Expand Up @@ -518,9 +530,17 @@ export default async function routes(
...typeAndVariant,
})

const proxiedResponse = await fetch(blobUrl)
let proxiedResponse
try {
proxiedResponse = await fetch(blobUrl)
} catch (err) {
// Core's blob server resets the socket when a blob's early blocks are missing
req.log.warn({ err }, 'attachment blob fetch failed')
throw errors.attachmentUpstreamUnavailableError()
}
reply.code(proxiedResponse.status)
for (const [headerName, headerValue] of proxiedResponse.headers) {
if (HOP_BY_HOP_HEADERS.has(headerName)) continue
reply.header(headerName, headerValue)
}
return reply.send(proxiedResponse.body)
Expand Down
56 changes: 56 additions & 0 deletions test/attachments-endpoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import { randomBytes } from 'node:crypto'
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import test from 'node:test'

import { BEARER_TOKEN, createTestServer } from './test-helpers.js'

// Test-only internals, not in core's package exports or public types
const { kBlobStore, kCoreManager } = await import(
new URL('../node_modules/@comapeo/core/src/mapeo-project.js', import.meta.url)
.href
)

test('returns 502 for an attachment that is only partly synced', async (t) => {
const server = createTestServer(t)
await server.listen()

const projectPublicId = await server.comapeo.createProject({
name: 'project',
})
const project = await server.comapeo.getProject(projectPublicId)
const internals = /** @type {any} */ (project)

const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'comapeo-cloud-test-'))
t.after(() => fs.rm(dir, { recursive: true, force: true }))
const filePath = path.join(dir, 'original.jpg')
// Three hypercore blocks (64 KiB each), so a partial blob is possible
await fs.writeFile(filePath, randomBytes(3 * 64 * 1024))

const blob = await project.$blobs.create(
{ original: filePath },
{ mimeType: 'image/jpeg' },
)
const entry = await internals[kBlobStore].entry({
driveId: blob.driveId,
type: 'photo',
variant: 'original',
name: blob.name,
})
assert(entry, 'test setup: blob entry exists')
const { blockOffset, blockLength } = entry.value.blob
// Keep block 0 and drop the rest, like an original mid-way through sync
await internals[kCoreManager]
.getWriterCore('blob')
.core.clear(blockOffset + 1, blockOffset + blockLength)

const response = await server.inject({
method: 'GET',
url: `/projects/${projectPublicId}/attachments/${blob.driveId}/photo/${blob.name}`,
headers: { Authorization: 'Bearer ' + BEARER_TOKEN },
})
assert.equal(response.statusCode, 502)
assert.equal(response.json().error.code, 'ATTACHMENT_UPSTREAM_UNAVAILABLE')
})
Loading