diff --git a/src/errors.js b/src/errors.js index 7b24327..adc7607 100644 --- a/src/errors.js +++ b/src/errors.js @@ -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`) diff --git a/src/routes.js b/src/routes.js index b04a28e..01f202d 100644 --- a/src/routes.js +++ b/src/routes.js @@ -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 @@ -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) diff --git a/test/attachments-endpoint.js b/test/attachments-endpoint.js new file mode 100644 index 0000000..04b0f87 --- /dev/null +++ b/test/attachments-endpoint.js @@ -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') +})